返回 DeepSeek-Reasonix
bootstrap.go
根目录 / internal / remote / bootstrap / bootstrap.go
1 // Package bootstrap starts and manages a detached `reasonix serve` process on
2 // a remote host over an established SSH connection. It detects the remote
3 // OS/arch, locates or installs reasonix, launches serve bound to a random
4 // loopback port with a file-based token (never in argv), and records the
5 // result under the remote ~/.reasonix/remote so a later reconnect can reuse
6 // it. V1 targets Linux and macOS remotes.
7 package bootstrap
8
9 import (
10 "context"
11 "crypto/rand"
12 "encoding/hex"
13 "errors"
14 "fmt"
15 "io"
16 "net"
17 "strconv"
18 "strings"
19 "time"
20
21 "reasonix/internal/remote"
22 "reasonix/internal/remote/sftpfs"
23 )
24
25 // Conn is the subset of *remote.Client bootstrap needs. *remote.Client
26 // satisfies it directly; tests inject a fake. bootstrap depends on remote
27 // (never the reverse), so using remote.ExecResult here introduces no cycle.
28 type Conn interface {
29 Exec(ctx context.Context, cmd string) (remote.ExecResult, error)
30 SFTP() (*sftpfs.FS, error)
31 }
32
33 // Install strategies.
34 const (
35 InstallAuto = "auto"
36 InstallNPM = "npm"
37 InstallUpload = "upload"
38 InstallNever = "never"
39 )
40
41 // MinServeVersion is retained for display/informational use only. Usability is
42 // decided by probing `serve --help` for the --port-file flag (see locate), not
43 // by a version number: --port-file/--token-file ship in this change, so no
44 // released version satisfies a numeric gate, and the release number this change
45 // lands in is unknown at authoring time.
46 const MinServeVersion = "flag:port-file"
47
48 // Options configures EnsureServe.
49 type Options struct {
50 Workspace string // remote workspace path (may start with ~)
51 Install string // auto|npm|upload|never
52 LocalBinary string // path to the running reasonix binary, for same-platform upload
53 LocalGOOS string // GOOS of LocalBinary
54 LocalGOARCH string // GOARCH of LocalBinary
55 ProductVersion string // exact local release used for a cross-platform official download
56 FetchBinary func(context.Context, string, string, string) ([]byte, error) // local verified release fetcher
57 MinVersion string // minimum acceptable remote version
58 Progress func(step, detail string) // optional progress callback
59 Clock func() time.Time // nil => time.Now
60 }
61
62 func (o Options) progress(step, detail string) {
63 if o.Progress != nil {
64 o.Progress(step, detail)
65 }
66 }
67
68 func (o Options) clock() func() time.Time {
69 if o.Clock != nil {
70 return o.Clock
71 }
72 return time.Now
73 }
74
75 // Result is the outcome of EnsureServe.
76 type Result struct {
77 State ServeState
78 Token string // the pre-shared auth token (read from or written to TokenFile)
79 Reused bool // true when an already-running serve was reused
80 }
81
82 // EnsureServe returns a running serve for (host, workspace), starting one if
83 // needed. It is also the reconnect path: an existing live process is reused.
84 func EnsureServe(ctx context.Context, conn Conn, opts Options) (Result, error) {
85 fs, err := conn.SFTP()
86 if err != nil {
87 return Result{}, err
88 }
89 home, err := fs.RealPath(ctx, "~")
90 if err != nil {
91 return Result{}, fmt.Errorf("bootstrap: resolve remote home: %w", err)
92 }
93 workspace, err := resolveWorkspace(ctx, fs, opts.Workspace, home)
94 if err != nil {
95 return Result{}, err
96 }
97 paths := pathsFor(home, workspace)
98
99 // 1. Reuse a live process if the recorded pid is still running.
100 if st, tok, ok := tryReuse(ctx, conn, fs, paths, workspace); ok {
101 opts.progress("reuse", st.Addr)
102 return Result{State: st, Token: tok, Reused: true}, nil
103 }
104
105 // 2. Detect remote platform.
106 opts.progress("detect", "")
107 unameRes, err := conn.Exec(ctx, "uname -sm")
108 if err != nil {
109 return Result{}, fmt.Errorf("bootstrap: uname: %w", err)
110 }
111 goos, goarch, err := ParseUname(string(unameRes.Stdout))
112 if err != nil {
113 return Result{}, err
114 }
115
116 // 3. Locate or install a usable reasonix.
117 bin, version, err := ensureBinary(ctx, conn, fs, opts, home, goos, goarch, paths)
118 if err != nil {
119 return Result{}, err
120 }
121
122 // 4. Serialize only the short launch/publish section across every client.
123 // Another caller may have completed while this one was locating/installing,
124 // so re-check state after acquiring the remote lock.
125 opts.progress("waiting_lock", "")
126 lock, err := acquireServeLock(ctx, fs, paths, opts.clock())
127 if err != nil {
128 return Result{}, err
129 }
130 defer lock.release()
131 if st, tok, ok := tryReuse(ctx, conn, fs, paths, workspace); ok {
132 opts.progress("reuse", st.Addr)
133 return Result{State: st, Token: tok, Reused: true}, nil
134 }
135
136 // 5. Generate token, write it 0600, and launch detached serve.
137 token, err := generateToken()
138 if err != nil {
139 return Result{}, err
140 }
141 if err := fs.MkdirAll(ctx, paths.Dir); err != nil {
142 return Result{}, err
143 }
144 if err := fs.WriteFileAtomic(ctx, paths.TokenFile, []byte(token+"\n"), 0o600); err != nil {
145 return Result{}, fmt.Errorf("bootstrap: write token: %w", err)
146 }
147 opts.progress("launch", "")
148 launchRes, err := conn.Exec(ctx, LaunchCommand(bin, workspace, paths))
149 if err != nil {
150 cleanupFailedLaunch(conn, fs, paths, 0)
151 return Result{}, fmt.Errorf("bootstrap: launch: %w", err)
152 }
153 pid, _ := strconv.Atoi(strings.TrimSpace(string(launchRes.Stdout)))
154
155 // 6. Poll the newly-created port file for the real bound address. The launch
156 // command removes stale port/pid files before forking.
157 opts.progress("health_check", "")
158 addr, err := pollPortFile(ctx, fs, paths.PortFile, opts.clock())
159 if err != nil {
160 cleanupFailedLaunch(conn, fs, paths, pid)
161 return Result{}, err
162 }
163 if filePID, perr := readPIDFile(ctx, fs, paths.PidFile); perr == nil {
164 pid = filePID // --pid-file is authoritative when available.
165 }
166 if pid <= 0 || !pidIsServe(ctx, conn, pid, paths) {
167 cleanupFailedLaunch(conn, fs, paths, pid)
168 return Result{}, errors.New("bootstrap: launched process did not become the expected reasonix serve")
169 }
170
171 st := ServeState{
172 PID: pid,
173 Addr: addr,
174 Workspace: workspace,
175 Version: version,
176 TokenFile: paths.TokenFile,
177 LogFile: paths.LogFile,
178 StartedAt: nowUnix(opts.clock()),
179 }
180 data, err := MarshalState(st)
181 if err != nil {
182 cleanupFailedLaunch(conn, fs, paths, pid)
183 return Result{}, err
184 }
185 if err := fs.WriteFileAtomic(ctx, paths.StateJSON, data, 0o600); err != nil {
186 cleanupFailedLaunch(conn, fs, paths, pid)
187 return Result{}, fmt.Errorf("bootstrap: write state: %w", err)
188 }
189 opts.progress("ready", addr)
190 return Result{State: st, Token: token}, nil
191 }
192
193 // Status reads the recorded state and reports whether the process is alive.
194 func Status(ctx context.Context, conn Conn, workspace string) (ServeState, bool, error) {
195 fs, err := conn.SFTP()
196 if err != nil {
197 return ServeState{}, false, err
198 }
199 home, err := fs.RealPath(ctx, "~")
200 if err != nil {
201 return ServeState{}, false, err
202 }
203 ws, err := resolveWorkspace(ctx, fs, workspace, home)
204 if err != nil {
205 return ServeState{}, false, err
206 }
207 paths := pathsFor(home, ws)
208 st, err := readState(ctx, fs, paths.StateJSON)
209 if err != nil {
210 return ServeState{}, false, nil // no state => not running
211 }
212 alive := st.Workspace == ws && validServeAddr(st.Addr) && pidIsServe(ctx, conn, st.PID, paths)
213 return st, alive, nil
214 }
215
216 // Stop terminates the recorded process and removes its state files.
217 func Stop(ctx context.Context, conn Conn, workspace string) error {
218 fs, err := conn.SFTP()
219 if err != nil {
220 return err
221 }
222 home, err := fs.RealPath(ctx, "~")
223 if err != nil {
224 return err
225 }
226 ws, err := resolveWorkspace(ctx, fs, workspace, home)
227 if err != nil {
228 return err
229 }
230 paths := pathsFor(home, ws)
231 st, err := readState(ctx, fs, paths.StateJSON)
232 if err != nil {
233 return nil // nothing recorded
234 }
235 // Only signal the pid if it is still OUR serve: a recycled PID now owned by
236 // an unrelated process must never be TERM/KILLed.
237 if st.PID > 0 {
238 if _, err := conn.Exec(ctx, StopCommand(st.PID, paths)); err != nil {
239 return fmt.Errorf("bootstrap: stop pid %d: %w", st.PID, err)
240 }
241 }
242 _ = fs.Remove(ctx, paths.StateJSON, false)
243 _ = fs.Remove(ctx, paths.TokenFile, false)
244 _ = fs.Remove(ctx, paths.PortFile, false)
245 _ = fs.Remove(ctx, paths.PidFile, false)
246 return nil
247 }
248
249 // Logs writes up to n tail lines of the serve log to w.
250 func Logs(ctx context.Context, conn Conn, workspace string, n int, w io.Writer) error {
251 fs, err := conn.SFTP()
252 if err != nil {
253 return err
254 }
255 home, err := fs.RealPath(ctx, "~")
256 if err != nil {
257 return err
258 }
259 ws, err := resolveWorkspace(ctx, fs, workspace, home)
260 if err != nil {
261 return err
262 }
263 paths := pathsFor(home, ws)
264 res, err := conn.Exec(ctx, LogsCommand(paths.LogFile, n))
265 if err != nil {
266 return err
267 }
268 _, err = w.Write(res.Stdout)
269 return err
270 }
271
272 func tryReuse(ctx context.Context, conn Conn, fs *sftpfs.FS, paths StatePaths, workspace ...string) (ServeState, string, bool) {
273 st, err := readState(ctx, fs, paths.StateJSON)
274 if err != nil || st.PID <= 0 || st.Addr == "" {
275 return ServeState{}, "", false
276 }
277 if len(workspace) > 0 && st.Workspace != workspace[0] {
278 return ServeState{}, "", false
279 }
280 if !validServeAddr(st.Addr) || !pidIsServe(ctx, conn, st.PID, paths) {
281 return ServeState{}, "", false
282 }
283 // The state record is informational; the workspace-derived path is the
284 // authority, so a tampered record cannot make us read an arbitrary file.
285 tok, err := readToken(ctx, fs, paths.TokenFile)
286 if err != nil {
287 return ServeState{}, "", false
288 }
289 return st, tok, true
290 }
291
292 // pidIsServe reports whether pid is running AND is a reasonix serve process,
293 // so PID reuse cannot make an unrelated process look like a live serve.
294 func pidIsServe(ctx context.Context, conn Conn, pid int, paths StatePaths) bool {
295 if pid <= 0 {
296 return false
297 }
298 res, err := conn.Exec(ctx, ServeAliveCommand(pid, paths))
299 if err != nil {
300 return false
301 }
302 return strings.TrimSpace(string(res.Stdout)) == "1"
303 }
304
305 func readState(ctx context.Context, fs *sftpfs.FS, path string) (ServeState, error) {
306 data, _, _, err := fs.ReadFile(ctx, path, 1<<20)
307 if err != nil {
308 return ServeState{}, err
309 }
310 return UnmarshalState(data)
311 }
312
313 func readToken(ctx context.Context, fs *sftpfs.FS, path string) (string, error) {
314 data, _, _, err := fs.ReadFile(ctx, path, 64<<10)
315 if err != nil {
316 return "", err
317 }
318 tok := strings.TrimSpace(string(data))
319 if tok == "" {
320 return "", errors.New("bootstrap: empty token file")
321 }
322 return tok, nil
323 }
324
325 func pollPortFile(ctx context.Context, fs *sftpfs.FS, portFile string, clock func() time.Time) (string, error) {
326 deadline := clock().Add(20 * time.Second)
327 for {
328 data, _, _, err := fs.ReadFile(ctx, portFile, 128)
329 if err == nil {
330 addr := strings.TrimSpace(string(data))
331 if validServeAddr(addr) {
332 return addr, nil
333 }
334 }
335 if clock().After(deadline) {
336 return "", errors.New("bootstrap: timed out waiting for serve to report its port")
337 }
338 select {
339 case <-ctx.Done():
340 return "", ctx.Err()
341 case <-time.After(250 * time.Millisecond):
342 }
343 }
344 }
345
346 func validServeAddr(addr string) bool {
347 host, portText, err := net.SplitHostPort(strings.TrimSpace(addr))
348 if err != nil || host != "127.0.0.1" {
349 return false
350 }
351 port, err := strconv.Atoi(portText)
352 return err == nil && port > 0 && port <= 65535
353 }
354
355 func readPIDFile(ctx context.Context, fs *sftpfs.FS, pidFile string) (int, error) {
356 data, _, _, err := fs.ReadFile(ctx, pidFile, 64)
357 if err != nil {
358 return 0, err
359 }
360 pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
361 if err != nil || pid <= 0 {
362 return 0, errors.New("bootstrap: invalid serve pid file")
363 }
364 return pid, nil
365 }
366
367 func cleanupFailedLaunch(conn Conn, fs *sftpfs.FS, paths StatePaths, pid int) {
368 ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
369 defer cancel()
370 if pid <= 0 {
371 pid, _ = readPIDFile(ctx, fs, paths.PidFile)
372 }
373 if pid > 0 {
374 _, _ = conn.Exec(ctx, StopCommand(pid, paths))
375 }
376 _ = fs.Remove(ctx, paths.StateJSON, false)
377 _ = fs.Remove(ctx, paths.TokenFile, false)
378 _ = fs.Remove(ctx, paths.PortFile, false)
379 _ = fs.Remove(ctx, paths.PidFile, false)
380 }
381
382 func resolveWorkspace(ctx context.Context, fs *sftpfs.FS, workspace, home string) (string, error) {
383 workspace = strings.TrimSpace(workspace)
384 if workspace == "" {
385 return home, nil
386 }
387 if workspace == "~" {
388 return home, nil
389 }
390 if strings.HasPrefix(workspace, "~/") {
391 return strings.TrimRight(home, "/") + "/" + strings.TrimPrefix(workspace, "~/"), nil
392 }
393 if strings.HasPrefix(workspace, "/") {
394 return workspace, nil
395 }
396 // Relative to home.
397 return strings.TrimRight(home, "/") + "/" + workspace, nil
398 }
399
400 func generateToken() (string, error) {
401 var b [32]byte
402 if _, err := rand.Read(b[:]); err != nil {
403 return "", err
404 }
405 return hex.EncodeToString(b[:]), nil
406 }
407
407 lines GO