| new file mode 100644 |
| index 000000000..b8fdc13f7 |
| --- /dev/null |
| +++ b/update.go |
| @@ -0,0 +1,327 @@ |
| +// Self-update (#161): the binary knows its own version, the apt static |
| +// site publishes the latest, and carlosframework/releases holds the |
| +// artifacts — so a stale workstation install can notice and fix itself. |
| +// Everything here is workstation-only by construction: boxes (bucket env, |
| +// root) are rolled by update-platform-binary.sh and adoption, and a |
| +// self-updating box binary would fight that machinery. |
| +package main |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "regexp" |
| + "runtime" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| +) |
| + |
| +// Vars, not consts: the tests point them at httptest servers and a test |
| +// keypair. Production never changes them. |
| +var ( |
| + updateLatestURL = "https://apt.carlosframework.com/latest.txt" |
| + updateDownloadBase = "https://github.com/carlosframework/releases/releases/latest/download" |
| + |
| + // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the |
| + // public half of the dedicated CLI-update keypair (private half at SSM |
| + // /carlos/cli-update-key — NOT the release key; different blast |
| + // radius, different rotation story). |
| + cliUpdatePubKey = "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" |
| +) |
| + |
| +// releaseTag matches the version stamp of a published build ("v0.7.0"). |
| +// Anything else — "dev" above all — never checks and never updates: there |
| +// is no meaningful "newer than dev". |
| +var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) |
| + |
| +func releaseBuild(build string) bool { return releaseTag.MatchString(build) } |
| + |
| +// newerVersion reports whether latest (a release tag) is strictly newer |
| +// than current (also a release tag). Unparseable input is never "newer" — |
| +// a garbled feed must not nag anyone. |
| +func newerVersion(latest, current string) bool { |
| + l, lok := release.ParseSemVer(strings.TrimPrefix(latest, "v")) |
| + c, cok := release.ParseSemVer(strings.TrimPrefix(current, "v")) |
| + return lok && cok && l.Compare(c) > 0 |
| +} |
| + |
| +// noHintVerbs are the box/daemon/plumbing verbs the update hint stays out |
| +// of — either they run where updates are the platform's job, or their |
| +// output is parsed (version: the tap formula's test asserts on it). |
| +var noHintVerbs = map[string]bool{ |
| + "edge": true, "agent": true, "adopt": true, "bootstrap": true, |
| + "add": true, "remove": true, "route": true, "ops": true, |
| + "status": true, "canary": true, "vet": true, "release-keygen": true, |
| + "version": true, "help": true, "-h": true, "--help": true, |
| + "update": true, |
| +} |
| + |
| +type updateCache struct { |
| + CheckedAt time.Time `json:"checked_at"` |
| + Latest string `json:"latest"` |
| +} |
| + |
| +func updateCachePath() (string, error) { |
| + home, err := os.UserHomeDir() |
| + if err != nil { |
| + return "", err |
| + } |
| + return filepath.Join(home, ".carlos", "update-check.json"), nil |
| +} |
| + |
| +// fetchLatest asks the apt site which release is current. One small GET |
| +// with its own short deadline — the caller is done with its real work, and |
| +// a slow feed must cost nobody anything. |
| +func fetchLatest(timeout time.Duration) (string, error) { |
| + client := &http.Client{Timeout: timeout} |
| + res, err := client.Get(updateLatestURL) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + return "", fmt.Errorf("latest.txt: status %d", res.StatusCode) |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(res.Body, 128)) |
| + if err != nil { |
| + return "", err |
| + } |
| + tag := strings.TrimSpace(string(raw)) |
| + if !releaseBuild(tag) { |
| + return "", fmt.Errorf("latest.txt: %q is not a release tag", tag) |
| + } |
| + return tag, nil |
| +} |
| + |
| +// maybeUpdateHint prints the one-line "a newer carlos exists" nudge after |
| +// a successful member verb — at most once per 24h (the network check and |
| +// the hint share the cache window), and only where a hint can possibly be |
| +// wanted: a human at a terminal, running a release build, off-box, not |
| +// opted out. Every failure is silence; this must never make a working |
| +// command look broken. |
| +func maybeUpdateHint(cmd string) { |
| + if noHintVerbs[cmd] || !interactiveTerminal() || !releaseBuild(version.Build) { |
| + return |
| + } |
| + if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { |
| + return |
| + } |
| + if os.Geteuid() == 0 || os.Getenv("CARLOS_NO_UPDATE_CHECK") != "" { |
| + return |
| + } |
| + if off, err := configUpdateCheckOff(); err != nil || off { |
| + return |
| + } |
| + path, err := updateCachePath() |
| + if err != nil { |
| + return |
| + } |
| + var cache updateCache |
| + if raw, err := os.ReadFile(path); err == nil { |
| + if json.Unmarshal(raw, &cache) == nil && time.Since(cache.CheckedAt) < 24*time.Hour { |
| + return |
| + } |
| + } |
| + latest, err := fetchLatest(1500 * time.Millisecond) |
| + if err != nil { |
| + return |
| + } |
| + // The cache is written even when up to date — "checked, nothing new" |
| + // is exactly what should suppress tomorrow's re-check until due. |
| + if raw, err := json.Marshal(updateCache{CheckedAt: time.Now().UTC(), Latest: latest}); err == nil { |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err == nil { |
| + os.WriteFile(path, raw, 0o600) |
| + } |
| + } |
| + if newerVersion(latest, version.Build) { |
| + mutedf(stdout, "carlos %s is available (you have %s) — run: carlos update", latest, version.Build) |
| + } |
| +} |
| + |
| +// configUpdateCheckOff walks the config files like the other defaults do: |
| +// nearest .carlos/config with an update_check key, else the global. Only |
| +// the literal "off" disables. |
| +func configUpdateCheckOff() (bool, error) { |
| + if dir, werr := os.Getwd(); werr == nil { |
| + for { |
| + cfg, err := loadConfig(filepath.Join(dir, ".carlos", "config")) |
| + if err != nil { |
| + return false, err |
| + } |
| + if cfg.UpdateCheck != "" { |
| + return cfg.UpdateCheck == "off", nil |
| + } |
| + parent := filepath.Dir(dir) |
| + if parent == dir { |
| + break |
| + } |
| + dir = parent |
| + } |
| + } |
| + path, perr := globalConfigPath() |
| + if perr != nil { |
| + return false, nil |
| + } |
| + cfg, err := loadConfig(path) |
| + if err != nil { |
| + return false, err |
| + } |
| + return cfg.UpdateCheck == "off", nil |
| +} |
| + |
| +// installMethod classifies how the running binary got where it is, so |
| +// `carlos update` hands package-managed installs to their manager instead |
| +// of fighting it. |
| +func installMethod(exe string) string { |
| + if strings.Contains(exe, "/Cellar/") || strings.Contains(exe, "/homebrew/") || strings.Contains(exe, "/linuxbrew/") { |
| + return "brew" |
| + } |
| + if dpkg, err := exec.LookPath("dpkg"); err == nil { |
| + out, err := exec.Command(dpkg, "-S", exe).Output() |
| + if err == nil && strings.HasPrefix(string(out), "carlos:") { |
| + return "apt" |
| + } |
| + } |
| + return "binary" |
| +} |
| + |
| +func runUpdate(args []string) { |
| + fs := flag.NewFlagSet("update", flag.ExitOnError) |
| + yes := fs.Bool("y", false, "replace without asking (plain-binary installs only)") |
| + fs.Parse(args) |
| + if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { |
| + log.Fatal("carlos update is for workstation installs — a box's binary is rolled by update-platform-binary.sh and adoption") |
| + } |
| + if os.Geteuid() == 0 { |
| + log.Fatal("carlos update refuses to run as root — box binaries are rolled by the platform, not self-updated") |
| + } |
| + if !releaseBuild(version.Build) { |
| + log.Fatalf("this is a %q build, not a published release — update by rebuilding from source", version.Build) |
| + } |
| + latest, err := fetchLatest(10 * time.Second) |
| + if err != nil { |
| + log.Fatalf("could not read the release feed: %v", err) |
| + } |
| + if !newerVersion(latest, version.Build) { |
| + fmt.Fprintf(stdout, "carlos %s is the latest release — nothing to do\n", version.Build) |
| + return |
| + } |
| + exe, err := os.Executable() |
| + if err == nil { |
| + exe, err = filepath.EvalSymlinks(exe) |
| + } |
| + if err != nil { |
| + log.Fatalf("cannot locate the running binary: %v", err) |
| + } |
| + switch installMethod(exe) { |
| + case "brew": |
| + fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| + fmt.Fprintln(stdout, "this install is Homebrew's — update with: brew upgrade carlosframework/tap/carlos") |
| + return |
| + case "apt": |
| + fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) |
| + fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos") |
| + return |
| + } |
| + fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe) |
| + if !*yes { |
| + if !interactiveTerminal() { |
| + log.Fatal("not a terminal — pass -y to replace without asking") |
| + } |
| + if !promptYes(stdout, fmt.Sprintf("download %s and replace it? [y/N] ", latest)) { |
| + return |
| + } |
| + } |
| + if err := selfReplace(exe, latest); err != nil { |
| + if errors.Is(err, os.ErrPermission) { |
| + log.Fatalf("no permission to replace %s — re-run as: sudo carlos update", exe) |
| + } |
| + log.Fatalf("update: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe) |
| +} |
| + |
| +// selfReplace downloads the matching artifact for this OS/arch, proves it |
| +// against the SIGNED checksum list, and renames it over exe — temp file |
| +// beside the target then rename, the same crash-safety rule as the config |
| +// writes, so no failure can leave half a binary answering `carlos`. |
| +func selfReplace(exe, latest string) error { |
| + asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH) |
| + sums, err := fetchUpdateFile("SHA256SUMS.txt", 1<<20) |
| + if err != nil { |
| + return err |
| + } |
| + sig, err := fetchUpdateFile("SHA256SUMS.txt.sig", 4096) |
| + if err != nil { |
| + return fmt.Errorf("%v (a release without a signed checksum list predates carlos update)", err) |
| + } |
| + pub, err := base64.StdEncoding.DecodeString(cliUpdatePubKey) |
| + if err != nil || len(pub) != ed25519.PublicKeySize { |
| + return errors.New("embedded update key is invalid — this binary cannot self-update") |
| + } |
| + rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig))) |
| + if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) { |
| + return errors.New("SHA256SUMS.txt signature does not verify — refusing the download") |
| + } |
| + wantSum := "" |
| + for _, line := range strings.Split(string(sums), "\n") { |
| + fields := strings.Fields(line) |
| + if len(fields) == 2 && fields[1] == asset { |
| + wantSum = fields[0] |
| + } |
| + } |
| + if wantSum == "" { |
| + return fmt.Errorf("no %s in the release's checksum list", asset) |
| + } |
| + binData, err := fetchUpdateFile(asset, 256<<20) |
| + if err != nil { |
| + return err |
| + } |
| + if got := hex.EncodeToString(func() []byte { s := sha256.Sum256(binData); return s[:] }()); got != wantSum { |
| + return fmt.Errorf("%s does not match its signed checksum — refusing to install it", asset) |
| + } |
| + tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(tmp.Name()) |
| + if err := tmp.Chmod(0o755); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if _, err := tmp.Write(binData); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Close(); err != nil { |
| + return err |
| + } |
| + return os.Rename(tmp.Name(), exe) |
| +} |
| + |
| +func fetchUpdateFile(name string, limit int64) ([]byte, error) { |
| + client := &http.Client{Timeout: 5 * time.Minute} |
| + res, err := client.Get(updateDownloadBase + "/" + name) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + return nil, fmt.Errorf("%s: status %d", name, res.StatusCode) |
| + } |
| + return io.ReadAll(io.LimitReader(res.Body, limit)) |
| +} |