| 1 | package bootstrap |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/remote/sftpfs" |
| 11 | ) |
| 12 | |
| 13 | // ensureBinary resolves a usable reasonix binary on the remote host per the |
| 14 | // install strategy, returning its path and version. A located binary older |
| 15 | // than MinVersion counts as missing (it lacks --port-file/--token-file). |
| 16 | func ensureBinary(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch string, paths StatePaths) (bin, version string, err error) { |
| 17 | uploaded := uploadedBinPath(home) |
| 18 | bin, version = locate(ctx, conn, uploaded, opts.MinVersion) |
| 19 | if bin != "" { |
| 20 | return bin, version, nil |
| 21 | } |
| 22 | |
| 23 | strategy := opts.Install |
| 24 | if strategy == "" { |
| 25 | strategy = InstallAuto |
| 26 | } |
| 27 | opts.progress("install", strategy) |
| 28 | |
| 29 | switch strategy { |
| 30 | case InstallNever: |
| 31 | return "", "", fmt.Errorf("bootstrap: reasonix not found on remote and serve_install = never") |
| 32 | case InstallNPM: |
| 33 | return installViaNPM(ctx, conn, opts.MinVersion) |
| 34 | case InstallUpload: |
| 35 | return installViaUpload(ctx, conn, fs, opts, home, goos, goarch, uploaded) |
| 36 | default: // auto: try npm, packaged same-platform upload, then verified release upload |
| 37 | if b, v, nerr := installViaNPM(ctx, conn, opts.MinVersion); nerr == nil { |
| 38 | return b, v, nil |
| 39 | } else { |
| 40 | attempts := []error{nerr} |
| 41 | if opts.LocalBinary != "" && opts.LocalGOOS == goos && opts.LocalGOARCH == goarch { |
| 42 | if b, v, uploadErr := installViaUpload(ctx, conn, fs, opts, home, goos, goarch, uploaded); uploadErr == nil { |
| 43 | return b, v, nil |
| 44 | } else { |
| 45 | attempts = append(attempts, uploadErr) |
| 46 | } |
| 47 | } else if opts.LocalBinary == "" { |
| 48 | attempts = append(attempts, errors.New("bootstrap: no local Reasonix CLI is available for upload")) |
| 49 | } else { |
| 50 | attempts = append(attempts, fmt.Errorf("bootstrap: local binary is %s/%s but remote is %s/%s", opts.LocalGOOS, opts.LocalGOARCH, goos, goarch)) |
| 51 | } |
| 52 | if opts.FetchBinary != nil { |
| 53 | binary, fetchErr := opts.FetchBinary(ctx, opts.ProductVersion, goos, goarch) |
| 54 | if fetchErr == nil { |
| 55 | if b, v, uploadErr := installBinaryBytes(ctx, conn, fs, binary, opts.MinVersion, home, uploaded); uploadErr == nil { |
| 56 | return b, v, nil |
| 57 | } else { |
| 58 | attempts = append(attempts, uploadErr) |
| 59 | } |
| 60 | } else { |
| 61 | attempts = append(attempts, fmt.Errorf("bootstrap: fetch official %s/%s CLI: %w", goos, goarch, fetchErr)) |
| 62 | } |
| 63 | } |
| 64 | return "", "", fmt.Errorf("bootstrap: automatic install failed: %w", errors.Join(attempts...)) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // locate finds an existing reasonix and returns it only if its serve command |
| 70 | // supports --port-file (the bootstrap contract). A binary that lacks the flag — |
| 71 | // including every currently-released version — is reported as missing so the |
| 72 | // install/upload path replaces it. minVersion is accepted for signature |
| 73 | // stability but the flag probe is authoritative. |
| 74 | func locate(ctx context.Context, conn Conn, uploaded, minVersion string) (bin, version string) { |
| 75 | _ = minVersion |
| 76 | res, err := conn.Exec(ctx, LocateCommand(uploaded)) |
| 77 | if err != nil { |
| 78 | return "", "" |
| 79 | } |
| 80 | lines := strings.Split(strings.TrimRight(string(res.Stdout), "\n"), "\n") |
| 81 | path := strings.TrimSpace(lines[0]) |
| 82 | if path == "" { |
| 83 | return "", "" |
| 84 | } |
| 85 | supportsPortFile := false |
| 86 | for _, ln := range lines[1:] { |
| 87 | ln = strings.TrimSpace(ln) |
| 88 | if ln == "portfile:yes" { |
| 89 | supportsPortFile = true |
| 90 | } else if ln == "portfile:no" { |
| 91 | supportsPortFile = false |
| 92 | } else if v, verr := ParseVersion(ln); verr == nil { |
| 93 | version = v |
| 94 | } |
| 95 | } |
| 96 | if !supportsPortFile { |
| 97 | // Missing the --port-file flag: treat as unusable so it is upgraded. |
| 98 | return "", "" |
| 99 | } |
| 100 | return path, version |
| 101 | } |
| 102 | |
| 103 | func installViaNPM(ctx context.Context, conn Conn, minVersion string) (bin, version string, err error) { |
| 104 | res, err := conn.Exec(ctx, "npm i -g reasonix 2>&1") |
| 105 | if err != nil { |
| 106 | return "", "", fmt.Errorf("bootstrap: npm install: %w", err) |
| 107 | } |
| 108 | if res.ExitCode != 0 { |
| 109 | return "", "", fmt.Errorf("bootstrap: npm install failed: %s", tail(res.Stdout, 400)) |
| 110 | } |
| 111 | // npm may install outside the login PATH; probe npm prefix explicitly. |
| 112 | loc, ver := locate(ctx, conn, "", minVersion) |
| 113 | if loc == "" { |
| 114 | return "", "", fmt.Errorf("bootstrap: reasonix not found after npm install (check remote PATH / npm prefix)") |
| 115 | } |
| 116 | return loc, ver, nil |
| 117 | } |
| 118 | |
| 119 | // installViaUpload uploads the local reasonix binary when the remote platform |
| 120 | // matches the local one. Cross-platform release download is a documented V1 |
| 121 | // limitation: use serve_install = npm for a differing remote platform. |
| 122 | func installViaUpload(ctx context.Context, conn Conn, fs *sftpfs.FS, opts Options, home, goos, goarch, uploaded string) (bin, version string, err error) { |
| 123 | if opts.LocalBinary == "" { |
| 124 | return "", "", fmt.Errorf("bootstrap: upload strategy needs the local reasonix binary path") |
| 125 | } |
| 126 | if opts.LocalGOOS != goos || opts.LocalGOARCH != goarch { |
| 127 | return "", "", fmt.Errorf("bootstrap: cannot upload: local binary is %s/%s but remote is %s/%s; use serve_install = npm", |
| 128 | opts.LocalGOOS, opts.LocalGOARCH, goos, goarch) |
| 129 | } |
| 130 | data, rerr := os.ReadFile(opts.LocalBinary) |
| 131 | if rerr != nil { |
| 132 | return "", "", fmt.Errorf("bootstrap: read local binary: %w", rerr) |
| 133 | } |
| 134 | return installBinaryBytes(ctx, conn, fs, data, opts.MinVersion, home, uploaded) |
| 135 | } |
| 136 | |
| 137 | func installBinaryBytes(ctx context.Context, conn Conn, fs *sftpfs.FS, data []byte, minVersion, home, uploaded string) (bin, version string, err error) { |
| 138 | if len(data) == 0 { |
| 139 | return "", "", fmt.Errorf("bootstrap: downloaded binary is empty") |
| 140 | } |
| 141 | if err := fs.MkdirAll(ctx, dirOf(uploaded)); err != nil { |
| 142 | return "", "", err |
| 143 | } |
| 144 | if err := fs.WriteFileAtomic(ctx, uploaded, data, 0o755); err != nil { |
| 145 | return "", "", fmt.Errorf("bootstrap: upload binary: %w", err) |
| 146 | } |
| 147 | loc, ver := locate(ctx, conn, uploaded, minVersion) |
| 148 | if loc == "" { |
| 149 | return "", "", fmt.Errorf("bootstrap: uploaded binary not runnable on remote") |
| 150 | } |
| 151 | return loc, ver, nil |
| 152 | } |
| 153 | |
| 154 | func dirOf(p string) string { |
| 155 | if i := strings.LastIndex(p, "/"); i >= 0 { |
| 156 | return p[:i] |
| 157 | } |
| 158 | return "." |
| 159 | } |
| 160 | |
| 161 | func tail(b []byte, n int) string { |
| 162 | s := strings.TrimSpace(string(b)) |
| 163 | if len(s) > n { |
| 164 | return "..." + s[len(s)-n:] |
| 165 | } |
| 166 | return s |
| 167 | } |
| 168 |