返回 DeepSeek-Reasonix
ensure_test.go
根目录 / internal / remote / bootstrap / ensure_test.go
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
305 lines GO