| 1 | // Self-update (#161): the binary knows its own version, the apt static |
| 2 | // site publishes the latest, and carlosframework/releases holds the |
| 3 | // artifacts — so a stale workstation install can notice and fix itself. |
| 4 | // Everything here is workstation-only by construction: boxes (bucket env, |
| 5 | // root) are rolled by update-platform-binary.sh and adoption, and a |
| 6 | // self-updating box binary would fight that machinery. |
| 7 | package main |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "encoding/json" |
| 12 | "errors" |
| 13 | "flag" |
| 14 | "fmt" |
| 15 | "log" |
| 16 | "net/http" |
| 17 | "os" |
| 18 | "os/exec" |
| 19 | "path/filepath" |
| 20 | "regexp" |
| 21 | "runtime" |
| 22 | "strings" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/publicrelease" |
| 26 | "github.com/carlosframework/platform/internal/release" |
| 27 | "github.com/carlosframework/platform/internal/version" |
| 28 | ) |
| 29 | |
| 30 | // Vars, not consts: the tests point them at httptest servers and a test |
| 31 | // keypair. Production never changes them. |
| 32 | var ( |
| 33 | updateLatestURL = "https://apt.carlosframework.com/latest.txt" |
| 34 | // Tag-pinned, never "latest/download": the floating alias can serve an |
| 35 | // older authentic release than the one latest.txt promised, and every |
| 36 | // artifact of a self-replace must come from the ONE tag being claimed |
| 37 | // (review 2026-08-12: an authentic-but-old sums+binary pair must not |
| 38 | // be acceptable, or a caching layer can pin workstations to a |
| 39 | // known-vulnerable release without a signature error). |
| 40 | updateDownloadBase = "https://github.com/carlosframework/releases/releases/download" |
| 41 | |
| 42 | // updateGOOS is runtime.GOOS behind a var for the same reason the URLs |
| 43 | // above are: the Windows branch is otherwise unreachable from a test on |
| 44 | // any machine we build on. Production never changes it. |
| 45 | updateGOOS = runtime.GOOS |
| 46 | |
| 47 | // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the |
| 48 | // public half of the dedicated CLI-update keypair (private half lives in the deployment's |
| 49 | // secret store — NOT the release key; different blast |
| 50 | // radius, different rotation story). |
| 51 | cliUpdatePubKey = "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" |
| 52 | ) |
| 53 | |
| 54 | // releaseTag matches the version stamp of a published build ("v0.7.0"). |
| 55 | // Anything else — "dev" above all — never checks and never updates: there |
| 56 | // is no meaningful "newer than dev". |
| 57 | var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) |
| 58 | |
| 59 | func releaseBuild(build string) bool { return releaseTag.MatchString(build) } |
| 60 | |
| 61 | // newerVersion reports whether latest (a release tag) is strictly newer |
| 62 | // than current (also a release tag). Unparseable input is never "newer" — |
| 63 | // a garbled feed must not nag anyone. |
| 64 | func newerVersion(latest, current string) bool { |
| 65 | l, lok := release.ParseSemVer(strings.TrimPrefix(latest, "v")) |
| 66 | c, cok := release.ParseSemVer(strings.TrimPrefix(current, "v")) |
| 67 | return lok && cok && l.Compare(c) > 0 |
| 68 | } |
| 69 | |
| 70 | // noHintVerbs are the box/daemon/plumbing verbs the update hint stays out |
| 71 | // of — either they run where updates are the platform's job, or their |
| 72 | // output is parsed (version: the tap formula's test asserts on it). |
| 73 | var noHintVerbs = map[string]bool{ |
| 74 | "edge": true, "agent": true, "adopt": true, "bootstrap": true, |
| 75 | "add": true, "remove": true, "route": true, "ops": true, |
| 76 | "status": true, "vet": true, "release-keygen": true, |
| 77 | "version": true, "help": true, "-h": true, "--help": true, |
| 78 | "update": true, "system": true, |
| 79 | } |
| 80 | |
| 81 | type updateCache struct { |
| 82 | CheckedAt time.Time `json:"checked_at"` |
| 83 | Latest string `json:"latest"` |
| 84 | } |
| 85 | |
| 86 | func updateCachePath() (string, error) { |
| 87 | home, err := os.UserHomeDir() |
| 88 | if err != nil { |
| 89 | return "", err |
| 90 | } |
| 91 | return filepath.Join(home, ".carlos", "update-check.json"), nil |
| 92 | } |
| 93 | |
| 94 | // updateFeed builds the publicrelease.Feed from this package's vars, at |
| 95 | // call time — never cached — so update_test.go's repointing of the vars |
| 96 | // (a fresh httptest server per test) always reaches the feed it expects. |
| 97 | func updateFeed() publicrelease.Feed { |
| 98 | return publicrelease.Feed{LatestURL: updateLatestURL, DownloadBase: updateDownloadBase, PubKey: cliUpdatePubKey} |
| 99 | } |
| 100 | |
| 101 | // fetchLatest asks the apt site which release is current. One small GET |
| 102 | // with its own short deadline — the caller is done with its real work, and |
| 103 | // a slow feed must cost nobody anything. |
| 104 | func fetchLatest(timeout time.Duration) (string, error) { |
| 105 | f := updateFeed() |
| 106 | f.Client = &http.Client{Timeout: timeout} |
| 107 | return f.Latest(context.Background()) |
| 108 | } |
| 109 | |
| 110 | // maybeUpdateHint prints the one-line "a newer carlos exists" nudge after |
| 111 | // a successful member verb — at most once per 24h (the network check and |
| 112 | // the hint share the cache window), and only where a hint can possibly be |
| 113 | // wanted: a human at a terminal, running a release build, off-box, not |
| 114 | // opted out. Every failure is silence; this must never make a working |
| 115 | // command look broken. |
| 116 | func maybeUpdateHint(cmd string) { |
| 117 | if noHintVerbs[cmd] || !interactiveTerminal() || !releaseBuild(version.Build) { |
| 118 | return |
| 119 | } |
| 120 | if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { |
| 121 | return |
| 122 | } |
| 123 | if os.Geteuid() == 0 || os.Getenv("CARLOS_NO_UPDATE_CHECK") != "" { |
| 124 | return |
| 125 | } |
| 126 | if off, err := configUpdateCheckOff(); err != nil || off { |
| 127 | return |
| 128 | } |
| 129 | path, err := updateCachePath() |
| 130 | if err != nil { |
| 131 | return |
| 132 | } |
| 133 | var cache updateCache |
| 134 | if raw, err := os.ReadFile(path); err == nil { |
| 135 | // A future checked_at (clock skew, corrupt write) must re-check, |
| 136 | // not silence the hint until the clock catches up. |
| 137 | if json.Unmarshal(raw, &cache) == nil { |
| 138 | if since := time.Since(cache.CheckedAt); since >= 0 && since < 24*time.Hour { |
| 139 | return |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | // The attempt is stamped BEFORE the fetch: a blackholed egress must |
| 144 | // cost one command its 1.5s, not every command forever. Latest is |
| 145 | // informational (for a human reading the file) — the hint only ever |
| 146 | // speaks at check time, so a stale value never re-nags. |
| 147 | writeCache := func(latest string) { |
| 148 | if raw, err := json.Marshal(updateCache{CheckedAt: time.Now().UTC(), Latest: latest}); err == nil { |
| 149 | if err := os.MkdirAll(filepath.Dir(path), 0o700); err == nil { |
| 150 | os.WriteFile(path, raw, 0o600) |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | writeCache("") |
| 155 | latest, err := fetchLatest(1500 * time.Millisecond) |
| 156 | if err != nil { |
| 157 | return |
| 158 | } |
| 159 | writeCache(latest) |
| 160 | if newerVersion(latest, version.Build) { |
| 161 | mutedf(stdout, "carlos %s is available (you have %s) — run: carlos update", latest, version.Build) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // configUpdateCheckOff walks the config files like the other defaults do: |
| 166 | // nearest .carlos/config with an update_check key, else the global. Only |
| 167 | // the literal "off" disables. |
| 168 | func configUpdateCheckOff() (bool, error) { |
| 169 | cfg, src, err := walkConfig(func(c cliConfig) bool { return c.UpdateCheck != "" }) |
| 170 | if err != nil || src == "" { |
| 171 | return false, err |
| 172 | } |
| 173 | return cfg.UpdateCheck == "off", nil |
| 174 | } |
| 175 | |
| 176 | // installMethod classifies how the running binary got where it is, so |
| 177 | // `carlos update` hands package-managed installs to their manager instead |
| 178 | // of fighting it. |
| 179 | func installMethod(exe string) string { |
| 180 | // Windows first, before any path matching: there is no apt and no brew |
| 181 | // in the Windows story, and no self-replace either (os.Rename cannot |
| 182 | // overwrite a running .exe), so the zip that carried this binary here |
| 183 | // is also how it leaves. A Windows path that happens to contain |
| 184 | // /Cellar/ is a coincidence, not a formula. |
| 185 | if updateGOOS == "windows" { |
| 186 | return "zip" |
| 187 | } |
| 188 | // Both brews keep formula binaries under a Cellar (/opt/homebrew/Cellar, |
| 189 | // ~/.linuxbrew/Cellar) — matching looser substrings ("/homebrew/") would |
| 190 | // also match unrelated paths that merely contain the word. |
| 191 | if strings.Contains(exe, "/Cellar/") { |
| 192 | return "brew" |
| 193 | } |
| 194 | if dpkg, err := exec.LookPath("dpkg"); err == nil { |
| 195 | out, err := exec.Command(dpkg, "-S", exe).Output() |
| 196 | if err == nil && strings.HasPrefix(string(out), "carlos:") { |
| 197 | return "apt" |
| 198 | } |
| 199 | } |
| 200 | return "binary" |
| 201 | } |
| 202 | |
| 203 | func runUpdate(args []string) { |
| 204 | fs := flag.NewFlagSet("update", flag.ExitOnError) |
| 205 | yes := fs.Bool("y", false, "replace without asking (plain-binary installs only)") |
| 206 | fs.Usage = flagUsage(fs, "") |
| 207 | fs.Parse(args) |
| 208 | if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { |
| 209 | log.Fatal("carlos update is for workstation installs — a box's binary is rolled by update-platform-binary.sh and adoption") |
| 210 | } |
| 211 | if os.Geteuid() == 0 { |
| 212 | log.Fatal("carlos update refuses to run as root — box binaries are rolled by the platform, not self-updated") |
| 213 | } |
| 214 | if !releaseBuild(version.Build) { |
| 215 | log.Fatalf("this is a %q build, not a published release — update by rebuilding from source", version.Build) |
| 216 | } |
| 217 | latest, err := fetchLatest(10 * time.Second) |
| 218 | if err != nil { |
| 219 | log.Fatalf("could not read the release feed: %v", err) |
| 220 | } |
| 221 | if !newerVersion(latest, version.Build) { |
| 222 | fmt.Fprintf(stdout, "carlos %s is the latest release — nothing to do\n", version.Build) |
| 223 | return |
| 224 | } |
| 225 | exe, err := os.Executable() |
| 226 | if err == nil { |
| 227 | exe, err = filepath.EvalSymlinks(exe) |
| 228 | } |
| 229 | if err != nil { |
| 230 | log.Fatalf("cannot locate the running binary: %v", err) |
| 231 | } |
| 232 | switch installMethod(exe) { |
| 233 | case "brew": |
| 234 | fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| 235 | fmt.Fprintln(stdout, "this install is Homebrew's — update with: brew upgrade carlosframework/tap/carlos") |
| 236 | return |
| 237 | case "apt": |
| 238 | fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| 239 | fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos") |
| 240 | return |
| 241 | case "zip": |
| 242 | // The one install method whose update is manual. Windows cannot |
| 243 | // rename a new binary over the running .exe, so rather than ship a |
| 244 | // self-replace nobody on this project can test, the command tells |
| 245 | // the truth and hands over the exact URL — the staleness check |
| 246 | // above has already done the part that needed the network. |
| 247 | fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| 248 | fmt.Fprintf(stdout, "Windows installs update by hand — download and unzip over %s:\n\n %s\n\n", |
| 249 | exe, updateDownloadBase+"/"+latest+"/"+windowsAsset()) |
| 250 | return |
| 251 | } |
| 252 | fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe) |
| 253 | // Non-TTY never self-replaces, -y or not (spec): provisioning belongs |
| 254 | // to package managers and image builds, not to a CLI quietly swapping |
| 255 | // itself in a pipeline. -y only skips the question for a human. |
| 256 | if !interactiveTerminal() { |
| 257 | log.Fatal("carlos update replaces a binary only at a terminal — scripts should install the release directly") |
| 258 | } |
| 259 | if !*yes && !promptYes(stdout, fmt.Sprintf("download %s and replace it? [y/N] ", latest)) { |
| 260 | return |
| 261 | } |
| 262 | if err := selfReplace(exe, latest); err != nil { |
| 263 | if errors.Is(err, os.ErrPermission) { |
| 264 | log.Fatalf("no permission to replace %s — re-run as: sudo carlos update", exe) |
| 265 | } |
| 266 | log.Fatalf("update: %v", err) |
| 267 | } |
| 268 | fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe) |
| 269 | } |
| 270 | |
| 271 | // windowsAsset names the zip published for this machine's architecture. |
| 272 | // It shares the "carlos-<goos>-<goarch>" stem with the raw binaries in |
| 273 | // SHA256SUMS.txt below, so the checksum list has one naming rule, not two. |
| 274 | func windowsAsset() string { |
| 275 | return fmt.Sprintf("carlos-%s-%s.zip", updateGOOS, runtime.GOARCH) |
| 276 | } |
| 277 | |
| 278 | // selfReplace downloads the matching artifact for this OS/arch — from the |
| 279 | // TAG-pINNED release path, so the bytes installed are the version claimed — |
| 280 | // proves it against the SIGNED checksum list, runs the replacement's own |
| 281 | // `version` as a final lie detector, and renames it over exe: temp file |
| 282 | // beside the target then rename, the same crash-safety rule as the config |
| 283 | // writes, so no failure can leave half a binary answering `carlos`. |
| 284 | func selfReplace(exe, latest string) error { |
| 285 | asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH) |
| 286 | binData, _, err := updateFeed().Asset(context.Background(), latest, asset, 256<<20) |
| 287 | if err != nil { |
| 288 | return err |
| 289 | } |
| 290 | tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*") |
| 291 | if err != nil { |
| 292 | return err |
| 293 | } |
| 294 | defer os.Remove(tmp.Name()) |
| 295 | if err := tmp.Chmod(0o755); err != nil { |
| 296 | tmp.Close() |
| 297 | return err |
| 298 | } |
| 299 | if _, err := tmp.Write(binData); err != nil { |
| 300 | tmp.Close() |
| 301 | return err |
| 302 | } |
| 303 | if err := tmp.Close(); err != nil { |
| 304 | return err |
| 305 | } |
| 306 | // The lie detector: before anything is replaced, the candidate itself |
| 307 | // must report the version this whole exchange promised. Catches a |
| 308 | // re-pointed tag, a mislabeled artifact, and "updated!" prints that |
| 309 | // would otherwise claim a version that never arrived. |
| 310 | if out, err := exec.Command(tmp.Name(), "version").Output(); err != nil || |
| 311 | !strings.Contains(string(out), "carlos "+latest) { |
| 312 | return fmt.Errorf("the downloaded binary reports %q, not %s — refusing to install it", |
| 313 | strings.TrimSpace(string(out)), latest) |
| 314 | } |
| 315 | return os.Rename(tmp.Name(), exe) |
| 316 | } |
| 317 | |