返回 DeepSeek-Reasonix
ensure_test.go
根目录 / internal / remote / bootstrap / ensure_test.go
1 package bootstrap
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "golang.org/x/crypto/ssh"
15
16 "reasonix/internal/remote"
17 "reasonix/internal/remote/sftpfs"
18 "reasonix/internal/remote/sshtest"
19 )
20
21 // fakeConn scripts exec responses and shares a real sftpfs.FS backed by an
22 // sshtest SFTP server rooted at a temp dir. The temp dir stands in for the
23 // remote home, so ~ resolves to it.
24 type fakeConn struct {
25 fs *sftpfs.FS
26 sftpErr error
27 mu sync.Mutex
28 execs []string
29 handler func(cmd string) (remote.ExecResult, error)
30 }
31
32 func (f *fakeConn) Exec(_ context.Context, cmd string) (remote.ExecResult, error) {
33 f.mu.Lock()
34 f.execs = append(f.execs, cmd)
35 f.mu.Unlock()
36 return f.handler(cmd)
37 }
38
39 func (f *fakeConn) SFTP() (*sftpfs.FS, error) {
40 if f.sftpErr != nil {
41 return nil, f.sftpErr
42 }
43 return f.fs, nil
44 }
45
46 func (f *fakeConn) ranContaining(sub string) bool {
47 f.mu.Lock()
48 defer f.mu.Unlock()
49 for _, c := range f.execs {
50 if strings.Contains(c, sub) {
51 return true
52 }
53 }
54 return false
55 }
56
57 // skipOnWindows guards the EnsureServe integration tests. They model a POSIX
58 // remote — pathsFor uses path.Join and the slug maps a POSIX home, while the
59 // SFTP harness serves the local FS. On Windows the temp-dir "remote home" is a
60 // drive path, so both the test's own pathsFor pre-writes and the harness break.
61 // This is a harness limitation, not a product one (V1 remotes are Linux/macOS);
62 // Linux/macOS CI covers these flows. Call it first thing in each such test,
63 // before any pathsFor/os setup.
64 func skipOnWindows(t *testing.T) {
65 t.Helper()
66 if runtime.GOOS == "windows" {
67 t.Skip("EnsureServe harness models a POSIX remote; exercised on Linux/macOS")
68 }
69 }
70
71 func newFakeConn(t *testing.T, root string, handler func(cmd string) (remote.ExecResult, error)) *fakeConn {
72 t.Helper()
73 skipOnWindows(t)
74 srv := sshtest.Start(t, sshtest.Options{SFTPRoot: root})
75 cfg := &ssh.ClientConfig{User: "t", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}
76 cl, err := ssh.Dial("tcp", srv.Addr, cfg)
77 if err != nil {
78 t.Fatal(err)
79 }
80 t.Cleanup(func() { cl.Close() })
81 fs, err := sftpfs.New(cl)
82 if err != nil {
83 t.Fatal(err)
84 }
85 t.Cleanup(func() { fs.Close() })
86 return &fakeConn{fs: fs, handler: handler}
87 }
88
89 func ok(stdout string) (remote.ExecResult, error) {
90 return remote.ExecResult{Stdout: []byte(stdout)}, nil
91 }
92
93 // TestEnsureServeLaunchesWhenAbsent drives a full cold start: no prior state,
94 // reasonix already on PATH, serve writes its port file.
95 func TestEnsureServeLaunchesWhenAbsent(t *testing.T) {
96 skipOnWindows(t)
97 root := t.TempDir()
98 var portFile string
99 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
100 switch {
101 case strings.Contains(cmd, "uname"):
102 return ok("Linux x86_64\n")
103 case strings.Contains(cmd, "command -v reasonix"):
104 // LocateCommand: report a path and a fresh version.
105 return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n")
106 case strings.Contains(cmd, "nohup"):
107 // Simulate serve writing the port file, then echo the pid.
108 if portFile != "" {
109 _ = os.WriteFile(portFile, []byte("127.0.0.1:44321\n"), 0o600)
110 }
111 return ok("54321\n")
112 case strings.Contains(cmd, "ps -p 54321"):
113 return ok("1\n")
114 default:
115 return ok("")
116 }
117 })
118 // Discover the port-file path the bootstrap will use so the fake serve can
119 // write it.
120 paths := pathsFor(root, root)
121 portFile = paths.PortFile
122
123 res, err := EnsureServe(context.Background(), conn, Options{
124 Workspace: "~",
125 MinVersion: "1.0.0",
126 Clock: time.Now,
127 })
128 if err != nil {
129 t.Fatalf("EnsureServe: %v", err)
130 }
131 if res.Reused {
132 t.Fatal("cold start should not report reuse")
133 }
134 if res.State.Addr != "127.0.0.1:44321" || res.State.PID != 54321 {
135 t.Fatalf("state wrong: %+v", res.State)
136 }
137 if res.Token == "" {
138 t.Fatal("no token generated")
139 }
140 // Token file written 0600.
141 fi, err := os.Stat(paths.TokenFile)
142 if err != nil {
143 t.Fatalf("token file missing: %v", err)
144 }
145 if fi.Mode().Perm() != 0o600 {
146 t.Fatalf("token perm = %v, want 0600", fi.Mode().Perm())
147 }
148 // State file persisted and reloadable.
149 data, err := os.ReadFile(paths.StateJSON)
150 if err != nil {
151 t.Fatal(err)
152 }
153 st, err := UnmarshalState(data)
154 if err != nil || st.Addr != "127.0.0.1:44321" {
155 t.Fatalf("persisted state wrong: %+v (%v)", st, err)
156 }
157 }
158
159 // TestEnsureServeReusesLiveProcess: a recorded, alive pid short-circuits to
160 // reuse without detecting/launching.
161 func TestEnsureServeReusesLiveProcess(t *testing.T) {
162 skipOnWindows(t)
163 root := t.TempDir()
164 paths := pathsFor(root, root)
165 // Pre-write state + token as if a serve is already running.
166 if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
167 t.Fatal(err)
168 }
169 st := ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, ServeCaps: ServeCapsToken, TokenFile: paths.TokenFile}
170 data, _ := MarshalState(st)
171 if err := os.WriteFile(paths.StateJSON, data, 0o600); err != nil {
172 t.Fatal(err)
173 }
174 if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil {
175 t.Fatal(err)
176 }
177
178 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
179 if strings.Contains(cmd, "kill -0 777") {
180 return ok("1\n") // alive
181 }
182 if strings.Contains(cmd, "readlink /proc/777/exe") {
183 t.Fatal("managed capability token should avoid re-executing the live image")
184 }
185 if strings.Contains(cmd, "uname") || strings.Contains(cmd, "nohup") {
186 t.Errorf("reuse path should not detect/launch; ran: %s", cmd)
187 }
188 return ok("")
189 })
190
191 res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"})
192 if err != nil {
193 t.Fatalf("EnsureServe: %v", err)
194 }
195 if !res.Reused {
196 t.Fatal("expected reuse of live process")
197 }
198 if res.Token != "existing-token" {
199 t.Fatalf("token = %q, want existing-token", res.Token)
200 }
201 if conn.ranContaining("nohup") {
202 t.Fatal("reuse path launched a new serve")
203 }
204 }
205
206 // TestEnsureServeRelaunchesDeadProcess: a recorded but dead pid triggers a
207 // fresh launch.
208 func TestEnsureServeRelaunchesDeadProcess(t *testing.T) {
209 skipOnWindows(t)
210 root := t.TempDir()
211 paths := pathsFor(root, root)
212 if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
213 t.Fatal(err)
214 }
215 st := ServeState{PID: 888, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}
216 data, _ := MarshalState(st)
217 _ = os.WriteFile(paths.StateJSON, data, 0o600)
218 _ = os.WriteFile(paths.TokenFile, []byte("stale\n"), 0o600)
219
220 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
221 switch {
222 case strings.Contains(cmd, "kill -0 888"):
223 return ok("0\n") // dead
224 case strings.Contains(cmd, "uname"):
225 return ok("Linux aarch64\n")
226 case strings.Contains(cmd, "command -v reasonix"):
227 return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n")
228 case strings.Contains(cmd, "nohup"):
229 _ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:6001\n"), 0o600)
230 return ok("999\n")
231 case strings.Contains(cmd, "ps -p 999"):
232 return ok("1\n")
233 default:
234 return ok("")
235 }
236 })
237
238 res, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", MinVersion: "1.0.0"})
239 if err != nil {
240 t.Fatalf("EnsureServe: %v", err)
241 }
242 if res.Reused {
243 t.Fatal("dead process should be relaunched, not reused")
244 }
245 if res.State.PID != 999 || res.State.Addr != "127.0.0.1:6001" {
246 t.Fatalf("relaunched state wrong: %+v", res.State)
247 }
248 }
249
250 // TestEnsureServeInstallNeverErrorsWhenAbsent.
251 func TestEnsureServeInstallNeverErrorsWhenAbsent(t *testing.T) {
252 skipOnWindows(t)
253 root := t.TempDir()
254 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
255 switch {
256 case strings.Contains(cmd, "uname"):
257 return ok("Linux x86_64\n")
258 case strings.Contains(cmd, "command -v reasonix"):
259 return ok("\n") // not found anywhere
260 default:
261 return ok("")
262 }
263 })
264 _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", Install: InstallNever})
265 if err == nil || !strings.Contains(err.Error(), "serve_install = never") {
266 t.Fatalf("expected install-never error, got %v", err)
267 }
268 }
269
270 func TestEnsureServeUpgradeFailurePreservesOutdatedProcess(t *testing.T) {
271 skipOnWindows(t)
272 root := t.TempDir()
273 paths := pathsFor(root, root)
274 if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
275 t.Fatal(err)
276 }
277 state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile})
278 _ = os.WriteFile(paths.StateJSON, state, 0o600)
279 _ = os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600)
280 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
281 switch {
282 case strings.Contains(cmd, "kill -TERM 777"):
283 t.Fatal("outdated Serve was stopped before a replacement was available")
284 case strings.Contains(cmd, "ps -p 777"):
285 return ok("1\n")
286 case strings.Contains(cmd, "readlink /proc/777/exe"):
287 return ok("no\n")
288 case strings.Contains(cmd, "uname"):
289 return ok("Linux x86_64\n")
290 case strings.Contains(cmd, "command -v reasonix"):
291 return ok("\n")
292 }
293 return ok("")
294 })
295 _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~", Install: InstallNever})
296 if err == nil || !strings.Contains(err.Error(), "serve_install = never") {
297 t.Fatalf("upgrade error = %v, want install-never failure", err)
298 }
299 }
300
301 func TestEnsureServeTokenStageFailurePreservesOutdatedProcess(t *testing.T) {
302 skipOnWindows(t)
303 root := t.TempDir()
304 paths := pathsFor(root, root)
305 if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
306 t.Fatal(err)
307 }
308 state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile})
309 if err := os.WriteFile(paths.StateJSON, state, 0o600); err != nil {
310 t.Fatal(err)
311 }
312 if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil {
313 t.Fatal(err)
314 }
315 if err := os.Mkdir(paths.TokenFile+".next", 0o700); err != nil {
316 t.Fatal(err)
317 }
318 if err := os.WriteFile(filepath.Join(paths.TokenFile+".next", "block-rename"), []byte("x"), 0o600); err != nil {
319 t.Fatal(err)
320 }
321 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
322 switch {
323 case strings.Contains(cmd, "kill -TERM 777"):
324 t.Fatal("outdated Serve was stopped before the token was staged")
325 case strings.Contains(cmd, "kill -0 777"):
326 return ok("1\n")
327 case strings.Contains(cmd, "readlink /proc/777/exe"):
328 return ok("no\n")
329 case strings.Contains(cmd, "uname"):
330 return ok("Linux x86_64\n")
331 case strings.Contains(cmd, "command -v reasonix"):
332 return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n")
333 case strings.Contains(cmd, "nohup"):
334 t.Fatal("replacement launched after token staging failed")
335 }
336 return ok("")
337 })
338
339 _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"})
340 if err == nil || !strings.Contains(err.Error(), "stage token") {
341 t.Fatalf("staging error = %v, want token staging failure", err)
342 }
343 token, readErr := os.ReadFile(paths.TokenFile)
344 if readErr != nil || string(token) != "existing-token\n" {
345 t.Fatalf("token after staging failure = %q, %v", token, readErr)
346 }
347 }
348
349 func TestEnsureServeRetirementFailurePreservesExistingToken(t *testing.T) {
350 skipOnWindows(t)
351 root := t.TempDir()
352 paths := pathsFor(root, root)
353 if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
354 t.Fatal(err)
355 }
356 state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile})
357 if err := os.WriteFile(paths.StateJSON, state, 0o600); err != nil {
358 t.Fatal(err)
359 }
360 if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil {
361 t.Fatal(err)
362 }
363
364 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
365 switch {
366 case strings.Contains(cmd, "kill -TERM 777"):
367 return remote.ExecResult{}, errors.New("temporary SSH failure")
368 case strings.Contains(cmd, "kill -0 777"):
369 return ok("1\n")
370 case strings.Contains(cmd, "readlink /proc/777/exe"):
371 return ok("no\n")
372 case strings.Contains(cmd, "uname"):
373 return ok("Linux x86_64\n")
374 case strings.Contains(cmd, "command -v reasonix"):
375 return ok("/usr/bin/reasonix\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n")
376 case strings.Contains(cmd, "nohup"):
377 t.Fatal("replacement launched after retirement failed")
378 }
379 return ok("")
380 })
381
382 _, err := EnsureServe(context.Background(), conn, Options{Workspace: "~"})
383 if err == nil || !strings.Contains(err.Error(), "stop outdated serve") {
384 t.Fatalf("retirement error = %v, want stop failure", err)
385 }
386 token, readErr := os.ReadFile(paths.TokenFile)
387 if readErr != nil {
388 t.Fatal(readErr)
389 }
390 if got := string(token); got != "existing-token\n" {
391 t.Fatalf("token after failed retirement = %q, want existing token", got)
392 }
393 }
394
395 func TestEnsureServeDarwinRetiresOldPIDAfterBinaryReplacement(t *testing.T) {
396 skipOnWindows(t)
397 root := t.TempDir()
398 paths := pathsFor(root, root)
399 if err := os.MkdirAll(paths.Dir, 0o755); err != nil {
400 t.Fatal(err)
401 }
402 state, _ := MarshalState(ServeState{PID: 777, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile})
403 if err := os.WriteFile(paths.StateJSON, state, 0o600); err != nil {
404 t.Fatal(err)
405 }
406 if err := os.WriteFile(paths.TokenFile, []byte("existing-token\n"), 0o600); err != nil {
407 t.Fatal(err)
408 }
409 local := filepath.Join(root, "local-reasonix")
410 if err := os.WriteFile(local, []byte("fresh-darwin-cli"), 0o755); err != nil {
411 t.Fatal(err)
412 }
413 uploaded := uploadedBinPath(root)
414 stopped := false
415 capabilityProbes := 0
416 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
417 switch {
418 case strings.Contains(cmd, "kill -TERM 777"):
419 stopped = true
420 return ok("")
421 case strings.Contains(cmd, "kill -0 777"):
422 return ok("1\n")
423 case strings.Contains(cmd, "readlink /proc/777/exe"):
424 capabilityProbes++
425 if strings.Contains(cmd, "ps -p 777") {
426 t.Fatal("Darwin capability probe fell back to the replaced pathname")
427 }
428 return ok("no\n")
429 case strings.Contains(cmd, "uname"):
430 return ok("Darwin arm64\n")
431 case strings.Contains(cmd, "BIN=; if [ -x "+shellQuote(uploaded)):
432 return ok(uploaded + "\nreasonix v9.9.0\nportfile:yes\nsessionevents:yes\ndetachedheal:yes\ncaps:yes\n")
433 case strings.Contains(cmd, "command -v reasonix"):
434 return ok(uploaded + "\nreasonix v1.0.0\nportfile:yes\nsessionevents:no\ndetachedheal:no\ncaps:no\n")
435 case strings.Contains(cmd, "nohup"):
436 _ = os.WriteFile(paths.PortFile, []byte("127.0.0.1:6002\n"), 0o600)
437 return ok("999\n")
438 case strings.Contains(cmd, "kill -0 999"):
439 return ok("1\n")
440 default:
441 return ok("")
442 }
443 })
444
445 res, err := EnsureServe(context.Background(), conn, Options{
446 Workspace: "~", Install: InstallUpload, LocalBinary: local, LocalGOOS: "darwin", LocalGOARCH: "arm64",
447 })
448 if err != nil {
449 t.Fatal(err)
450 }
451 if res.Reused || !stopped || capabilityProbes < 2 || res.State.PID != 999 {
452 t.Fatalf("darwin replacement result = reused:%v stopped:%v probes:%d state:%+v", res.Reused, stopped, capabilityProbes, res.State)
453 }
454 if res.State.ServeCaps != ServeCapsToken {
455 t.Fatalf("launched state caps = %q, want %q", res.State.ServeCaps, ServeCapsToken)
456 }
457 }
458
459 func TestStopRemovesStateFiles(t *testing.T) {
460 skipOnWindows(t)
461 root := t.TempDir()
462 paths := pathsFor(root, root)
463 _ = os.MkdirAll(paths.Dir, 0o755)
464 st := ServeState{PID: 555, Addr: "127.0.0.1:5000", Workspace: root, TokenFile: paths.TokenFile}
465 data, _ := MarshalState(st)
466 _ = os.WriteFile(paths.StateJSON, data, 0o600)
467 _ = os.WriteFile(paths.TokenFile, []byte("tok\n"), 0o600)
468
469 stopped := false
470 conn := newFakeConn(t, root, func(cmd string) (remote.ExecResult, error) {
471 // Order matters: StopCommand also contains "kill -0 555" in its wait
472 // loop, so match the TERM (the stop signal) before the serve-alive probe.
473 if strings.Contains(cmd, "kill -TERM 555") {
474 stopped = true
475 return ok("")
476 }
477 // Stop verifies the pid is our serve (ServeAliveCommand) before signalling.
478 if strings.Contains(cmd, "ps -p 555") {
479 return ok("1\n")
480 }
481 return ok("")
482 })
483 if err := Stop(context.Background(), conn, "~"); err != nil {
484 t.Fatalf("Stop: %v", err)
485 }
486 if !stopped {
487 t.Error("Stop did not TERM the pid")
488 }
489 if _, err := os.Stat(paths.StateJSON); !os.IsNotExist(err) {
490 t.Error("state file not removed")
491 }
492 if _, err := os.Stat(paths.TokenFile); !os.IsNotExist(err) {
493 t.Error("token file not removed")
494 }
495 }
496
497 var _ = filepath.Join
498
498 lines GO