| 1 | package bootstrap |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "golang.org/x/crypto/ssh" |
| 14 | |
| 15 | "reasonix/internal/remote" |
| 16 | "reasonix/internal/remote/sftpfs" |
| 17 | "reasonix/internal/remote/sshtest" |
| 18 | ) |
| 19 | |
| 20 | // fakeConn scripts exec responses and shares a real sftpfs.FS backed by an |
| 21 | // sshtest SFTP server rooted at a temp dir. The temp dir stands in for the |
| 22 | // remote home, so ~ resolves to it. |
| 23 | type fakeConn struct { |
| 24 | fs *sftpfs.FS |
| 25 | sftpErr error |
| 26 | mu sync.Mutex |
| 27 | execs []string |
| 28 | handler func(cmd string) (remote.ExecResult, error) |
| 29 | } |
| 30 | |
| 31 | func (f *fakeConn) Exec(_ context.Context, cmd string) (remote.ExecResult, error) { |
| 32 | f.mu.Lock() |
| 33 | f.execs = append(f.execs, cmd) |
| 34 | f.mu.Unlock() |
| 35 | return f.handler(cmd) |
| 36 | } |
| 37 | |
| 38 | func (f *fakeConn) SFTP() (*sftpfs.FS, error) { |
| 39 | if f.sftpErr != nil { |
| 40 | return nil, f.sftpErr |
| 41 | } |
| 42 | return f.fs, nil |
| 43 | } |
| 44 | |
| 45 | func (f *fakeConn) ranContaining(sub string) bool { |
| 46 | f.mu.Lock() |
| 47 | defer f.mu.Unlock() |
| 48 | for _, c := range f.execs { |
| 49 | if strings.Contains(c, sub) { |
| 50 | return true |
| 51 | } |
| 52 | } |
| 53 | return false |
| 54 | } |
| 55 | |
| 56 | // skipOnWindows guards the EnsureServe integration tests. They model a POSIX |
| 57 | // remote — pathsFor uses path.Join and the slug maps a POSIX home, while the |
| 58 | // SFTP harness serves the local FS. On Windows the temp-dir "remote home" is a |
| 59 | // drive path, so both the test's own pathsFor pre-writes and the harness break. |
| 60 | // This is a harness limitation, not a product one (V1 remotes are Linux/macOS); |
| 61 | // Linux/macOS CI covers these flows. Call it first thing in each such test, |
| 62 | // before any pathsFor/os setup. |
| 63 | func skipOnWindows(t *testing.T) { |
| 64 | t.Helper() |
| 65 | if runtime.GOOS == "windows" { |
| 66 | t.Skip("EnsureServe harness models a POSIX remote; exercised on Linux/macOS") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func newFakeConn(t *testing.T, root string, handler func(cmd string) (remote.ExecResult, error)) *fakeConn { |
| 71 | t.Helper() |
| 72 | skipOnWindows(t) |
| 73 | srv := sshtest.Start(t, sshtest.Options{SFTPRoot: root}) |
| 74 | cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second} |
| 75 | cl, err := ssh.Dial("tcp", srv.Addr, cfg) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | t.Cleanup(func() { cl.Close() }) |
| 80 | fs, err := sftpfs.New(cl) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | t.Cleanup(func() { fs.Close() }) |
| 85 | return &fakeConn{fs: fs, handler: handler} |
| 86 | } |
| 87 | |
| 88 | func ok(stdout string) (remote.ExecResult, error) { |
| 89 | return remote.ExecResult{Stdout: []byte(stdout)}, nil |
| 90 | } |
| 91 | |
| 92 | // TestEnsureServeLaunchesWhenAbsent drives a full cold start: no prior state, |
| 93 | // reasonix already on PATH, serve writes its port file. |
| 94 | func TestEnsureServeLaunchesWhenAbsent(t *testing.T) { |
| 95 | skipOnWindows(t) |
| 96 | root := t.TempDir() |
| 97 | var portFile string |
| 98 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 99 | switch { |
| 100 | case strings.Contains(cmd, "uname"): |
| 101 | return ok("Linux x86_64\n") |
| 102 | case strings.Contains(cmd, "command -v reasonix"): |
| 103 | // LocateCommand: report a path and a fresh version. |
| 104 | return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\n") |
| 105 | case strings.Contains(cmd, "nohup"): |
| 106 | // Simulate serve writing the port file, then echo the pid. |
| 107 | if portFile != "" { |
| 108 | _ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600) |
| 109 | } |
| 110 | return ok("54321\n") |
| 111 | case strings.Contains(cmd, "ps -p 54321"): |
| 112 | return ok("1\n") |
| 113 | default: |
| 114 | return ok("") |
| 115 | } |
| 116 | }) |
| 117 | // Discover the port-file path the bootstrap will use so the fake serve can |
| 118 | // write it. |
| 119 | paths := pathsFor(root, root) |
| 120 | portFile = paths.PortFile |
| 121 | |
| 122 | res, err := EnsureServe(context.Background(), conn, Options{ |
| 123 | Workspace: "~", |
| 124 | MinVersion: "1.0.0", |
| 125 | Clock: time.Now, |
| 126 | }) |
| 127 | if err != nil { |
| 128 | t.Fatalf("EnsureServe: %v", err) |
| 129 | } |
| 130 | if res.Reused { |
| 131 | t.Fatal("cold start should not report reuse") |
| 132 | } |
| 133 | if res.State.Addr != "127.0.0.1:44321" || res.State.PID != 54321 { |
| 134 | t.Fatalf("state wrong: %+v", res.State) |
| 135 | } |
| 136 | if res.Token == "" { |
| 137 | t.Fatal("no token generated") |
| 138 | } |
| 139 | // Token file written 0600. |
| 140 | fi, err := os.Stat(paths.TokenFile) |
| 141 | if err != nil { |
| 142 | t.Fatalf("token file missing: %v", err) |
| 143 | } |
| 144 | if fi.Mode().Perm() != 0o600 { |
| 145 | t.Fatalf("token perm = %v, want 0600", fi.Mode().Perm()) |
| 146 | } |
| 147 | // State file persisted and reloadable. |
| 148 | data, err := os.ReadFile(paths.StateJSON) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | st, err := UnmarshalState(data) |
| 153 | if err != nil || st.Addr != "127.0.0.1:44321" { |
| 154 | t.Fatalf("persisted state wrong: %+v (%v)", st, err) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // TestEnsureServeReusesLiveProcess: a recorded, alive pid short-circuits to |
| 159 | // reuse without detecting/launching. |
| 160 | func TestEnsureServeReusesLiveProcess(t *testing.T) { |
| 161 | skipOnWindows(t) |
| 162 | root := t.TempDir() |
| 163 | paths := pathsFor(root, root) |
| 164 | // Pre-write state + token as if a serve is already running. |
| 165 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | st := ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile} |
| 169 | data, _ := MarshalState(st) |
| 170 | if err := os.WriteFile(paths.StateJSON, data, 0o600); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | |
| 177 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 178 | if strings.Contains(cmd, "kill -0 777") { |
| 179 | return ok("1\n") // alive |
| 180 | } |
| 181 | if strings.Contains(cmd, "uname") || strings.Contains(cmd, "nohup") { |
| 182 | t.Errorf("reuse path should not detect/launch; ran: %s", cmd) |
| 183 | } |
| 184 | return ok("") |
| 185 | }) |
| 186 | |
| 187 | res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"}) |
| 188 | if err != nil { |
| 189 | t.Fatalf("EnsureServe: %v", err) |
| 190 | } |
| 191 | if !res.Reused { |
| 192 | t.Fatal("expected reuse of live process") |
| 193 | } |
| 194 | if res.Token != "existing-token" { |
| 195 | t.Fatalf("token = %q, want existing-token", res.Token) |
| 196 | } |
| 197 | if conn.ranContaining("nohup") { |
| 198 | t.Fatal("reuse path launched a new serve") |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // TestEnsureServeRelaunchesDeadProcess: a recorded but dead pid triggers a |
| 203 | // fresh launch. |
| 204 | func TestEnsureServeRelaunchesDeadProcess(t *testing.T) { |
| 205 | skipOnWindows(t) |
| 206 | root := t.TempDir() |
| 207 | paths := pathsFor(root, root) |
| 208 | if err := os.MkdirAll(paths.Dir, 0o755); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | st := ServeState{PID: 888, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile} |
| 212 | data, _ := MarshalState(st) |
| 213 | _ = os.WriteFile(paths.StateJSON, data, 0o600) |
| 214 | _ = os.WriteFile(paths.TokenFile, []byte("stale\n"), 0o600) |
| 215 | |
| 216 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 217 | switch { |
| 218 | case strings.Contains(cmd, "kill -0 888"): |
| 219 | return ok("0\n") // dead |
| 220 | case strings.Contains(cmd, "uname"): |
| 221 | return ok("Linux aarch64\n") |
| 222 | case strings.Contains(cmd, "command -v reasonix"): |
| 223 | return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\n") |
| 224 | case strings.Contains(cmd, "nohup"): |
| 225 | _ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:6001\n"), 0o600) |
| 226 | return ok("999\n") |
| 227 | case strings.Contains(cmd, "ps -p 999"): |
| 228 | return ok("1\n") |
| 229 | default: |
| 230 | return ok("") |
| 231 | } |
| 232 | }) |
| 233 | |
| 234 | res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0"}) |
| 235 | if err != nil { |
| 236 | t.Fatalf("EnsureServe: %v", err) |
| 237 | } |
| 238 | if res.Reused { |
| 239 | t.Fatal("dead process should be relaunched, not reused") |
| 240 | } |
| 241 | if res.State.PID != 999 || res.State.Addr != "127.0.0.1:6001" { |
| 242 | t.Fatalf("relaunched state wrong: %+v", res.State) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // TestEnsureServeInstallNeverErrorsWhenAbsent. |
| 247 | func TestEnsureServeInstallNeverErrorsWhenAbsent(t *testing.T) { |
| 248 | skipOnWindows(t) |
| 249 | root := t.TempDir() |
| 250 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 251 | switch { |
| 252 | case strings.Contains(cmd, "uname"): |
| 253 | return ok("Linux x86_64\n") |
| 254 | case strings.Contains(cmd, "command -v reasonix"): |
| 255 | return ok("\n") // not found anywhere |
| 256 | default: |
| 257 | return ok("") |
| 258 | } |
| 259 | }) |
| 260 | _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", Install: InstallNever}) |
| 261 | if err == nil || !strings.Contains(err.Error(), "serve_install = never") { |
| 262 | t.Fatalf("expected install-never error, got %v", err) |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | func TestStopRemovesStateFiles(t *testing.T) { |
| 267 | skipOnWindows(t) |
| 268 | root := t.TempDir() |
| 269 | paths := pathsFor(root, root) |
| 270 | _ = os.MkdirAll(paths.Dir, 0o755) |
| 271 | st := ServeState{PID: 555, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile} |
| 272 | data, _ := MarshalState(st) |
| 273 | _ = os.WriteFile(paths.StateJSON, data, 0o600) |
| 274 | _ = os.WriteFile(paths.TokenFile, []byte("tok\n"), 0o600) |
| 275 | |
| 276 | stopped := false |
| 277 | conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) { |
| 278 | // Order matters: StopCommand also contains "kill -0 555" in its wait |
| 279 | // loop, so match the TERM (the stop signal) before the serve-alive probe. |
| 280 | if strings.Contains(cmd, "kill -TERM 555") { |
| 281 | stopped = true |
| 282 | return ok("") |
| 283 | } |
| 284 | // Stop verifies the pid is our serve (ServeAliveCommand) before signalling. |
| 285 | if strings.Contains(cmd, "ps -p 555") { |
| 286 | return ok("1\n") |
| 287 | } |
| 288 | return ok("") |
| 289 | }) |
| 290 | if err := Stop(context.Background(), conn, "~"); err != nil { |
| 291 | t.Fatalf("Stop: %v", err) |
| 292 | } |
| 293 | if !stopped { |
| 294 | t.Error("Stop did not TERM the pid") |
| 295 | } |
| 296 | if _, err := os.Stat(paths.StateJSON); !os.IsNotExist(err) { |
| 297 | t.Error("state file not removed") |
| 298 | } |
| 299 | if _, err := os.Stat(paths.TokenFile); !os.IsNotExist(err) { |
| 300 | t.Error("token file not removed") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | var _ = filepath.Join |
| 305 |