carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
7package main
8
9import (
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.
32var (
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".
57var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
58
59func 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.
64func 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).
73var 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
81type updateCache struct {
82 CheckedAt time.Time `json:"checked_at"`
83 Latest string `json:"latest"`
84}
85
86func 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.
97func 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.
104func 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.
116func 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.
168func 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.
179func 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
203func 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.
274func 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`.
284func 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