Merge remote-tracking branch 'origin/main' into worktree-console-app-pages
28 files changed,
+1974
−248
.github/workflows/ci.yml+107 −0CLAUDE.md+5 −0README.md+8 −0accounts.go+37 −30appconfig_cli.go+24 −25apps.go+15 −13auth.go+17 −14cli.go+45 −43color.go+222 −0color_test.go+128 −0docs/superpowers/specs/2026-08-04-instance-provisioning-design.md+33 −0go.mod+1 −1instances_cli.go+13 −12internal/console/attachhint_test.go+132 −0internal/console/server.go+79 −1internal/console/server_test.go+10 −1internal/console/templates/app_releases.html+7 −1internal/host/probes.go+1 −1internal/lease/endpoint.go+130 −0internal/lease/endpoint_test.go+263 −0internal/lease/lease.go+19 −19internal/lease/lease_test.go+7 −3internal/release/store.go+12 −9internal/release/store_rgw_test.go+169 −0internal/s3compat/s3compat.go+181 −0internal/s3compat/s3compat_test.go+235 −0main.go+53 −75testdata/echoapp/main.go+21 −0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml| new file mode 100644 |
| index 000000000..b461422e5 |
| --- /dev/null |
| +++ b/.github/workflows/ci.yml |
| @@ -0,0 +1,107 @@ |
| +# The build gate from CLAUDE.md, enforced by a machine rather than by |
| +# whoever happens to be at a keyboard. The gate itself is not new; what is |
| +# new is that it now runs somewhere other than a maintainer's Linux laptop. |
| +# |
| +# The cross-compile job exists because of a specific bug: readDiskFreeBytes |
| +# in internal/host/probes.go multiplied an int64 by syscall.Statfs_t.Bsize, |
| +# which is int64 on Linux and uint32 on darwin. That is a compile error on |
| +# macOS for every GOARCH, and internal/host is imported by the carlos CLI — |
| +# the laptop tool the README's developer story is built around. It sat in |
| +# main undetected because every person who built it was on Linux. A |
| +# `GOOS=darwin go build ./...` costs seconds on a Linux runner and would |
| +# have caught it at the commit that introduced it. |
| +name: CI |
| + |
| +on: |
| + push: |
| + branches: [main] |
| + pull_request: |
| + |
| +permissions: |
| + contents: read |
| + |
| +# A second push to a PR makes the first run's answer irrelevant. |
| +concurrency: |
| + group: ci-${{ github.workflow }}-${{ github.ref }} |
| + cancel-in-progress: true |
| + |
| +jobs: |
| + gate: |
| + name: build · vet · test (linux) |
| + runs-on: ubuntu-latest |
| + steps: |
| + - uses: actions/checkout@v4 |
| + - uses: actions/setup-go@v5 |
| + with: |
| + go-version-file: go.mod |
| + cache: true |
| + - run: go build ./... |
| + - run: go vet ./... |
| + # -count=1 to match CLAUDE.md: never satisfy the gate from a cached |
| + # pass. The explicit timeout is above the 10m default because the |
| + # root package's e2e tests compile the carlos binary many times over. |
| + - run: go test ./... -count=1 -timeout 30m |
| + |
| + cross: |
| + name: compile ${{ matrix.goos }}/${{ matrix.goarch }} |
| + runs-on: ubuntu-latest |
| + strategy: |
| + # One platform breaking must not hide the state of the others. |
| + fail-fast: false |
| + matrix: |
| + include: |
| + - {goos: darwin, goarch: arm64} |
| + - {goos: darwin, goarch: amd64} |
| + - {goos: linux, goarch: arm64} |
| + steps: |
| + - uses: actions/checkout@v4 |
| + - uses: actions/setup-go@v5 |
| + with: |
| + go-version-file: go.mod |
| + cache: true |
| + # Compile only — these binaries are never run, so no runner for the |
| + # target OS is needed. CGO_ENABLED=0 is already the default when |
| + # cross-compiling; it is explicit here so that adding a cgo dependency |
| + # fails loudly in this job rather than silently changing what is |
| + # covered. The tree is pure Go today (modernc.org/sqlite, no import "C"). |
| + - run: go build ./... |
| + env: |
| + GOOS: ${{ matrix.goos }} |
| + GOARCH: ${{ matrix.goarch }} |
| + CGO_ENABLED: "0" |
| + |
| + gofmt: |
| + name: gofmt (changed files) |
| + runs-on: ubuntu-latest |
| + # Only meaningful against a base to diff from. |
| + if: github.event_name == 'pull_request' |
| + steps: |
| + - uses: actions/checkout@v4 |
| + with: |
| + fetch-depth: 0 |
| + - uses: actions/setup-go@v5 |
| + with: |
| + go-version-file: go.mod |
| + # CLAUDE.md's rule is "gofmt -l clean for files you touch", not for the |
| + # tree: three pre-existing unformatted files are deliberately left |
| + # alone, so a repo-wide check would fail on arrival and teach everyone |
| + # to ignore this job. Scoped to the files the PR actually changes. |
| + - name: gofmt changed files |
| + env: |
| + BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| + run: | |
| + set -euo pipefail |
| + files=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- '*.go') |
| + if [ -z "$files" ]; then |
| + echo "no Go files changed" |
| + exit 0 |
| + fi |
| + echo "checking:" |
| + echo "$files" | sed 's/^/ /' |
| + unformatted=$(gofmt -l $files) |
| + if [ -n "$unformatted" ]; then |
| + echo |
| + echo "needs gofmt -w:" |
| + echo "$unformatted" | sed 's/^/ /' |
| + exit 1 |
| + fi |
diff --git a/CLAUDE.md b/CLAUDE.md| index 3ef9f15d4..67bb9728e 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -47,6 +47,11 @@ remote form was the bug.) |
| 18 packages, before any commit lands. |
| - `gofmt -l` clean for files you touch (three pre-existing unformatted files |
| elsewhere are left alone). |
| +- `.github/workflows/ci.yml` runs that same gate on every PR, plus a |
| + `GOOS=darwin` compile. **The gate is not Linux-only**: `internal/host` is |
| + linked into the CLI, which people run on Macs, so a Linux-only type |
| + assumption breaks `go build .` for them. Cross-compiling is enough to catch |
| + it — no Mac required. |
| - Sandbox note: use `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod |
| GOPROXY=off`; go commands may need the sandbox disabled ("read-only file |
| system" from the toolchain is the tell). |
diff --git a/README.md b/README.md| index f9c453f57..a775ae81f 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -84,6 +84,14 @@ defaulting to `ops`: |
| carlos ship -account kass -app blog ... |
| carlos add -account kass -app blog blog.example.com |
| +**Colour.** The CLI colours its output when — and only when — it is writing |
| +to a terminal: a write that happened reads green, a dry run or a bypassed |
| +rail yellow, a refusal red, and context you did not ask for (the `using |
| +<console> as <you>` banner, "nothing to do" verdicts, follow-up hints) is |
| +dimmed. Redirect it, pipe it, or run it in CI and every byte is what it |
| +always was, so parsing scripts and test assertions are unaffected. Override |
| +with `CARLOS_COLOR=always|never`; `NO_COLOR=1` and `TERM=dumb` are honoured. |
| + |
| **Who the CLI is.** `carlos auth login` gives the command line an identity, |
| and that identity owns the accounts it creates. It authenticates against the |
| **deployment's own console** — already a registered Keymail OAuth client and |
diff --git a/accounts.go b/accounts.go| index a703033c4..b398cc583 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -21,7 +21,6 @@ import ( |
| "os" |
| "slices" |
| "strings" |
| - "text/tabwriter" |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| @@ -149,8 +148,9 @@ func createAccountInBucket(regPath, name, owner string) { |
| if err := r.PutAccount(id, sqid, name); err != nil { |
| log.Fatalf("accounts create: materialize: %v", err) |
| } |
| - fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s\n", name, id, sqid, owner) |
| - fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid) |
| + fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s\n", |
| + colorFor(os.Stdout).ok("created account"), name, id, sqid, owner) |
| + mutedf(os.Stdout, "use it with: carlos ship -account %s -app <app> ...\n", sqid) |
| fmt.Printf("%s becomes its owner in the console the next time the console materializes\n"+ |
| "accounts from the bucket (on start, or on any `console member` command).\n", owner) |
| } |
| @@ -165,7 +165,7 @@ func createAccountInBucket(regPath, name, owner string) { |
| // gap that produced accounts nobody could reach. |
| func createAccountViaConsole(cred *credential, regPath, name string) { |
| if warn := credentialPermWarning(); warn != "" { |
| - fmt.Fprintln(os.Stderr, warn) |
| + warnf(os.Stderr, "%s", warn) |
| } |
| c := newConsoleClient(cred.Console, cred.Token) |
| var out struct { |
| @@ -179,23 +179,25 @@ func createAccountViaConsole(cred *credential, regPath, name string) { |
| map[string]string{"name": name}, &out); err != nil { |
| log.Fatalf("accounts create: %s: %v", cred.Console, err) |
| } |
| - fmt.Printf("created account %q\n id: %d\n sqid: %s\n owner: %s (you)\n", out.Name, out.ID, out.Sqid, out.Owner) |
| - fmt.Printf(" open: %s\n", out.URL) |
| - fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", out.Sqid) |
| + pal := colorFor(os.Stdout) |
| + fmt.Printf("%s %q\n id: %d\n sqid: %s\n owner: %s (you)\n", |
| + pal.ok("created account"), out.Name, out.ID, out.Sqid, out.Owner) |
| + fmt.Printf(" open: %s\n", pal.link(out.URL)) |
| + mutedf(os.Stdout, "use it with: carlos ship -account %s -app <app> ...\n", out.Sqid) |
| // The registry row is this box's local materialization of what the bucket |
| // already says. Best-effort, unlike the bucket-mint path: the account |
| // exists and is owned no matter what happens here, and a build machine |
| // with no registry at all is a perfectly normal place to run this. |
| r, err := registry.Open(regPath) |
| if err != nil { |
| - fmt.Printf("\n(no registry at %s, so nothing was materialized locally: %v —\n"+ |
| - "harmless: the bucket is the control plane and every box reconciles from it.)\n", regPath, err) |
| + mutedf(os.Stdout, "\n(no registry at %s, so nothing was materialized locally: %v —\n"+ |
| + "harmless: the bucket is the control plane and every box reconciles from it.)", regPath, err) |
| return |
| } |
| defer r.Close() |
| if err := r.PutAccount(out.ID, out.Sqid, out.Name); err != nil { |
| - fmt.Printf("\n(could not materialize the registry row here: %v — harmless: the account\n"+ |
| - "exists in the bucket and every box reconciles from it.)\n", err) |
| + mutedf(os.Stdout, "\n(could not materialize the registry row here: %v — harmless: the account\n"+ |
| + "exists in the bucket and every box reconciles from it.)", err) |
| } |
| } |
| @@ -229,7 +231,7 @@ func runAccountsList(args []string) { |
| } |
| apps[acct][rt.App] = true |
| } |
| - w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(os.Stdout) |
| fmt.Fprintln(w, "SQID\tNAME\tAPPS\tID") |
| for _, a := range accts { |
| fmt.Fprintf(w, "%s\t%s\t%d\t%d\n", a.Sqid, a.Name, len(apps[a.Sqid]), a.ID) |
| @@ -277,7 +279,7 @@ func runAccountsMigrate(args []string) { |
| // assuming a prefix. |
| plan, srcAcct, hadProduction := migrationPlan(ctx, store, *app, *from, *to, dst) |
| if len(plan) == 0 { |
| - fmt.Printf("nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app)) |
| + mutedf(os.Stdout, "nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app)) |
| } |
| // Pointers the legacy-source path refused to overwrite because a newer |
| // qualified pointer already exists; verification must not compare these |
| @@ -285,7 +287,7 @@ func runAccountsMigrate(args []string) { |
| var keptPointers []string |
| for _, c := range plan { |
| if *dryRun { |
| - fmt.Printf("would copy %s -> %s\n", c.from, c.to) |
| + warnf(os.Stdout, "would copy %s -> %s\n", c.from, c.to) |
| continue |
| } |
| body, _, err := store.Get(ctx, c.from) |
| @@ -318,8 +320,9 @@ func runAccountsMigrate(args []string) { |
| // pointer was NOT taken rather than left to find out from adopt. |
| if srcAcct == release.LegacySource { |
| if cur, _, err := store.Get(ctx, c.to); err == nil && string(cur) != string(body) { |
| - fmt.Printf("KEPT %s — a qualified pointer already exists and differs from the legacy\n"+ |
| - "copy; the qualified one is live state and wins. The legacy pointer was not taken.\n", c.to) |
| + fmt.Printf("%s %s — a qualified pointer already exists and differs from the legacy\n"+ |
| + "copy; the qualified one is live state and wins. The legacy pointer was not taken.\n", |
| + colorFor(os.Stdout).warn("KEPT"), c.to) |
| keptPointers = append(keptPointers, c.to) |
| continue |
| } |
| @@ -328,9 +331,10 @@ func runAccountsMigrate(args []string) { |
| case err != nil: |
| log.Fatalf("accounts migrate: write %s: %v", c.to, err) |
| case changed: |
| - fmt.Printf("converged %s -> %s (the destination pointer disagreed)\n", c.from, c.to) |
| + fmt.Printf("%s %s -> %s (the destination pointer disagreed)\n", |
| + colorFor(os.Stdout).ok("converged"), c.from, c.to) |
| default: |
| - fmt.Printf("already copied %s -> %s\n", c.from, c.to) |
| + mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to) |
| } |
| continue |
| } |
| @@ -344,16 +348,16 @@ func runAccountsMigrate(args []string) { |
| "overwrite an immutable release object. Nothing has been deleted; work out which\n"+ |
| "of the two is the real %s before going further.", c.to, *app) |
| } |
| - fmt.Printf("already copied %s -> %s\n", c.from, c.to) |
| + mutedf(os.Stdout, "already copied %s -> %s\n", c.from, c.to) |
| continue |
| } |
| - fmt.Printf("copied %s -> %s\n", c.from, c.to) |
| + fmt.Printf("%s %s -> %s\n", colorFor(os.Stdout).ok("copied"), c.from, c.to) |
| } |
| // The one object that does not travel. Loud, because nothing downstream |
| // will notice: a destination with no flag simply defaults to "not |
| // production", so the app stops baking silently rather than failing. |
| if hadProduction { |
| - fmt.Println("\nNOT COPIED:") |
| + fmt.Printf("\n%s\n", colorFor(os.Stdout).warn("NOT COPIED:")) |
| fmt.Printf(" %s: carried a production declaration — re-declare it at the destination "+ |
| "(the flag is signed per-account and cannot be copied)\n", *app) |
| } |
| @@ -371,13 +375,13 @@ func runAccountsMigrate(args []string) { |
| continue |
| } |
| if *dryRun { |
| - fmt.Printf("would stamp account %s on route %s (was %q)\n", *to, rt.Host, rt.Account) |
| + warnf(os.Stdout, "would stamp account %s on route %s (was %q)\n", *to, rt.Host, rt.Account) |
| continue |
| } |
| if err := r.SetAccount(rt.Host, *to); err != nil { |
| log.Fatalf("accounts migrate: stamp %s: %v", rt.Host, err) |
| } |
| - fmt.Printf("stamped account %s on route %s\n", *to, rt.Host) |
| + fmt.Printf("%s account %s on route %s\n", colorFor(os.Stdout).ok("stamped"), *to, rt.Host) |
| } |
| // The console keeps its OWN app→account row, and this binary deliberately |
| @@ -387,7 +391,7 @@ func runAccountsMigrate(args []string) { |
| // output rather than in a runbook somebody has to find. |
| followUp := fmt.Sprintf("console migrate-app -app %s -to %s", *app, *to) |
| if *dryRun { |
| - fmt.Println("dry run: nothing was written") |
| + warnf(os.Stdout, "dry run: nothing was written") |
| fmt.Printf("\nafterwards, on the console's box: %s\n", followUp) |
| return |
| } |
| @@ -496,7 +500,7 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst |
| if len(keys) == 0 { |
| log.Fatalf("accounts migrate: nothing under %s — %s has no legacy objects to move", src, app) |
| } |
| - fmt.Printf("source: %s (the legacy, account-less keys)\n", src) |
| + mutedf(os.Stdout, "source: %s (the legacy, account-less keys)\n", src) |
| var out []copyOp |
| hadProduction := false |
| for _, k := range keys { |
| @@ -548,9 +552,9 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst |
| fmt.Printf("exists in both (that is what the runtime reads), and the legacy copy of the\n") |
| fmt.Printf("rest, so no rollback target is lost.\n") |
| } else if len(opsKeys) > 0 { |
| - fmt.Printf("source: %s\n", opsPrefix) |
| + mutedf(os.Stdout, "source: %s\n", opsPrefix) |
| } else if len(legacyKeys) > 0 { |
| - fmt.Printf("source: %s\n", legacyPrefix) |
| + mutedf(os.Stdout, "source: %s\n", legacyPrefix) |
| } |
| // Qualified first, then legacy for anything it did not cover — the |
| @@ -601,7 +605,8 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc |
| // construction, and the exemption was already printed. |
| if slices.Contains(kept, key) { |
| channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json") |
| - fmt.Printf("verified %s: kept the existing qualified pointer (legacy copy was stale)\n", channel) |
| + fmt.Printf("%s %s: kept the existing qualified pointer (legacy copy was stale)\n", |
| + colorFor(os.Stdout).ok("verified"), channel) |
| continue |
| } |
| channel := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json") |
| @@ -653,10 +658,12 @@ func verifyMigratedPointers(ctx context.Context, store release.Store, app, srcAc |
| if err != nil { |
| log.Fatalf("accounts migrate: verify: %s/%s does not resolve under %s: %v", app, channel, dstAcct, err) |
| } |
| - fmt.Printf("verified %s: %s serves %s under %s\n", channel, app, p.Version, dstAcct) |
| + fmt.Printf("%s %s: %s serves %s under %s\n", |
| + colorFor(os.Stdout).ok("verified"), channel, app, p.Version, dstAcct) |
| continue |
| } |
| - fmt.Printf("verified %s: byte-identical to what %s serves (set CARLOS_RELEASE_PUBKEY to see versions)\n", channel, srcAcct) |
| + fmt.Printf("%s %s: byte-identical to what %s serves (set CARLOS_RELEASE_PUBKEY to see versions)\n", |
| + colorFor(os.Stdout).ok("verified"), channel, srcAcct) |
| } |
| } |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index 977c5513f..59b10801a 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -12,7 +12,6 @@ import ( |
| "os" |
| "sort" |
| "strings" |
| - "text/tabwriter" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -211,7 +210,7 @@ func runEnvSet(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| set := make(map[string]appconfig.Var, len(kv)) |
| for k, v := range kv { |
| set[k] = appconfig.Var{Kind: "env", Value: v} |
| @@ -220,14 +219,14 @@ func runEnvSet(args []string) { |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) |
| return |
| } |
| if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d env var(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) |
| } |
| func runEnvUnset(args []string) { |
| @@ -247,13 +246,13 @@ func runEnvUnset(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("unset %d of %d var(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) |
| return |
| } |
| @@ -261,7 +260,7 @@ func runEnvUnset(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("unset %d of %d var(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d of %d var(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) |
| } |
| func runEnvList(args []string) { |
| @@ -281,7 +280,7 @@ func runEnvList(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
| @@ -323,7 +322,7 @@ func envVarsFromAPI(vars []apiConfigVar) map[string]string { |
| // byte-identical between the direct and API branches of runEnvList, which |
| // differ only in HOW they fetch execDelivery/vars. |
| func printEnvList(app, environment, execDelivery string, vars map[string]string) { |
| - fmt.Printf("%s (%s):\n", app, bundleLabel(environment)) |
| + fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment)))) |
| // exec_delivery is app-level only and never printed for a named |
| // environment's listing, matching where it can ever actually be SET |
| // (runEnvExecDelivery refuses -environment before it reaches appconfig |
| @@ -336,7 +335,7 @@ func printEnvList(app, environment, execDelivery string, vars map[string]string) |
| keys = append(keys, k) |
| } |
| sort.Strings(keys) |
| - w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(os.Stdout) |
| fmt.Fprintln(w, "KEY\tVALUE") |
| for _, k := range keys { |
| fmt.Fprintf(w, "%s\t%s\n", k, vars[k]) |
| @@ -389,7 +388,7 @@ func runEnvExecDelivery(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| // The wire vocabulary is "file"/"off", never "" — value is "" for |
| // the local SetExecDelivery convention (clearing the field), but |
| // the console's PUT decodes ExecDelivery as a pointer where nil |
| @@ -406,9 +405,9 @@ func runEnvExecDelivery(args []string) { |
| log.Fatal(err) |
| } |
| if value == "" { |
| - fmt.Printf("exec-delivery off for %s\n", *app) |
| + fmt.Printf("%s off for %s\n", colorFor(os.Stdout).ok("exec-delivery"), *app) |
| } else { |
| - fmt.Printf("exec-delivery %s for %s\n", value, *app) |
| + fmt.Printf("%s %s for %s\n", colorFor(os.Stdout).ok("exec-delivery"), value, *app) |
| } |
| } |
| @@ -427,7 +426,7 @@ func runEnvEnvironments(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, "") |
| if err != nil { |
| log.Fatal(err) |
| @@ -477,16 +476,16 @@ func runEnvSync(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| if err := sess.configSync(context.Background(), apiAccount(acctRaw, acctIDRaw), *app); err != nil { |
| log.Fatal(err) |
| } |
| } else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("config sync requested for %s — exec-backed instances converge within seconds; "+ |
| + fmt.Printf("%s for %s — exec-backed instances converge within seconds; "+ |
| "unit-backed routes within seconds where the box's carlos-adopt.path trigger is armed, "+ |
| - "else on the next hourly root pass\n", *app) |
| + "else on the next hourly root pass\n", colorFor(os.Stdout).ok("config sync requested"), *app) |
| } |
| const secretsUsage = `usage: carlos secrets <command> |
| @@ -608,7 +607,7 @@ func runSecretsSet(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| pub := secretsPubKeyAPI(sess, apiAccount(acctRaw, acctIDRaw), *app) |
| set := make(map[string]appconfig.Var, len(kv)) |
| for k, v := range kv { |
| @@ -622,7 +621,7 @@ func runSecretsSet(args []string) { |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) |
| return |
| } |
| @@ -638,7 +637,7 @@ func runSecretsSet(args []string) { |
| if err := saveSecrets(context.Background(), openStore(), acctOf(), *app, *environment, sealed); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("set"), len(kv), *app, bundleLabel(*environment)) |
| } |
| func runSecretsUnset(args []string) { |
| @@ -658,13 +657,13 @@ func runSecretsUnset(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) |
| return |
| } |
| @@ -672,7 +671,7 @@ func runSecretsUnset(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment)) |
| + fmt.Printf("%s %d of %d secret(s) for %s (%s)\n", colorFor(os.Stdout).ok("unset"), removed, fs.NArg(), *app, bundleLabel(*environment)) |
| } |
| // printSecretsList renders `secrets list`'s output — header line, then |
| @@ -680,7 +679,7 @@ func runSecretsUnset(args []string) { |
| // and API branches of runSecretsList, which differ only in HOW they fetch |
| // the key set. |
| func printSecretsList(app, environment string, keys []string) { |
| - fmt.Printf("%s (%s):\n", app, bundleLabel(environment)) |
| + fmt.Printf("%s\n", colorFor(os.Stdout).head(fmt.Sprintf("%s (%s):", app, bundleLabel(environment)))) |
| sort.Strings(keys) |
| for _, k := range keys { |
| fmt.Println(k) |
| @@ -704,7 +703,7 @@ func runSecretsList(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(os.Stdout, sess) |
| cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
diff --git a/apps.go b/apps.go| index d255eab49..9f5587589 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -179,7 +179,7 @@ func runAppsDelete(args []string) { |
| if mode == "signed-in" { |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| res, err := sess.trashApp(context.Background(), acct, app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -268,9 +268,10 @@ func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicK |
| // back releases and channels but no routes — repeated at delete time as well |
| // as restore time because nothing else in the terminal will say it twice. |
| func printTrashed(app, restorableUntil string) { |
| - fmt.Fprintf(stdout, "trashed %s — restorable until %s (carlos apps restore %s)\n", app, restorableUntil, app) |
| - fmt.Fprintf(stdout, "restoring brings back its releases and channels, but NO routes — "+ |
| - "carlos add <host> to serve it again\n") |
| + fmt.Fprintf(stdout, "%s %s — restorable until %s (carlos apps restore %s)\n", |
| + colorFor(stdout).ok("trashed"), app, restorableUntil, app) |
| + mutedf(stdout, "restoring brings back its releases and channels, but NO routes — "+ |
| + "carlos add <host> to serve it again") |
| } |
| func runAppsRestore(args []string) { |
| @@ -298,19 +299,19 @@ func runAppsRestore(args []string) { |
| if mode == "signed-in" { |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| res, err := sess.restoreApp(context.Background(), acct, app) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "restored %s\n", app) |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app) |
| if res.Note != "" { |
| // The server's own wording (handleAPIRestoreApp) — printed |
| // verbatim rather than reworded here, so the two modes cannot |
| // drift apart on what they tell someone about routing. |
| fmt.Fprintln(stdout, res.Note) |
| } else { |
| - fmt.Fprintf(stdout, "%s has NO routes — carlos add <host> to serve it again\n", app) |
| + mutedf(stdout, "%s has NO routes — carlos add <host> to serve it again\n", app) |
| } |
| return |
| } |
| @@ -325,9 +326,9 @@ func runAppsRestore(args []string) { |
| if err := release.RestoreApp(context.Background(), store, acctID, app); err != nil { |
| log.Fatalf("apps restore: %v", err) |
| } |
| - fmt.Fprintf(stdout, "restored %s\n", app) |
| - fmt.Fprintf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+ |
| - "carlos add <host> to serve it again\n", app) |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("restored"), app) |
| + mutedf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+ |
| + "carlos add <host> to serve it again", app) |
| } |
| func runAppsCreate(args []string) { |
| @@ -365,12 +366,13 @@ func runAppsCreate(args []string) { |
| "unset the store env and run: carlos auth login -console <url>") |
| } |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app)) |
| if err != nil { |
| log.Fatalf("apps create: %v", err) |
| } |
| - fmt.Fprintf(stdout, "created app %s in account %s — it's yours\n", res.App, res.Account) |
| - fmt.Fprintf(stdout, " open: %s\n", res.URL) |
| + c := colorFor(stdout) |
| + fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account) |
| + fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL)) |
| fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App) |
| } |
diff --git a/auth.go b/auth.go| index e8053f778..ade8dfe54 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -99,7 +99,7 @@ func RunAuth(args []string) { |
| os.Exit(1) |
| } |
| if err != nil { |
| - fmt.Fprintf(os.Stderr, "carlos auth %s: %v\n", args[0], err) |
| + fmt.Fprintf(os.Stderr, "%s %v\n", colorFor(os.Stderr).bad("carlos auth "+args[0]+":"), err) |
| os.Exit(1) |
| } |
| } |
| @@ -631,9 +631,12 @@ func authLogin(out io.Writer, args []string) error { |
| } |
| // Printed, never opened: this frequently runs over SSH, where opening a |
| // browser would open it on the wrong machine. |
| - fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, link) |
| - fmt.Fprintf(out, "and type this code:\n\n %s\n\n", start.UserCode) |
| - fmt.Fprintf(out, "Waiting for approval (this code expires in %d minutes)…\n", start.ExpiresIn/60) |
| + pal := colorFor(out) |
| + fmt.Fprintf(out, "\nOpen this in a browser signed in to %s:\n\n %s\n\n", base, pal.link(link)) |
| + // The code is the one thing here a person has to read off the screen and |
| + // type somewhere else, so it is the one thing given weight. |
| + fmt.Fprintf(out, "and type this code:\n\n %s\n\n", pal.head(start.UserCode)) |
| + mutedf(out, "Waiting for approval (this code expires in %d minutes)…", start.ExpiresIn/60) |
| deadline := loginDeadline(time.Now(), start.ExpiresIn) |
| wait := pollWait(start.Interval) |
| @@ -682,12 +685,12 @@ func authLogin(out io.Writer, args []string) error { |
| // token first exists on this machine, and a pre-existing file with |
| // loose permissions is exactly when to say so. |
| if warn := credentialPermWarning(); warn != "" { |
| - fmt.Fprintln(out, warn) |
| + warnf(out, "%s", warn) |
| } |
| - fmt.Fprintf(out, "\nLogged in to %s as %s.\n", base, poll.Address) |
| - fmt.Fprintf(out, "Token stored in %s (mode 0600); it expires %s.\n", |
| + fmt.Fprintf(out, "\n%s to %s as %s.\n", pal.ok("Logged in"), base, poll.Address) |
| + mutedf(out, "Token stored in %s (mode 0600); it expires %s.", |
| credentialsPath(), expires.Format("2006-01-02")) |
| - fmt.Fprintf(out, "Revoke it any time at %s/auth/cli/tokens\n", base) |
| + mutedf(out, "Revoke it any time at %s/auth/cli/tokens", base) |
| return nil |
| default: |
| return fmt.Errorf("the console answered with an unknown status %q", poll.Status) |
| @@ -712,7 +715,7 @@ func authWhoami(out io.Writer, args []string) error { |
| return err |
| } |
| if warn := credentialPermWarning(); warn != "" { |
| - fmt.Fprintln(out, warn) |
| + warnf(out, "%s", warn) |
| } |
| var who whoamiResponse |
| if _, err := newConsoleClient(c.Console, c.Token). |
| @@ -724,10 +727,10 @@ func authWhoami(out io.Writer, args []string) error { |
| } |
| fmt.Fprintf(out, "%s at %s\n", who.Address, c.Console) |
| if who.Label != "" { |
| - fmt.Fprintf(out, " terminal: %s\n", who.Label) |
| + mutedf(out, " terminal: %s", who.Label) |
| } |
| if who.ExpiresAt != "" { |
| - fmt.Fprintf(out, " expires: %s\n", who.ExpiresAt) |
| + mutedf(out, " expires: %s", who.ExpiresAt) |
| } |
| return nil |
| } |
| @@ -762,11 +765,11 @@ func authLogout(out io.Writer, args []string) error { |
| return err |
| } |
| if revokeErr != nil { |
| - fmt.Fprintf(out, "Forgot the credential for %s locally, but could not revoke it there: %v\n", origin, revokeErr) |
| - fmt.Fprintf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens\n", origin) |
| + warnf(out, "Forgot the credential for %s locally, but could not revoke it there: %v", origin, revokeErr) |
| + warnf(out, "It stays live until it expires — revoke it at %s/auth/cli/tokens", origin) |
| return nil |
| } |
| - fmt.Fprintf(out, "Logged out of %s — the token is revoked.\n", origin) |
| + fmt.Fprintf(out, "%s of %s — the token is revoked.\n", colorFor(out).ok("Logged out"), origin) |
| return nil |
| } |
diff --git a/cli.go b/cli.go| index 226a8e54d..78682b327 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -21,7 +21,6 @@ import ( |
| "regexp" |
| "sort" |
| "strings" |
| - "text/tabwriter" |
| "time" |
| "github.com/carlosframework/platform/internal/appconfig" |
| @@ -159,7 +158,7 @@ func runAdd(args []string) { |
| }); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("added %s\n", host) |
| + fmt.Printf("%s %s\n", colorFor(os.Stdout).ok("added"), host) |
| } |
| func runRemove(args []string) { |
| @@ -177,7 +176,7 @@ func runRemove(args []string) { |
| if err := r.Delete(fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("removed %s\n", fs.Arg(0)) |
| + fmt.Printf("%s %s\n", colorFor(os.Stdout).ok("removed"), fs.Arg(0)) |
| } |
| func runInstances(args []string) { |
| @@ -213,7 +212,7 @@ func runInstances(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| - w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(os.Stdout) |
| fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tSTATUS\tVERSION\tHIB\tBACKING") |
| for _, t := range routes { |
| hib := "" |
| @@ -570,7 +569,7 @@ func runRoute(args []string) { |
| // applied structurally below for every candidate, not just |
| // here — so this exclusion is belt-and-braces for those, but |
| // load-bearing for a plain Platform apex.) |
| - fmt.Printf("%s: skipped — platform-minted route; use -host %s to change it explicitly\n", rt.Host, rt.Host) |
| + warnf(os.Stdout, "%s: skipped — platform-minted route; use -host %s to change it explicitly\n", rt.Host, rt.Host) |
| continue |
| } |
| candidates = append(candidates, rt) |
| @@ -823,7 +822,8 @@ func runRoute(args []string) { |
| for _, msg := range refusals { |
| fmt.Fprintln(os.Stderr, msg) |
| } |
| - fmt.Fprintf(os.Stderr, "route: %d of %d route(s) refused — nothing written\n", len(refusals), len(candidates)) |
| + fmt.Fprintf(os.Stderr, "%s %d of %d route(s) refused — nothing written\n", |
| + colorFor(os.Stderr).bad("route:"), len(refusals), len(candidates)) |
| os.Exit(1) |
| } |
| @@ -868,7 +868,7 @@ func runRoute(args []string) { |
| prefix := "" |
| if *dryRun { |
| - prefix = "(dry run) " |
| + prefix = colorFor(os.Stdout).warn("(dry run)") + " " |
| } |
| envLabel := func(e string) string { |
| if e == "" { |
| @@ -886,7 +886,7 @@ func runRoute(args []string) { |
| if chanVerdicts != nil { |
| v := chanVerdicts[i] |
| if v.kind == vNoop { |
| - fmt.Printf("%s: already following %s — nothing to do\n", v.rt.Host, *channel) |
| + mutedf(os.Stdout, "%s: already following %s — nothing to do\n", v.rt.Host, *channel) |
| } else { |
| fmt.Printf("%s%s: %s -> %s (%s)\n", prefix, v.rt.Host, v.from, *channel, v.note) |
| } |
| @@ -894,14 +894,14 @@ func runRoute(args []string) { |
| if envVerdicts != nil { |
| v := envVerdicts[i] |
| if v.kind == eNoop { |
| - fmt.Printf("%s: environment already %s — nothing to do\n", v.rt.Host, envLabel(resolvedEnv)) |
| + mutedf(os.Stdout, "%s: environment already %s — nothing to do\n", v.rt.Host, envLabel(resolvedEnv)) |
| } else { |
| fmt.Printf("%s%s: environment %s -> %s\n", prefix, v.rt.Host, envLabel(v.from), envLabel(resolvedEnv)) |
| } |
| } |
| if *tunnel != "" && i == 0 { |
| if tunnelNoop { |
| - fmt.Printf("%s: tunnel already %s — nothing to do\n", candidates[0].Host, tunnelLabel(resolvedTunnel)) |
| + mutedf(os.Stdout, "%s: tunnel already %s — nothing to do\n", candidates[0].Host, tunnelLabel(resolvedTunnel)) |
| } else { |
| fmt.Printf("%s%s: tunnel %s -> %s\n", prefix, candidates[0].Host, tunnelLabel(tunnelFrom), tunnelLabel(resolvedTunnel)) |
| } |
| @@ -954,7 +954,7 @@ func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok |
| prefix := "" |
| if dryRun { |
| - prefix = "(dry run) " |
| + prefix = colorFor(os.Stdout).warn("(dry run)") + " " |
| } |
| var next string |
| if granting { |
| @@ -969,7 +969,7 @@ func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok |
| log.Fatalf("route: %s: %v", rt.Host, err) |
| } |
| if cur.Has(tok) { |
| - fmt.Printf("%s: %s already granted — nothing to do\n", rt.Host, tok) |
| + mutedf(os.Stdout, "%s: %s already granted — nothing to do\n", rt.Host, tok) |
| return |
| } |
| } else { |
| @@ -979,7 +979,7 @@ func runRouteGrant(r *registry.Registry, rt registry.Route, grantTok, revokeTok |
| if !cur.Has(tok) { |
| // A quiet success: the caller's goal is "not granted", not "I |
| // removed something". |
| - fmt.Printf("%s: %s was not granted — nothing to revoke\n", rt.Host, tok) |
| + mutedf(os.Stdout, "%s: %s was not granted — nothing to revoke\n", rt.Host, tok) |
| return |
| } |
| } |
| @@ -1102,10 +1102,10 @@ func runRouteHibernate(r *registry.Registry, rt registry.Route, on, dryRun bool) |
| } |
| prefix := "" |
| if dryRun { |
| - prefix = "(dry run) " |
| + prefix = colorFor(os.Stdout).warn("(dry run)") + " " |
| } |
| if rt.Hibernate == on { |
| - fmt.Printf("%s: hibernate already %v — nothing to do\n", rt.Host, on) |
| + mutedf(os.Stdout, "%s: hibernate already %v — nothing to do\n", rt.Host, on) |
| return |
| } |
| if !dryRun { |
| @@ -1147,10 +1147,10 @@ func runRouteBacking(r *registry.Registry, rt registry.Route, backing string, dr |
| } |
| prefix := "" |
| if dryRun { |
| - prefix = "(dry run) " |
| + prefix = colorFor(os.Stdout).warn("(dry run)") + " " |
| } |
| if rt.Backing == backing { |
| - fmt.Printf("%s: backing already %s — nothing to do\n", rt.Host, backingWord(backing)) |
| + mutedf(os.Stdout, "%s: backing already %s — nothing to do\n", rt.Host, backingWord(backing)) |
| return |
| } |
| if !dryRun { |
| @@ -1524,7 +1524,7 @@ func runShip(args []string) { |
| if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "shipped %s %s (%d artifacts)\n", *app, *ver, len(files)) |
| + fmt.Fprintf(stdout, "%s %s %s (%d artifacts)\n", colorFor(stdout).ok("shipped"), *app, *ver, len(files)) |
| } |
| // readCapabilities is the ship-time declaration surface: a file holding |
| @@ -1644,15 +1644,15 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind |
| } |
| } |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| res, err := sess.ship(context.Background(), acct, app, ver, kind, files, capsSet) |
| if err != nil { |
| return err |
| } |
| if res.NoOp { |
| - fmt.Fprintf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver) |
| + mutedf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver) |
| } else { |
| - fmt.Fprintf(stdout, "shipped %s %s\n", app, ver) |
| + fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, ver) |
| } |
| return nil |
| } |
| @@ -1689,15 +1689,15 @@ func runPromote(args []string) { |
| log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console") |
| } |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| res, err := sess.promote(context.Background(), acct, *app, version, channel) |
| if err != nil { |
| log.Fatal(err) |
| } |
| if res.NoOp { |
| - fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| + mutedf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| } else { |
| - fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel) |
| + fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel) |
| } |
| return |
| } |
| @@ -1710,11 +1710,13 @@ func runPromote(args []string) { |
| } |
| switch { |
| case res.NoOp: |
| - fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| + mutedf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| case res.Hotfix: |
| - fmt.Fprintf(stdout, "promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel) |
| + c := colorFor(stdout) |
| + fmt.Fprintf(stdout, "%s %s %s -> %s %s\n", c.ok("promoted"), *app, version, channel, |
| + c.warn("(HOTFIX: ladder bypassed, recorded)")) |
| default: |
| - fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel) |
| + fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, version, channel) |
| } |
| } |
| @@ -1747,12 +1749,12 @@ func runChannels(args []string) { |
| } |
| if sess != nil { |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| chans, err := sess.channels(context.Background(), acct, *app) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(stdout) |
| fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| for _, ch := range chans { |
| if ch.Err != "" { |
| @@ -1773,7 +1775,7 @@ func runChannels(args []string) { |
| log.Fatal(err) |
| } |
| pub := publicKey() |
| - w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(stdout) |
| fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| for _, k := range keys { |
| name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| @@ -1803,18 +1805,18 @@ func runRollback(args []string) { |
| } |
| if sess != nil { |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0)) |
| + fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), *app, fs.Arg(0)) |
| return |
| } |
| if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0)) |
| + fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("rolled back"), *app, fs.Arg(0)) |
| } |
| // ledgerUsage lists every `carlos ledger` sub-verb — printed for a bare |
| @@ -1959,12 +1961,12 @@ func runLedgerAppend(args []string) { |
| } |
| sess := ledgerAPISession(*consoleFlag) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| a, err := sess.ledgerAppend(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, json.RawMessage(raw)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "appended to %s: entry #%d (sha %s)\n", name, a.Count, a.SHA256[:12]) |
| + fmt.Fprintf(stdout, "%s %s: entry #%d (sha %s)\n", colorFor(stdout).ok("appended to"), name, a.Count, a.SHA256[:12]) |
| } |
| // runLedgerHead prints HEAD.json verbatim — no "using ... as ..." banner |
| @@ -2008,7 +2010,7 @@ func runLedgerList(args []string) { |
| } |
| sess := ledgerAPISession(*consoleFlag) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| body, err := sess.ledgerList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -2023,7 +2025,7 @@ func runLedgerList(args []string) { |
| switch { |
| case res.Manifest == nil: |
| - fmt.Fprintln(stdout, "not published — nothing is public for this app yet") |
| + mutedf(stdout, "not published — nothing is public for this app yet") |
| case res.Manifest.Public: |
| title := res.Manifest.Title |
| if title == "" { |
| @@ -2031,13 +2033,13 @@ func runLedgerList(args []string) { |
| } |
| fmt.Fprintf(stdout, "published %q: %s\n", title, strings.Join(res.Manifest.Ledgers, ", ")) |
| default: |
| - fmt.Fprintln(stdout, "unpublished (public=false)") |
| + mutedf(stdout, "unpublished (public=false)") |
| } |
| if len(res.Ledgers) == 0 { |
| - fmt.Fprintln(stdout, "no ledgers yet") |
| + mutedf(stdout, "no ledgers yet") |
| return |
| } |
| - w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(stdout) |
| fmt.Fprintln(w, "LEDGER") |
| for _, n := range res.Ledgers { |
| fmt.Fprintln(w, n) |
| @@ -2064,7 +2066,7 @@ func runLedgerPublish(args []string) { |
| } |
| sess := ledgerAPISession(*consoleFlag) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title} |
| got, err := sess.ledgerPublish(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, m) |
| if err != nil { |
| @@ -2103,7 +2105,7 @@ func runLedgerBlob(args []string) { |
| files := fs.Args()[1:] |
| sess := ledgerAPISession(*consoleFlag) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| for _, path := range files { |
| data, err := os.ReadFile(path) |
| if err != nil { |
| @@ -2147,7 +2149,7 @@ func runLedgerVerify(args []string) { |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Fprintf(stdout, "verified: %d entries, head %s\n", res.Count, res.HeadSHA256[:12]) |
| + fmt.Fprintf(stdout, "%s %d entries, head %s\n", colorFor(stdout).ok("verified:"), res.Count, res.HeadSHA256[:12]) |
| } |
| // defaultGrantTTL is how long a scoped key lives when the operator does not |
diff --git a/color.go b/color.go| new file mode 100644 |
| index 000000000..703055f83 |
| --- /dev/null |
| +++ b/color.go |
| @@ -0,0 +1,222 @@ |
| +// Terminal colour for the operator CLI. Colour here is chrome, never |
| +// content: every line still says exactly what it said before, and a pipe, |
| +// a log file or a CI run sees byte-identical output to what it saw before |
| +// this file existed. That rule is what makes it safe to sprinkle — the |
| +// e2e tests exec the binary with its stdout on a pipe, so none of them can |
| +// be broken by a palette change. |
| +// |
| +// The decision is per WRITER, not global, because the CLI's print seam |
| +// (cli.go's `stdout` var) is swapped for a buffer by in-process tests: a |
| +// package-level "are we on a terminal" flag read from os.Stdout would leak |
| +// escape codes into those buffers on a developer's laptop while staying |
| +// clean in CI. A buffer is not an *os.File, so it never gets colour. |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "io" |
| + "os" |
| + "strings" |
| + "sync" |
| + "text/tabwriter" |
| +) |
| + |
| +// SGR codes. Deliberately the 8-colour set plus dim/bold: those render |
| +// sanely against both light and dark terminal themes, which 256-colour |
| +// picks do not. |
| +const ( |
| + ansiReset = "\x1b[0m" |
| + ansiBold = "\x1b[1m" |
| + ansiDim = "\x1b[2m" |
| + ansiRed = "\x1b[31m" |
| + ansiGreen = "\x1b[32m" |
| + ansiYellow = "\x1b[33m" |
| + ansiCyan = "\x1b[36m" |
| +) |
| + |
| +// colors is a palette bound to one writer. The zero value is the no-colour |
| +// palette, so anything that forgets to ask still prints plain text. |
| +type colors struct{ on bool } |
| + |
| +// colorFor decides whether the writer wants colour. Anything that is not a |
| +// real file — a test buffer, a bytes.Buffer behind the `stdout` seam — gets |
| +// the plain palette without consulting the environment at all. |
| +func colorFor(w io.Writer) colors { |
| + f, ok := w.(*os.File) |
| + if !ok { |
| + return colors{} |
| + } |
| + return colors{on: colorEnabled(f)} |
| +} |
| + |
| +// colorEnabled is the precedence chain, most explicit first: |
| +// |
| +// CARLOS_COLOR=always|never an operator's own override, wins over all |
| +// NO_COLOR=<anything> the cross-tool convention (no-color.org) |
| +// TERM unset or "dumb" the terminal has told us it cannot |
| +// otherwise colour iff this writer is a terminal |
| +func colorEnabled(f *os.File) bool { |
| + switch strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_COLOR"))) { |
| + case "always", "force", "1", "true", "yes": |
| + return true |
| + case "never", "0", "false", "no": |
| + return false |
| + } |
| + if os.Getenv("NO_COLOR") != "" { |
| + return false |
| + } |
| + if t := os.Getenv("TERM"); t == "" || t == "dumb" { |
| + return false |
| + } |
| + return isTerminal(f) |
| +} |
| + |
| +var ( |
| + stdoutIsTTY = sync.OnceValue(func() bool { return charDevice(os.Stdout) }) |
| + stderrIsTTY = sync.OnceValue(func() bool { return charDevice(os.Stderr) }) |
| +) |
| + |
| +// isTerminal answers for the two files every CLI verb writes to without a |
| +// syscall per line; anything else is stat'd on the spot (only tests do |
| +// that, and only a handful of times). |
| +func isTerminal(f *os.File) bool { |
| + switch f { |
| + case os.Stdout: |
| + return stdoutIsTTY() |
| + case os.Stderr: |
| + return stderrIsTTY() |
| + } |
| + return charDevice(f) |
| +} |
| + |
| +// charDevice is the dependency-free isatty: a pipe, a regular file and a |
| +// socket all fail it, which is exactly the set we must not colour. (The |
| +// repo has no isatty dependency and does not need one for this.) |
| +func charDevice(f *os.File) bool { |
| + if f == nil { |
| + return false |
| + } |
| + fi, err := f.Stat() |
| + if err != nil { |
| + return false |
| + } |
| + return fi.Mode()&os.ModeCharDevice != 0 |
| +} |
| + |
| +// The vocabulary is semantic, not chromatic: call sites say what a span of |
| +// text MEANS and this file decides what that looks like. |
| +// |
| +// muted context the operator did not ask for — banners, "nothing to do" |
| +// ok a write actually happened |
| +// warn a dry run, a skip, a bypassed rail |
| +// bad a refusal or an error |
| +// head a heading or a table header row |
| +// link a URL or a code to type |
| +func (c colors) muted(s string) string { return c.wrap(ansiDim, s) } |
| +func (c colors) ok(s string) string { return c.wrap(ansiGreen, s) } |
| +func (c colors) warn(s string) string { return c.wrap(ansiYellow, s) } |
| +func (c colors) bad(s string) string { return c.wrap(ansiRed, s) } |
| +func (c colors) head(s string) string { return c.wrap(ansiBold, s) } |
| +func (c colors) link(s string) string { return c.wrap(ansiCyan, s) } |
| + |
| +func (c colors) wrap(code, s string) string { |
| + if !c.on || s == "" { |
| + return s |
| + } |
| + return code + s + ansiReset |
| +} |
| + |
| +// mutedf and warnf print one whole line in one style. They take the same |
| +// format string the call site had before (trailing newline and all) so the |
| +// only thing that changes at a call site is which function it calls. Whole |
| +// lines earn a style only when the whole line is one thought: a "nothing to |
| +// do" verdict the operator can skim past (muted), or a refusal to act |
| +// (warn). Anywhere the line mixes a verb with real content, style the verb |
| +// and leave the content plain — a paragraph of green is noise. |
| +func mutedf(w io.Writer, format string, a ...any) { |
| + fmt.Fprintln(w, colorFor(w).muted(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n"))) |
| +} |
| + |
| +func warnf(w io.Writer, format string, a ...any) { |
| + fmt.Fprintln(w, colorFor(w).warn(strings.TrimSuffix(fmt.Sprintf(format, a...), "\n"))) |
| +} |
| + |
| +// printUsing prints the "which console, as whom" banner nearly every |
| +// API-mode verb opens with. Dimmed, because it is context for the lines |
| +// under it rather than the answer anybody typed the command to get. |
| +func printUsing(w io.Writer, sess *apiSession) { |
| + c := colorFor(w) |
| + fmt.Fprintln(w, c.muted(fmt.Sprintf("using %s as %s", sess.console, sess.address))) |
| +} |
| + |
| +// errorWriter paints whole lines with the "something failed" style. Every |
| +// verb in this binary reports failure through log.Fatal, so wrapping the |
| +// standard logger's output once (main) colours all of them without touching |
| +// a single call site. It is a no-op wherever stderr is not a terminal, which |
| +// includes every daemon this binary also runs: `carlos edge` and `carlos |
| +// agent` log into journald, and journald must not receive escape codes. |
| +type errorWriter struct { |
| + w io.Writer |
| + pal colors |
| +} |
| + |
| +func newErrorWriter(w io.Writer) io.Writer { |
| + pal := colorFor(w) |
| + if !pal.on { |
| + return w |
| + } |
| + return errorWriter{w: w, pal: pal} |
| +} |
| + |
| +func (e errorWriter) Write(p []byte) (int, error) { |
| + line := strings.TrimSuffix(string(p), "\n") |
| + if _, err := io.WriteString(e.w, e.pal.bad(line)+"\n"); err != nil { |
| + return 0, err |
| + } |
| + // The caller is told its whole write landed: it did, styling aside. |
| + return len(p), nil |
| +} |
| + |
| +// table is the CLI's tabwriter with a bold header row. It buffers because |
| +// tabwriter measures a cell as its bytes: styling the header before it goes |
| +// through the writer would count the eight bytes of escape codes as column |
| +// content and pad every row below it that much too wide. So cells go in |
| +// plain, the layout is computed plain, and only the finished header LINE is |
| +// styled on its way out. |
| +type table struct { |
| + *tabwriter.Writer |
| + buf *bytes.Buffer |
| + out io.Writer |
| + pal colors |
| +} |
| + |
| +func newTable(out io.Writer) *table { |
| + buf := &bytes.Buffer{} |
| + return &table{ |
| + Writer: tabwriter.NewWriter(buf, 0, 4, 2, ' ', 0), |
| + buf: buf, |
| + out: out, |
| + pal: colorFor(out), |
| + } |
| +} |
| + |
| +func (t *table) Flush() error { |
| + if err := t.Writer.Flush(); err != nil { |
| + return err |
| + } |
| + s := t.buf.String() |
| + t.buf.Reset() |
| + if t.pal.on { |
| + if i := strings.IndexByte(s, '\n'); i > 0 { |
| + // Style the header text but not the padding that follows it: |
| + // a bold run of trailing spaces is invisible on most terminals |
| + // and an underline-ish smear on a few. |
| + line := s[:i] |
| + text := strings.TrimRight(line, " ") |
| + s = t.pal.head(text) + line[len(text):] + s[i:] |
| + } |
| + } |
| + _, err := io.WriteString(t.out, s) |
| + return err |
| +} |
diff --git a/color_test.go b/color_test.go| new file mode 100644 |
| index 000000000..a6d0a8ba4 |
| --- /dev/null |
| +++ b/color_test.go |
| @@ -0,0 +1,128 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// pipeFile is a real *os.File that is NOT a terminal — the shape every CI |
| +// run, every `| less` and every e2e test's exec'd binary sees. |
| +func pipeFile(t *testing.T) *os.File { |
| + t.Helper() |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { r.Close(); w.Close() }) |
| + return w |
| +} |
| + |
| +func TestColorEnabledPrecedence(t *testing.T) { |
| + f := pipeFile(t) |
| + cases := []struct { |
| + name string |
| + env map[string]string |
| + want bool |
| + }{ |
| + {"pipe with a terminal set", map[string]string{"TERM": "xterm-256color"}, false}, |
| + {"CARLOS_COLOR=always beats the pipe", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always"}, true}, |
| + {"CARLOS_COLOR=always beats NO_COLOR", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "always", "NO_COLOR": "1"}, true}, |
| + {"CARLOS_COLOR=never", map[string]string{"TERM": "xterm", "CARLOS_COLOR": "never"}, false}, |
| + {"NO_COLOR", map[string]string{"TERM": "xterm", "NO_COLOR": "1"}, false}, |
| + {"TERM=dumb", map[string]string{"TERM": "dumb", "CARLOS_COLOR": ""}, false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + for _, k := range []string{"TERM", "NO_COLOR", "CARLOS_COLOR"} { |
| + t.Setenv(k, tc.env[k]) |
| + } |
| + if got := colorEnabled(f); got != tc.want { |
| + t.Fatalf("colorEnabled = %v, want %v", got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A buffer is what the CLI's print seam holds under test. It must never be |
| +// coloured, whatever the environment says — including CARLOS_COLOR=always, |
| +// because an operator with that set in their shell would otherwise see every |
| +// in-process test fail on escape codes. |
| +func TestColorForNonFileIsAlwaysPlain(t *testing.T) { |
| + t.Setenv("CARLOS_COLOR", "always") |
| + t.Setenv("TERM", "xterm-256color") |
| + var buf bytes.Buffer |
| + if c := colorFor(&buf); c.on { |
| + t.Fatal("a bytes.Buffer was given a colour palette") |
| + } |
| + mutedf(&buf, "using %s as %s", "https://console.example", "vicky@keymail.dev") |
| + if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want { |
| + t.Fatalf("mutedf wrote %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestPaletteWrapsOnlyWhenOn(t *testing.T) { |
| + on := colors{on: true} |
| + if got, want := on.ok("shipped"), ansiGreen+"shipped"+ansiReset; got != want { |
| + t.Fatalf("ok = %q, want %q", got, want) |
| + } |
| + if got := on.muted(""); got != "" { |
| + t.Fatalf("an empty string got wrapped: %q", got) |
| + } |
| + off := colors{} |
| + for name, got := range map[string]string{ |
| + "muted": off.muted("x"), "ok": off.ok("x"), "warn": off.warn("x"), |
| + "bad": off.bad("x"), "head": off.head("x"), "link": off.link("x"), |
| + } { |
| + if got != "x" { |
| + t.Fatalf("%s with colour off = %q, want %q", name, got, "x") |
| + } |
| + } |
| +} |
| + |
| +// The point of table's buffering: escape codes on the header must not be |
| +// counted as column content. If they were, every row below would be padded |
| +// to the header's inflated width. |
| +func TestTableHeaderStylingDoesNotMoveColumns(t *testing.T) { |
| + rows := func(w *table) { |
| + w.Write([]byte("CHANNEL\tVERSION\tPROMOTED\n")) |
| + w.Write([]byte("canary/keymail\t1.2.3\t2026-08-07\n")) |
| + w.Write([]byte("stable\t1.0.0\t2026-08-01\n")) |
| + } |
| + |
| + var plain bytes.Buffer |
| + pt := newTable(&plain) |
| + rows(pt) |
| + if err := pt.Flush(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var styled bytes.Buffer |
| + st := newTable(&styled) |
| + st.pal = colors{on: true} // a buffer never gets colour on its own |
| + rows(st) |
| + if err := st.Flush(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + got := styled.String() |
| + if !strings.HasPrefix(got, ansiBold+"CHANNEL") { |
| + t.Fatalf("header not styled: %q", got[:40]) |
| + } |
| + // Strip the codes back out and the two renderings must be identical: |
| + // the columns did not move. |
| + stripped := strings.NewReplacer(ansiBold, "", ansiReset, "").Replace(got) |
| + if stripped != plain.String() { |
| + t.Fatalf("styling changed the layout:\n plain: %q\nstyled: %q", plain.String(), stripped) |
| + } |
| +} |
| + |
| +// Colour off, the banner is byte-for-byte the line the API-mode tests pin. |
| +func TestPrintUsingIsUnchangedWithoutColor(t *testing.T) { |
| + var buf bytes.Buffer |
| + printUsing(&buf, &apiSession{console: "https://console.example", address: "vicky@keymail.dev"}) |
| + if got, want := buf.String(), "using https://console.example as vicky@keymail.dev\n"; got != want { |
| + t.Fatalf("printUsing wrote %q, want %q", got, want) |
| + } |
| +} |
diff --git a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md| index d412fb808..55fe15ed2 100644 |
| --- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md |
| @@ -422,6 +422,39 @@ mint the app's apex onto that one customer's socket — pre-existing |
| behaviour, now reachable by members, worth its own slice if platform-pinned |
| multi-tenant apps become a real shape. |
| +## 12b. Addendum (2026-08-07): the console must advertise the API-backed verb |
| + |
| +§9's "`runAdd` stays a box-side, registry-direct verb" is a statement about |
| +where that verb *lives*, not about what the **console** should tell a reader |
| +to run. The console had been doing both: the canary form on the app's releases |
| +tab and the post-create flash each printed `carlos add -app <a> -channel canary/<n> |
| +<host>` into a browser. That command opens the box's registry on local disk |
| +(`cli.go`'s `defaultRegistry`), so it cannot work from wherever a dashboard is |
| +being read — the same box-local-only gap CLAUDE.md names for `carlos |
| +instances`. An operator followed it from a laptop and hit the flag validation |
| +instead of a route. |
| + |
| +Nothing new was needed underneath: `POST .../instances` already takes an |
| +explicit `channel` (§7), and `provision.Reconcile`'s mint already carries |
| +`rec.Channel` onto the route, so a canary host has been a supported |
| +API-backed create since §12a. Both doors now render `carlos instances create |
| +-app <a> -host <name>.<domain> -channel canary/<n>`, computed from the app's |
| +own manifest, and lead with `carlos instances enable` when the manifest is |
| +**proven** absent, computed in the releases tab handler rather than the shared |
| +app chrome — the canary form lives on that tab and nowhere else. Proven is |
| +load-bearing: the enable door writes guarded and |
| +409s over a standing manifest, so an unreadable bucket renders the neutral |
| +form rather than sending someone at a refusal. |
| + |
| +`carlos add` remains correct and documented for an operator on the box — it |
| +provisions shapes records do not model (§9). It is simply not what a console |
| +page should hand to its reader. |
| + |
| +Recorded, not built: the minted route shape is fixed (`Hibernate: true`, exec |
| +backing, `DefaultSocket`) — an instance record cannot ask for `-backing unit`, |
| +so a deployment that needs unit tenants still reaches for `carlos add` on the |
| +box. Worth its own slice if unit-backed instances become a real shape. |
| + |
| ## 13. Testing |
| - `internal/provision`: record/manifest validation matrices, key parsing |
diff --git a/go.mod b/go.mod| index 6d1969096..2c15bd17c 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,6 +5,7 @@ go 1.26 |
| require ( |
| github.com/aws/aws-sdk-go-v2 v1.42.1 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 |
| github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 |
| github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
| github.com/aws/smithy-go v1.27.3 |
| @@ -16,7 +17,6 @@ require ( |
| require ( |
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect |
| - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect |
diff --git a/instances_cli.go b/instances_cli.go| index 79e3f3bac..0ee781a66 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -21,8 +21,8 @@ import ( |
| "flag" |
| "fmt" |
| "log" |
| + "os" |
| "strings" |
| - "text/tabwriter" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -119,13 +119,13 @@ func runInstancesEnable(args []string) { |
| seedReq = &argv |
| } |
| acct := apiAccount(acctRaw, acctIDRaw) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq) |
| if err != nil { |
| log.Fatalf("instances enable: %v", err) |
| } |
| - fmt.Fprintf(stdout, "enabled instances for %s under %s\n", m.App, m.Domain) |
| - fmt.Fprintf(stdout, " first instance: carlos instances create -app %s -host <label>.%s\n", m.App, m.Domain) |
| + fmt.Fprintf(stdout, "%s for %s under %s\n", colorFor(stdout).ok("enabled instances"), m.App, m.Domain) |
| + mutedf(stdout, " first instance: carlos instances create -app %s -host <label>.%s\n", m.App, m.Domain) |
| return |
| } |
| @@ -176,7 +176,7 @@ func runInstancesEnable(args []string) { |
| if err := instancesEnable(context.Background(), openStore(), acctOf(), *app, m); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("enabled instances for %s under %s\n", *app, *domain) |
| + fmt.Printf("%s for %s under %s\n", colorFor(os.Stdout).ok("enabled instances"), *app, *domain) |
| } |
| // parseSeed splits -seed's raw value on whitespace (strings.Fields) into the |
| @@ -242,14 +242,14 @@ func runInstancesCreate(args []string) { |
| log.Fatal(instancesRecordsUsage) |
| } |
| sess := requireAPISession("create", *consoleFlag) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel)) |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| } |
| - fmt.Fprintf(stdout, "created instance %s (channel %s)\n", rec.Host, rec.Channel) |
| - fmt.Fprintf(stdout, " the host comes live on the next reconcile pass on its box\n") |
| + fmt.Fprintf(stdout, "%s %s (channel %s)\n", colorFor(stdout).ok("created instance"), rec.Host, rec.Channel) |
| + mutedf(stdout, " the host comes live on the next reconcile pass on its box") |
| } |
| func runInstancesList(args []string) { |
| @@ -267,14 +267,14 @@ func runInstancesList(args []string) { |
| if err != nil { |
| log.Fatalf("instances list: %v", err) |
| } |
| - w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + w := newTable(stdout) |
| fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tCREATED\tBY") |
| for _, r := range recs { |
| fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.CreatedAt, r.CreatedBy) |
| } |
| w.Flush() |
| if truncated { |
| - fmt.Fprintln(stdout, "(truncated at 500 records)") |
| + mutedf(stdout, "(truncated at 500 records)") |
| } |
| } |
| @@ -290,9 +290,10 @@ func runInstancesDelete(args []string) { |
| log.Fatal(instancesRecordsUsage) |
| } |
| sess := requireAPISession("delete", *consoleFlag) |
| - fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + printUsing(stdout, sess) |
| if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil { |
| log.Fatalf("instances delete: %v", err) |
| } |
| - fmt.Fprintf(stdout, "deleted instance %s — its route retires on the next reconcile pass; parked data stays\n", *host) |
| + fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n", |
| + colorFor(stdout).ok("deleted instance"), *host) |
| } |
diff --git a/internal/console/attachhint_test.go b/internal/console/attachhint_test.go| new file mode 100644 |
| index 000000000..a64d3b921 |
| --- /dev/null |
| +++ b/internal/console/attachhint_test.go |
| @@ -0,0 +1,132 @@ |
| +package console |
| + |
| +// The "give this canary a URL" advice the app page and the post-create flash |
| +// both render. The property under test is not the wording — it is WHICH verb |
| +// the console sends its reader at. `carlos add` writes the box's registry on |
| +// local disk (cli.go's defaultRegistry), so a browser session anywhere else |
| +// cannot run it; `carlos instances create` is the API-backed door a login |
| +// credential can drive, and provision.Reconcile carries its channel onto the |
| +// minted route. A dashboard advertising the box-local verb is the bug this |
| +// file pins shut (2026-08-07). |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestAttachCommands(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + domain string |
| + appsDomain, sqid string |
| + absent bool |
| + wantCreate string |
| + wantEnable string |
| + }{{ |
| + // Enabled: the manifest's own domain is where hosts live, whether it |
| + // is the platform pin or a custom domain, and no enable step is owed. |
| + name: "enabled platform domain", domain: "bab.oncarlos.test", |
| + appsDomain: "oncarlos.test", sqid: "bab", |
| + wantCreate: "carlos instances create -app echo -host <name>.bab.oncarlos.test -channel canary/rc", |
| + }, { |
| + name: "enabled custom domain", domain: "go.tito.io", |
| + appsDomain: "oncarlos.test", sqid: "bab", |
| + wantCreate: "carlos instances create -app echo -host <name>.go.tito.io -channel canary/rc", |
| + }, { |
| + // Proven absent: lead with enable, and name the host shape the enable |
| + // door will pin — the account's own platform subdomain. |
| + name: "absent with apps domain", absent: true, |
| + appsDomain: "oncarlos.test", sqid: "bab", |
| + wantCreate: "carlos instances create -app echo -host <name>.bab.oncarlos.test -channel canary/rc", |
| + wantEnable: "carlos instances enable -app echo", |
| + }, { |
| + // Absent and the deployment has no platform apps domain: the enable |
| + // door itself refuses (503), so there is no host shape to promise. |
| + name: "absent without apps domain", absent: true, sqid: "bab", |
| + wantCreate: "carlos instances create -app echo -host <host> -channel canary/rc", |
| + wantEnable: "carlos instances enable -app echo", |
| + }, { |
| + // Unreadable manifest: neither enabled nor proven absent. The enable |
| + // step must NOT appear — that door is guarded and answers 409 over a |
| + // standing manifest, so a transient bucket error must not send an |
| + // operator at a refusal. |
| + name: "unreadable", appsDomain: "oncarlos.test", sqid: "bab", |
| + wantCreate: "carlos instances create -app echo -host <host> -channel canary/rc", |
| + }} { |
| + t.Run(tc.name, func(t *testing.T) { |
| + create, enable := attachCommands("echo", "canary/rc", tc.domain, tc.appsDomain, tc.sqid, tc.absent) |
| + if create != tc.wantCreate { |
| + t.Errorf("create = %q, want %q", create, tc.wantCreate) |
| + } |
| + if enable != tc.wantEnable { |
| + t.Errorf("enable = %q, want %q", enable, tc.wantEnable) |
| + } |
| + if strings.Contains(create, "carlos add") || strings.Contains(enable, "carlos add") { |
| + t.Errorf("advice names the box-local verb: %q / %q", create, enable) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestInstanceDomainForStates pins the three-way answer, because collapsing |
| +// "could not read" into "absent" is exactly what would put a doomed |
| +// `carlos instances enable` in front of an operator during a bucket hiccup. |
| +func TestInstanceDomainForStates(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + // No manifest written for the fixture's app: proven absent. |
| + if domain, absent := fx.srv.instanceDomainFor(ctx, "bab", "kass"); domain != "" || !absent { |
| + t.Fatalf("no manifest: domain=%q absent=%v, want \"\" true", domain, absent) |
| + } |
| + |
| + // A manifest present: its domain, and not absent. |
| + m := provision.Manifest{Schema: 1, App: "kass", Domain: "bab.oncarlos.test", Channel: "stable"} |
| + if err := provision.PutManifest(ctx, fx.rel, "bab", "kass", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if domain, absent := fx.srv.instanceDomainFor(ctx, "bab", "kass"); domain != "bab.oncarlos.test" || absent { |
| + t.Fatalf("with manifest: domain=%q absent=%v, want bab.oncarlos.test false", domain, absent) |
| + } |
| + |
| + // A read that fails for any reason other than not-found is NOT absent. |
| + fx.srv.cfg.Releases = unreadableStore{fx.rel} |
| + if domain, absent := fx.srv.instanceDomainFor(ctx, "bab", "kass"); domain != "" || absent { |
| + t.Fatalf("unreadable: domain=%q absent=%v, want \"\" false", domain, absent) |
| + } |
| +} |
| + |
| +// TestAppPageNamesAPIBackedAttach is the regression at the level the operator |
| +// met it: the app page's canary form, rendered, must hand back a command the |
| +// reader can run from where they are. |
| +func TestAppPageNamesAPIBackedAttach(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + if err := provision.PutManifest(context.Background(), fx.rel, "bab", "kass", |
| + provision.Manifest{Schema: 1, App: "kass", Domain: "bab.oncarlos.test", Channel: "stable"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := signInPaul(t, fx) |
| + // The releases tab: the canary form lives there since the console's tab |
| + // split, and so does the advice beside it. |
| + _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| + |
| + // html/template escapes the <name> placeholder, so assert around it. |
| + for _, want := range []string{ |
| + "carlos instances create -app kass -host ", "bab.oncarlos.test -channel canary/", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body) |
| + } |
| + } |
| + // Enabled already: no enable step, and never the box-local verb. |
| + if strings.Contains(body, "carlos instances enable") { |
| + t.Error("app page offers `instances enable` for an app that already has a manifest") |
| + } |
| + if strings.Contains(body, "carlos add ") { |
| + t.Error("app page still sends the reader at box-local `carlos add`") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 644f5cbb9..eac1fd1df 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -23,6 +23,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -364,6 +365,14 @@ type page struct { |
| Events []Event // app page: recent console actions, newest first |
| CanPromote bool // a signing key is configured on this host |
| + // App page: how a canary channel gets a URL, rendered as commands the |
| + // reader can actually run from where they are reading. Computed |
| + // server-side (attachCommands) because the host shape comes from the |
| + // app's instances manifest. AttachEnable is non-empty only when |
| + // instances are PROVEN off, so the copy leads with the enable step. |
| + AttachCmd string |
| + AttachEnable string |
| + |
| // The account chrome every /a/{sqid}/… page carries. |
| Account *Account // the account being viewed |
| Role string // the viewer's role in it: owner | member |
| @@ -1406,6 +1415,14 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| p.FlashErr = "Could not list this app's releases." |
| } |
| p.Versions = versions |
| + // The canary form's "how do I give this a URL" copy — this tab's data, not |
| + // shared chrome: the canary form lives here and nowhere else. Bounded and |
| + // best-effort like every other store read behind an app page: an unreadable |
| + // manifest costs the concrete host shape, never the page. |
| + mctx, mcancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + domain, absent := s.instanceDomainFor(mctx, acct.Sqid, p.AppName) |
| + mcancel() |
| + p.AttachCmd, p.AttachEnable = attachCommands(p.AppName, "canary/<name>", domain, s.cfg.AppsDomain, acct.Sqid, absent) |
| s.render(w, http.StatusOK, "app_releases", p) |
| } |
| @@ -2106,7 +2123,68 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (canaries never bake).") |
| return |
| } |
| - back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a host follows it, no bake. Attach one: carlos add -app "+app+" -channel "+channel+" <host>.") |
| + domain, absent := s.instanceDomainFor(ctx, acct.Sqid, app) |
| + create, enable := attachCommands(app, channel, domain, s.cfg.AppsDomain, acct.Sqid, absent) |
| + if enable != "" { |
| + create = enable + " && " + create |
| + } |
| + back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a host follows it, no bake. Attach one: "+create) |
| +} |
| + |
| +// instanceDomainFor answers "where do this app's instance hosts live?" — the |
| +// app's instances-manifest domain when one exists, and whether its absence is |
| +// PROVEN. Both doors that tell a reader how to give a channel a URL ask this, |
| +// so both name the same command. |
| +// |
| +// The three-way answer is load-bearing: a manifest that merely could not be |
| +// READ is not "instances are off". The enable door writes guarded |
| +// (PutManifestIfMatch) and answers 409 over a standing manifest under another |
| +// domain, so telling someone to run `carlos instances enable` on the strength |
| +// of a transient bucket error sends them at a door that will refuse them. |
| +// Unreadable therefore renders the neutral form, never the enable step. |
| +func (s *Server) instanceDomainFor(ctx context.Context, sqid, app string) (domain string, absent bool) { |
| + m, err := provision.GetManifest(ctx, s.cfg.Releases, sqid, app) |
| + switch { |
| + case err == nil: |
| + return m.Domain, false |
| + case errors.Is(err, os.ErrNotExist): |
| + return "", true |
| + default: |
| + log.Printf("console: instances manifest %s/%s: %v", sqid, app, err) |
| + return "", false |
| + } |
| +} |
| + |
| +// attachCommands renders the "give this channel a URL" advice for one app: |
| +// the create command, plus the enable command that has to precede it when |
| +// instances are proven off ("" otherwise). |
| +// |
| +// It names `carlos instances create`, deliberately NOT `carlos add`. Both |
| +// place a route, but `carlos add` opens THIS box's registry on local disk |
| +// (cli.go's defaultRegistry) — it cannot be run from wherever a dashboard is |
| +// being read, and an operator who followed that hint from a browser landed on |
| +// a box-local verb that could not work for them (2026-08-07). The API-backed |
| +// path is the one a login credential can drive: spec §12a, and the record it |
| +// writes materializes as a route on the box's next provision pass, carrying |
| +// this channel through (provision.Reconcile's mint). |
| +func attachCommands(app, channel, domain, appsDomain, sqid string, absent bool) (create, enable string) { |
| + // An instance host is exactly one label under the manifest's domain |
| + // (provision.ValidHost), so the placeholder stands in for the label only. |
| + host := "<host>" |
| + switch { |
| + case domain != "": |
| + host = "<name>." + domain |
| + case absent && appsDomain != "": |
| + // Not enabled yet: the member-facing enable door pins the manifest to |
| + // the account's own platform subdomain (§12a), so that is where the |
| + // host will have to live once they run it. |
| + host = "<name>." + sqid + "." + appsDomain |
| + } |
| + create = "carlos instances create -app " + app + " -host " + host + " -channel " + channel |
| + if absent { |
| + enable = "carlos instances enable -app " + app |
| + } |
| + return create, enable |
| } |
| // handleRetireCanary removes a canary's channel pointer. Shipped releases |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index dc989f658..7e2edace1 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -910,11 +910,20 @@ func TestCanaryFlow(t *testing.T) { |
| t.Fatalf("canary/rc pointer = %+v, want v1", p) |
| } |
| _, _, body = get(t, c, fx.u("/apps/kass")) |
| - for _, want := range []string{"Created canary rc", "no bake", "carlos add -app kass -channel canary/rc", "canary/rc"} { |
| + // The flash names the API-backed verb. `carlos add` is box-local (it opens |
| + // the box's registry on local disk), so a dashboard must never send its |
| + // reader there — the whole point of the instances API path. |
| + for _, want := range []string{ |
| + "Created canary rc", "no bake", "canary/rc", |
| + "carlos instances enable -app kass", "carlos instances create -app kass", "-channel canary/rc", |
| + } { |
| if !strings.Contains(body, want) { |
| t.Fatalf("post-create page missing %q in:\n%s", want, body) |
| } |
| } |
| + if strings.Contains(body, "carlos add ") { |
| + t.Errorf("post-create page still sends the reader at box-local `carlos add`:\n%s", body) |
| + } |
| // New means new: the existing canary refuses re-creation. |
| postForm(t, c, fx.u("/apps/kass/canary"), |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index b84554eb8..7c1c5a8c8 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -85,7 +85,13 @@ never bakes.</p> |
| </select> |
| <button type="submit" class="btn-sm">Create canary</button> |
| </form> |
| -<p class="small">Creates channel <code>canary/<name></code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/<name> <host></code>.</p> |
| +<p class="small">Creates channel <code>canary/<name></code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel:</p> |
| +{{if .AttachEnable}}<pre><code>{{.AttachEnable}} |
| +{{.AttachCmd}}</code></pre> |
| +<p class="small">This app has no instances manifest yet, so the first line enables one under this account's platform subdomain. The host comes live on its box's next provision pass.</p> |
| +{{else}}<pre><code>{{.AttachCmd}}</code></pre> |
| +<p class="small">The host comes live on its box's next provision pass.</p> |
| +{{end}} |
| {{end}} |
| <h2>Releases</h2> |
diff --git a/internal/host/probes.go b/internal/host/probes.go| index 1e3bd5d64..6a64ce1c4 100644 |
| --- a/internal/host/probes.go |
| +++ b/internal/host/probes.go |
| @@ -317,6 +317,6 @@ func readDiskFreeBytes() *int64 { |
| if st.Blocks == 0 { |
| return nil |
| } |
| - freeBytes := int64(st.Bavail) * st.Bsize |
| + freeBytes := int64(st.Bavail) * int64(st.Bsize) |
| return &freeBytes |
| } |
diff --git a/internal/lease/endpoint.go b/internal/lease/endpoint.go| new file mode 100644 |
| index 000000000..c945f0dd1 |
| --- /dev/null |
| +++ b/internal/lease/endpoint.go |
| @@ -0,0 +1,130 @@ |
| +package lease |
| + |
| +// CARLOS_S3_ENDPOINT support for the hibernation lease. |
| +// |
| +// release.NewS3Store has honoured CARLOS_S3_ENDPOINT since the first |
| +// S3-compatible deployment; this package did not, and every S3 client it |
| +// builds dialled real AWS regardless. That gap is not cosmetic: an activator |
| +// route is minted with Hibernate:true, the activator only arms when |
| +// CARLOS_HIBERNATE_BUCKET is set, and arming means acquiring the lease here. |
| +// On a non-AWS store (Hetzner Object Storage, R2, MinIO) the acquire could |
| +// never succeed, so the activator stayed nil and an exec-backed route with |
| +// nothing to spawn it 502'd forever — which took `carlos instances create`, |
| +// the whole console-driven provisioning path, down with it. The same clients |
| +// back the agent's status-publisher election (internal/host/agent.go), so the |
| +// override has to reach all three construction sites, not just the one. |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + lss3 "github.com/benbjohnson/litestream/s3" |
| + |
| + "github.com/carlosframework/platform/internal/s3compat" |
| +) |
| + |
| +// s3Endpoint reports the CARLOS_S3_ENDPOINT override, or "" for stock AWS. |
| +// Delegated to s3compat so there is exactly one reader of that variable. |
| +func s3Endpoint() string { |
| + return s3compat.Endpoint() |
| +} |
| + |
| +// resolveRegion decides the region every client in this package signs for, in |
| +// strict order of authority: |
| +// |
| +// 1. the caller's explicit region (CARLOS_HIBERNATE_REGION), if any; |
| +// 2. whatever the AWS default chain yields — AWS_REGION, or a profile. This |
| +// step is why the resolution cannot be a pure function of the argument: a |
| +// compatible-store deployment sets AWS_REGION to its own location (the |
| +// private CARLOS uses nbg1), and that is the region release.NewS3Store |
| +// already signs for against the same bucket. Substituting a constant here |
| +// would make the lease sign differently from every other S3 client in the |
| +// process, against a store that may well check; |
| +// 3. only if BOTH are empty and an endpoint override is in play, litestream's |
| +// non-AWS default — better a working guess than a client signing for no |
| +// region at all. |
| +// |
| +// Step 3 is the one an override adds. Without it the AWS chain is authoritative |
| +// and an empty result is fine: stock AWS resolves the region per-request from |
| +// the bucket's own endpoint, which a compatible store cannot do. |
| +// |
| +// The resolved value is returned, not just applied, because it has to travel: |
| +// litestream's replica client does its OWN region defaulting (Init substitutes |
| +// the same non-AWS constant for an empty Region), so handing it the resolved |
| +// region is what stops the two halves of one lease — lock.json and the LTX |
| +// files beside it under the same prefix — from signing differently. |
| +func resolveRegion(ctx context.Context, region string) (string, error) { |
| + if region != "" { |
| + return region, nil // no config load needed: step 1 already decided |
| + } |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + if err != nil { |
| + return "", fmt.Errorf("load aws config: %w", err) |
| + } |
| + return regionFor(region, cfg.Region), nil |
| +} |
| + |
| +// regionFor is resolveRegion's decision with the chain lookup already done — |
| +// the shared rule, so the leaser's path (which loads a config anyway, and must |
| +// not load a second one just to re-ask the same question) and Restore's cannot |
| +// drift apart. |
| +func regionFor(explicit, chainResolved string) string { |
| + if explicit != "" { |
| + return explicit |
| + } |
| + if chainResolved == "" && s3Endpoint() != "" { |
| + return lss3.DefaultRegion |
| + } |
| + return chainResolved |
| +} |
| + |
| +// newLeaserClient builds the S3 client the litestream leaser writes lock.json |
| +// with. Unlike the replica client, which builds its own from its Endpoint |
| +// field, the leaser takes a ready-made client (lss3.Leaser.SetClient) — so the |
| +// override has to be applied right here or the lock lands on AWS while the |
| +// data it guards lands on the compatible store. |
| +// |
| +// The override, path-style addressing, when-required checksums and every |
| +// per-provider quirk come from internal/s3compat, which is a no-op on stock |
| +// AWS — so the flagship's path stays byte-identical while this client picks up |
| +// the unquoted-If-Match fix that litestream's leaser needs to renew a lease on |
| +// Ceph RGW. Passing that client to lss3.Leaser.SetClient is the ONLY way the |
| +// fix reaches litestream, which has no provider handling in its leaser at all. |
| +// |
| +// It returns the region it resolved alongside the client, so acquireTTL can |
| +// carry that exact value onto the Held for Replicate's replica client rather |
| +// than letting litestream substitute a different default of its own. |
| +func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string, error) { |
| + var opts []func(*awsconfig.LoadOptions) error |
| + if region != "" { |
| + opts = append(opts, awsconfig.WithRegion(region)) |
| + } |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + if err != nil { |
| + return nil, "", fmt.Errorf("load aws config: %w", err) |
| + } |
| + cfg.Region = regionFor(region, cfg.Region) |
| + return awss3.NewFromConfig(cfg, s3compat.Options()), cfg.Region, nil |
| +} |
| + |
| +// newReplicaClient builds litestream's replica client for the LTX files at |
| +// s3://bucket/prefix. litestream reads its OWN env var (LITESTREAM_S3_ENDPOINT) |
| +// only on the URL-parsing path, which this package never takes — it sets the |
| +// fields directly — so the override is applied here too. Endpoint/ForcePathStyle |
| +// is all it takes: litestream's Init derives the rest (the checksum knobs and |
| +// the non-AWS default region) from a non-empty Endpoint on its own, and the |
| +// signed-payload and content-MD5 defaults NewReplicaClient already ships are |
| +// the ones every compatible in litestream's provider table wants. |
| +func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient { |
| + c := lss3.NewReplicaClient() |
| + c.Bucket = bucket |
| + c.Path = prefix |
| + c.Region = region |
| + if ep := s3Endpoint(); ep != "" { |
| + c.Endpoint = ep |
| + c.ForcePathStyle = true |
| + } |
| + return c |
| +} |
diff --git a/internal/lease/endpoint_test.go b/internal/lease/endpoint_test.go| new file mode 100644 |
| index 000000000..935e9cb00 |
| --- /dev/null |
| +++ b/internal/lease/endpoint_test.go |
| @@ -0,0 +1,263 @@ |
| +package lease |
| + |
| +// CARLOS_S3_ENDPOINT tests. Unlike lease_test.go these need no AWS and no |
| +// CARLOS_TEST_S3_BUCKET: the point of the override is that S3 traffic can be |
| +// pointed anywhere, so the test points it at an httptest server and asserts |
| +// the request actually arrives. A regression here — the leaser silently going |
| +// back to real AWS — is exactly what cost this deployment hibernation and the |
| +// whole `carlos instances create` provisioning path. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/benbjohnson/litestream" |
| + lss3 "github.com/benbjohnson/litestream/s3" |
| +) |
| + |
| +// fakeS3 is just enough of S3 for the leaser's three verbs (GET/PUT/DELETE on |
| +// one key) to complete a full acquire-then-release, recording every request so |
| +// a test can assert on where the traffic went. |
| +type fakeS3 struct { |
| + mu sync.Mutex |
| + reqs []string // "METHOD /path" |
| + body []byte // last PUT body |
| +} |
| + |
| +func (f *fakeS3) record(method, path string) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.reqs = append(f.reqs, method+" "+path) |
| +} |
| + |
| +func (f *fakeS3) requests() []string { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return append([]string(nil), f.reqs...) |
| +} |
| + |
| +func (f *fakeS3) putBody() []byte { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return append([]byte(nil), f.body...) |
| +} |
| + |
| +func newFakeS3(t *testing.T) (*fakeS3, string) { |
| + t.Helper() |
| + f := &fakeS3{} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + f.record(r.Method, r.URL.Path) |
| + switch r.Method { |
| + case http.MethodGet: |
| + // No lease on the key yet. The XML body matters: litestream |
| + // classifies "absent" by the S3 error CODE, and a bare 404 with no |
| + // body would read as a hard failure instead. |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusNotFound) |
| + _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + |
| + `<Error><Code>NoSuchKey</Code><Message>no such key</Message></Error>`)) |
| + case http.MethodPut: |
| + b, _ := io.ReadAll(r.Body) |
| + f.mu.Lock() |
| + f.body = b |
| + f.mu.Unlock() |
| + w.Header().Set("ETag", `"lease-etag"`) |
| + w.WriteHeader(http.StatusOK) |
| + case http.MethodDelete: |
| + w.WriteHeader(http.StatusNoContent) |
| + default: |
| + w.WriteHeader(http.StatusMethodNotAllowed) |
| + } |
| + })) |
| + t.Cleanup(srv.Close) |
| + return f, srv.URL |
| +} |
| + |
| +// fakeCreds plants credentials the AWS default chain will resolve without |
| +// touching the operator's real profile or IMDS — the same isolation instinct |
| +// as carlosTestEnv, applied to the SDK instead of the carlos binary. |
| +func fakeCreds(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + // Cleared, not left ambient: region resolution is under test, so a region |
| + // inherited from whatever machine runs the suite would make these results |
| + // depend on the box. Tests that care set it back explicitly. |
| + t.Setenv("AWS_REGION", "") |
| + t.Setenv("AWS_DEFAULT_REGION", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| +} |
| + |
| +// TestAcquire_ReachesEndpointOverride is the regression test for the bug this |
| +// file exists for: with CARLOS_S3_ENDPOINT set, the lease's lock.json must be |
| +// written through the override — not to real AWS. It asserts on the recorded |
| +// requests, so it fails loudly (rather than hanging on a live AWS dial) if the |
| +// leaser ever stops honouring the endpoint. |
| +func TestAcquire_ReachesEndpointOverride(t *testing.T) { |
| + f, url := newFakeS3(t) |
| + fakeCreds(t) |
| + t.Setenv("CARLOS_S3_ENDPOINT", url) |
| + |
| + // Region deliberately empty, and fakeCreds leaves the chain empty too, so |
| + // this also exercises the last resort: a client signing for no region at |
| + // all would fail every request against the fake. |
| + held, err := Acquire(context.Background(), "test-bucket", "inst/example.com", "") |
| + if err != nil { |
| + t.Fatalf("Acquire against %s: %v", url, err) |
| + } |
| + |
| + got := f.requests() |
| + // Path-style addressing: bucket in the path, not the hostname — a |
| + // compatible store has no per-bucket virtual host. |
| + wantKey := "/test-bucket/inst/example.com/lock.json" |
| + var sawGet, sawPut bool |
| + for _, r := range got { |
| + switch r { |
| + case "GET " + wantKey: |
| + sawGet = true |
| + case "PUT " + wantKey: |
| + sawPut = true |
| + } |
| + } |
| + if !sawGet || !sawPut { |
| + t.Fatalf("lease traffic did not reach the override: got %v, want a GET and a PUT of %s", got, wantKey) |
| + } |
| + |
| + // The PUT body is the lock itself — proof the acquire really happened |
| + // here rather than the server merely being probed. |
| + var lease litestream.Lease |
| + if err := json.Unmarshal(f.putBody(), &lease); err != nil { |
| + t.Fatalf("decode written lock.json (%q): %v", f.putBody(), err) |
| + } |
| + if lease.Owner == "" { |
| + t.Errorf("written lease carries no owner: %+v", lease) |
| + } |
| + |
| + if err := held.Release(context.Background()); err != nil { |
| + t.Fatalf("Release: %v", err) |
| + } |
| + var sawDelete bool |
| + for _, r := range f.requests() { |
| + if r == "DELETE "+wantKey { |
| + sawDelete = true |
| + } |
| + } |
| + if !sawDelete { |
| + t.Errorf("release did not reach the override: got %v", f.requests()) |
| + } |
| +} |
| + |
| +// TestNewLeaserClient_EndpointConfig pins the client options either side of the |
| +// override, because the no-override case is the flagship's production path and |
| +// must stay byte-identical to what it was before endpoint.go existed. |
| +func TestNewLeaserClient_EndpointConfig(t *testing.T) { |
| + fakeCreds(t) |
| + |
| + t.Run("override sets a path-style base endpoint", func(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "https://fsn1.your-objectstorage.com") |
| + c, _, err := newLeaserClient(context.Background(), "eu-central-1") |
| + if err != nil { |
| + t.Fatalf("newLeaserClient: %v", err) |
| + } |
| + o := c.Options() |
| + if o.BaseEndpoint == nil || *o.BaseEndpoint != "https://fsn1.your-objectstorage.com" { |
| + t.Errorf("BaseEndpoint = %v, want the override", o.BaseEndpoint) |
| + } |
| + if !o.UsePathStyle { |
| + t.Error("UsePathStyle = false, want true under an endpoint override") |
| + } |
| + }) |
| + |
| + t.Run("no override leaves stock AWS addressing", func(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + c, _, err := newLeaserClient(context.Background(), "eu-west-1") |
| + if err != nil { |
| + t.Fatalf("newLeaserClient: %v", err) |
| + } |
| + o := c.Options() |
| + if o.BaseEndpoint != nil { |
| + t.Errorf("BaseEndpoint = %q, want nil (stock AWS)", *o.BaseEndpoint) |
| + } |
| + if o.UsePathStyle { |
| + t.Error("UsePathStyle = true, want false without an override") |
| + } |
| + }) |
| +} |
| + |
| +// TestNewReplicaClient_EndpointConfig covers the other half of a lease: the |
| +// LTX files beside lock.json. A replica client that ignored the override while |
| +// the leaser honoured it would split one lease across two stores. |
| +func TestNewReplicaClient_EndpointConfig(t *testing.T) { |
| + t.Run("override", func(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "https://fsn1.your-objectstorage.com") |
| + c := newReplicaClient("b", "p", "eu-central-1") |
| + if c.Endpoint != "https://fsn1.your-objectstorage.com" { |
| + t.Errorf("Endpoint = %q, want the override", c.Endpoint) |
| + } |
| + if !c.ForcePathStyle { |
| + t.Error("ForcePathStyle = false, want true under an endpoint override") |
| + } |
| + if c.Bucket != "b" || c.Path != "p" || c.Region != "eu-central-1" { |
| + t.Errorf("bucket/path/region = %q/%q/%q, want b/p/eu-central-1", c.Bucket, c.Path, c.Region) |
| + } |
| + }) |
| + |
| + t.Run("no override", func(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + c := newReplicaClient("b", "p", "eu-west-1") |
| + if c.Endpoint != "" || c.ForcePathStyle { |
| + t.Errorf("Endpoint/ForcePathStyle = %q/%v, want empty/false without an override", c.Endpoint, c.ForcePathStyle) |
| + } |
| + }) |
| +} |
| + |
| +// TestRegionFor pins the order of authority. The case that matters most is |
| +// "chain wins over the non-AWS default": a compatible-store deployment sets |
| +// AWS_REGION to its own location (carlos.exciting.io uses nbg1) and that is |
| +// what release.NewS3Store already signs for against the same bucket — |
| +// substituting a constant would make the lease sign differently from every |
| +// other S3 client in the process. |
| +func TestRegionFor(t *testing.T) { |
| + tests := []struct { |
| + name, endpoint, explicit, chain, want string |
| + }{ |
| + {"explicit wins over everything", "https://s3.example.com", "fsn1", "nbg1", "fsn1"}, |
| + {"chain wins over the non-AWS default", "https://s3.example.com", "", "nbg1", "nbg1"}, |
| + {"non-AWS default only fills a total blank", "https://s3.example.com", "", "", lss3.DefaultRegion}, |
| + {"no override leaves a blank blank", "", "", "", ""}, |
| + {"no override keeps the chain", "", "", "eu-west-1", "eu-west-1"}, |
| + } |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", tt.endpoint) |
| + if got := regionFor(tt.explicit, tt.chain); got != tt.want { |
| + t.Errorf("regionFor(%q, %q) with endpoint %q = %q, want %q", |
| + tt.explicit, tt.chain, tt.endpoint, got, tt.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestResolveRegion_PrefersAWSRegionEnv is the end-to-end version of the case |
| +// above, through the real AWS chain: with AWS_REGION set and no explicit |
| +// region, the lease must sign for AWS_REGION — not for litestream's non-AWS |
| +// constant. This is the exact shape of the private CARLOS box's host.env. |
| +func TestResolveRegion_PrefersAWSRegionEnv(t *testing.T) { |
| + fakeCreds(t) |
| + t.Setenv("CARLOS_S3_ENDPOINT", "https://nbg1.your-objectstorage.com") |
| + t.Setenv("AWS_REGION", "nbg1") |
| + |
| + got, err := resolveRegion(context.Background(), "") |
| + if err != nil { |
| + t.Fatalf("resolveRegion: %v", err) |
| + } |
| + if got != "nbg1" { |
| + t.Errorf("resolveRegion = %q, want %q (AWS_REGION must outrank the non-AWS default)", got, "nbg1") |
| + } |
| +} |
diff --git a/internal/lease/lease.go b/internal/lease/lease.go| index 4827481ce..3a108ed81 100644 |
| --- a/internal/lease/lease.go |
| +++ b/internal/lease/lease.go |
| @@ -19,8 +19,6 @@ import ( |
| "sync" |
| "time" |
| - awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| - awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| "github.com/benbjohnson/litestream" |
| lss3 "github.com/benbjohnson/litestream/s3" |
| ) |
| @@ -184,7 +182,8 @@ type Held struct { |
| // and starts renewing it. If another process holds the lease it fails LOUDLY |
| // with a *HeldError (check with errors.As) — this is the |
| // signal the activator reacts to. Region may be empty; the AWS default chain |
| -// (env/profile) resolves it. |
| +// (env/profile) resolves it, or — under a CARLOS_S3_ENDPOINT override, where |
| +// there is no chain to resolve from — endpoint.go's non-AWS default. |
| func Acquire(ctx context.Context, bucket, prefix, region string) (*Held, error) { |
| return acquireTTL(ctx, bucket, prefix, region, LeaseTTL) |
| } |
| @@ -197,18 +196,18 @@ func Acquire(ctx context.Context, bucket, prefix, region string) (*Held, error) |
| // fast, which is what makes "the holder went dark" observable. |
| func acquireTTL(ctx context.Context, bucket, prefix, region string, ttl time.Duration) (*Held, error) { |
| // The leaser wants a ready-made S3 client (unlike the replica client, |
| - // which builds its own). Default AWS credential chain. |
| - var opts []func(*awsconfig.LoadOptions) error |
| - if region != "" { |
| - opts = append(opts, awsconfig.WithRegion(region)) |
| - } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + // which builds its own). Default AWS credential chain, and the |
| + // CARLOS_S3_ENDPOINT override applied — see endpoint.go for why the lock |
| + // and the data it guards must resolve to the same store. The region is |
| + // resolved ONCE, here, and carried on the Held: Replicate's replica client |
| + // has to sign for whatever this client signed for. |
| + client, region, err := newLeaserClient(ctx, region) |
| if err != nil { |
| - return nil, fmt.Errorf("load aws config: %w", err) |
| + return nil, err |
| } |
| leaser := lss3.NewLeaser() |
| - leaser.SetClient(awss3.NewFromConfig(cfg)) |
| + leaser.SetClient(client) |
| leaser.Bucket = bucket |
| leaser.Path = prefix // lock lives at <prefix>/lock.json, colocated with the replica |
| leaser.TTL = ttl |
| @@ -267,10 +266,7 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D |
| // namespace ends the class. |
| db.SetMetaPath(filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream")) |
| db.MonitorInterval = dbMonitorInterval |
| - client := lss3.NewReplicaClient() |
| - client.Bucket = h.leaser.Bucket |
| - client.Path = h.leaser.Path |
| - client.Region = h.region |
| + client := newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region) |
| db.Replica = litestream.NewReplicaWithClient(db, client) |
| if syncInterval > 0 { |
| db.Replica.SyncInterval = syncInterval |
| @@ -380,10 +376,14 @@ func (r *Replicator) Close(ctx context.Context) error { |
| // it could resurrect a stale copy that later shadows the true holder's newer |
| // state. |
| func Restore(ctx context.Context, dbPath, bucket, prefix, region string) (bool, error) { |
| - client := lss3.NewReplicaClient() |
| - client.Bucket = bucket |
| - client.Path = prefix |
| - client.Region = region |
| + // Resolved the same way the leaser's client resolves it — a restore that |
| + // signed for a different region than the lease guarding it would be a |
| + // silent split against a store that checks. |
| + region, err := resolveRegion(ctx, region) |
| + if err != nil { |
| + return false, err |
| + } |
| + client := newReplicaClient(bucket, prefix, region) |
| // A nil *DB is litestream's own pattern for URL restores (the CLI does the |
| // same): Restore only needs the replica client and the output path. |
diff --git a/internal/lease/lease_test.go b/internal/lease/lease_test.go| index 8129595d3..ca31c0733 100644 |
| --- a/internal/lease/lease_test.go |
| +++ b/internal/lease/lease_test.go |
| @@ -19,7 +19,6 @@ import ( |
| "time" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| - awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| @@ -65,13 +64,18 @@ func seedDB(t *testing.T, path string) { |
| } |
| } |
| +// s3Client is the verification client the live tests list and clean up with. |
| +// It deliberately goes through the package's OWN constructor rather than |
| +// building its own: a helper that dialled stock AWS while the code under test |
| +// honoured CARLOS_S3_ENDPOINT would fail every assertion against a compatible |
| +// store and strand the objects it was meant to delete. |
| func s3Client(t *testing.T, region string) *awss3.Client { |
| t.Helper() |
| - cfg, err := awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(region)) |
| + client, _, err := newLeaserClient(context.Background(), region) |
| if err != nil { |
| t.Fatalf("aws config: %v", err) |
| } |
| - return awss3.NewFromConfig(cfg) |
| + return client |
| } |
| func listKeys(t *testing.T, bucket, prefix, region string) []string { |
diff --git a/internal/release/store.go b/internal/release/store.go| index f8b5cbd7d..f672d74e4 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -24,6 +24,8 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| "github.com/aws/smithy-go" |
| + |
| + "github.com/carlosframework/platform/internal/s3compat" |
| ) |
| // ErrCASConflict is a conditional Put that lost: the object already existed |
| @@ -229,20 +231,21 @@ type S3Store struct { |
| } |
| // NewS3Store builds a store on the standard AWS credential/region chain. |
| -// CARLOS_S3_ENDPOINT (optional) points at an S3-compatible (R2, MinIO) and |
| -// implies path-style addressing; checksums go when-required because |
| -// default-on flexible checksums wrap bodies in aws-chunked framing some |
| -// compatibles reject (Eleven s3blobstore.go's R2 lesson). |
| +// Everything that makes a non-AWS store work — CARLOS_S3_ENDPOINT, path-style |
| +// addressing, when-required checksums, and any per-provider quirk — now lives |
| +// in internal/s3compat, so this store and the hibernation lease cannot drift |
| +// apart against the same bucket. See that package for the quirk table. |
| func NewS3Store(ctx context.Context, bucket string) (*S3Store, error) { |
| cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| if err != nil { |
| return nil, fmt.Errorf("s3store: load aws config: %w", err) |
| } |
| - client := awss3.NewFromConfig(cfg, func(o *awss3.Options) { |
| - if ep := strings.TrimSpace(os.Getenv("CARLOS_S3_ENDPOINT")); ep != "" { |
| - o.BaseEndpoint = &ep |
| - o.UsePathStyle = true |
| - } |
| + client := awss3.NewFromConfig(cfg, s3compat.Options(), func(o *awss3.Options) { |
| + // s3compat sets these too, but only under a custom endpoint. This |
| + // store has always set them on EVERY client including stock AWS, and |
| + // the flagship runs on stock AWS — so the unconditional form stays |
| + // here rather than becoming a silent behaviour change on the one |
| + // deployment that cannot be tested from a compatible store. |
| o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired |
| o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired |
| }) |
diff --git a/internal/release/store_rgw_test.go b/internal/release/store_rgw_test.go| new file mode 100644 |
| index 000000000..78ef98533 |
| --- /dev/null |
| +++ b/internal/release/store_rgw_test.go |
| @@ -0,0 +1,169 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// rgwFake is a fake S3 that reproduces the one behaviour measured against |
| +// Hetzner Object Storage (Ceph RGW) on 2026-08-07: If-Match on PUT is compared |
| +// against the UNQUOTED etag, so the quoted entity-tag form S3 itself returns |
| +// never matches and every compare-and-swap replace 412s. |
| +// |
| +// It is deliberately strict in both directions — a wrong etag is rejected in |
| +// either form — so a fix that merely stopped sending the condition would fail |
| +// this test rather than pass it. |
| +type rgwFake struct { |
| + etag string // current etag, unquoted |
| + body string |
| +} |
| + |
| +func newRGWFake(t *testing.T) (*rgwFake, string) { |
| + t.Helper() |
| + f := &rgwFake{} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + precondFailed := func() { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusPreconditionFailed) |
| + _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + |
| + `<Error><Code>PreconditionFailed</Code><Message>etag mismatch</Message></Error>`)) |
| + } |
| + switch r.Method { |
| + case http.MethodGet: |
| + if f.etag == "" { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(http.StatusNotFound) |
| + _, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>` + |
| + `<Error><Code>NoSuchKey</Code><Message>absent</Message></Error>`)) |
| + return |
| + } |
| + w.Header().Set("ETag", `"`+f.etag+`"`) // quoted, exactly as S3 does |
| + _, _ = w.Write([]byte(f.body)) |
| + case http.MethodPut: |
| + raw, _ := io.ReadAll(r.Body) |
| + switch { |
| + case r.Header.Get("If-None-Match") == "*": |
| + if f.etag != "" { |
| + precondFailed() // create-only against an existing object |
| + return |
| + } |
| + case r.Header.Get("If-Match") != "": |
| + got := r.Header.Get("If-Match") |
| + // THE QUIRK: RGW compares against the unquoted etag, so a |
| + // correctly-quoted If-Match never matches. |
| + if got != f.etag { |
| + precondFailed() |
| + return |
| + } |
| + } |
| + f.body = string(raw) |
| + f.etag = fmt.Sprintf("%08x", len(f.body)*2654435761) |
| + w.Header().Set("ETag", `"`+f.etag+`"`) |
| + w.WriteHeader(http.StatusOK) |
| + default: |
| + w.WriteHeader(http.StatusMethodNotAllowed) |
| + } |
| + })) |
| + t.Cleanup(srv.Close) |
| + return f, srv.URL |
| +} |
| + |
| +func rgwEnv(t *testing.T, url string) { |
| + t.Helper() |
| + t.Setenv("AWS_ACCESS_KEY_ID", "k") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "s") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_REGION", "nbg1") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("CARLOS_S3_ENDPOINT", url) |
| +} |
| + |
| +// TestS3Store_CASReplaceAgainstRGW is the regression test for the |
| +// compare-and-swap failure that made the private CARLOS box report itself |
| +// UNREACHABLE: a single writer lost CAS 20/20 times against Hetzner because |
| +// every replace sent a quoted If-Match. |
| +// |
| +// The subtests are a matched pair — the quirk off case must FAIL, or the fix |
| +// is not what is making the on case pass. |
| +func TestS3Store_CASReplaceAgainstRGW(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + replaceOnce := func(t *testing.T) error { |
| + t.Helper() |
| + _, url := newRGWFake(t) |
| + rgwEnv(t, url) |
| + s, err := NewS3Store(ctx, "b") |
| + if err != nil { |
| + t.Fatalf("NewS3Store: %v", err) |
| + } |
| + // Create-only first: this path uses If-None-Match:* and works on RGW, |
| + // which is exactly why a fresh deployment looks healthy. |
| + if _, err := s.Put(ctx, "status/boxes/x.json", []byte(`{"n":0}`), ""); err != nil { |
| + t.Fatalf("create-only Put should succeed even on RGW: %v", err) |
| + } |
| + _, etag, err := s.Get(ctx, "status/boxes/x.json") |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + if !strings.HasPrefix(etag, `"`) { |
| + t.Fatalf("fake should hand back a quoted etag like S3 does, got %q", etag) |
| + } |
| + // The replace — the operation that fails in production. |
| + _, err = s.Put(ctx, "status/boxes/x.json", []byte(`{"n":1}`), etag) |
| + return err |
| + } |
| + |
| + t.Run("quirk off: reproduces the production failure", func(t *testing.T) { |
| + t.Setenv("CARLOS_S3_QUIRKS", "") |
| + err := replaceOnce(t) |
| + if !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("want ErrCASConflict without the quirk (this is the bug), got %v", err) |
| + } |
| + }) |
| + |
| + t.Run("quirk on: replace succeeds", func(t *testing.T) { |
| + // The fake's host is 127.0.0.1, so match-by-hostname cannot fire; |
| + // force by name, the same way an operator would on a new store. |
| + t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") |
| + if err := replaceOnce(t); err != nil { |
| + t.Fatalf("replace should succeed with the unquoted-if-match quirk: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestS3Store_CASStillRejectsAStaleEtag is the safety half. Stripping quotes |
| +// must FIX compare-and-swap, not remove it: a genuinely stale etag has to keep |
| +// losing, or every CAS in CARLOS silently becomes a blind overwrite. |
| +func TestS3Store_CASStillRejectsAStaleEtag(t *testing.T) { |
| + ctx := context.Background() |
| + _, url := newRGWFake(t) |
| + rgwEnv(t, url) |
| + t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") |
| + |
| + s, err := NewS3Store(ctx, "b") |
| + if err != nil { |
| + t.Fatalf("NewS3Store: %v", err) |
| + } |
| + key := "status/boxes/y.json" |
| + if _, err := s.Put(ctx, key, []byte(`{"n":0}`), ""); err != nil { |
| + t.Fatalf("seed Put: %v", err) |
| + } |
| + _, stale, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + // Someone else moves the object on. |
| + if _, err := s.Put(ctx, key, []byte(`{"n":1,"other":true}`), stale); err != nil { |
| + t.Fatalf("first replace: %v", err) |
| + } |
| + // Our now-stale etag must lose. |
| + if _, err := s.Put(ctx, key, []byte(`{"n":2}`), stale); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("a stale etag must still lose CAS, got %v — the quirk has disabled compare-and-swap", err) |
| + } |
| +} |
diff --git a/internal/s3compat/s3compat.go b/internal/s3compat/s3compat.go| new file mode 100644 |
| index 000000000..68995019f |
| --- /dev/null |
| +++ b/internal/s3compat/s3compat.go |
| @@ -0,0 +1,181 @@ |
| +// Package s3compat is the one place CARLOS records how a given S3-compatible |
| +// store differs from AWS S3, and the only place that knows CARLOS_S3_ENDPOINT. |
| +// |
| +// Two kinds of adjustment live here. The first is universal — anything that is |
| +// not AWS needs a base endpoint, path-style addressing and when-required |
| +// checksums (default-on flexible checksums wrap bodies in aws-chunked framing |
| +// some compatibles reject). Those were already duplicated in |
| +// release.NewS3Store and internal/lease; Options folds them into one. |
| +// |
| +// The second is per-provider QUIRKS: places a store answers differently enough |
| +// that correct S3 code breaks against it. Those go in the quirks table below, |
| +// each entry naming the provider it matches, what it changes, and — the part |
| +// that matters six months later — the measurement that justifies it. Adding a |
| +// provider is one entry; nothing else in the codebase changes. |
| +// |
| +// Deliberately NOT covered: litestream's replica client, which builds its own |
| +// S3 client we cannot decorate. It needs none of the quirks here — it writes |
| +// LTX files with unconditional PUTs — and its own provider auto-detection |
| +// already handles the universal settings when an Endpoint is set. The leaser |
| +// is different: it takes a ready-made client (lss3.Leaser.SetClient), which is |
| +// exactly how the If-Match quirk below reaches it. |
| +package s3compat |
| + |
| +import ( |
| + "context" |
| + "net/url" |
| + "os" |
| + "strings" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/aws/smithy-go/middleware" |
| + smithyhttp "github.com/aws/smithy-go/transport/http" |
| +) |
| + |
| +// Endpoint reports the CARLOS_S3_ENDPOINT override, or "" for stock AWS. Read |
| +// per call rather than cached at init so a test — and a process that re-reads |
| +// its config — sees the value it just set. |
| +func Endpoint() string { |
| + return strings.TrimSpace(os.Getenv("CARLOS_S3_ENDPOINT")) |
| +} |
| + |
| +// EndpointHost is the bare host of the override ("" for stock AWS), which is |
| +// what quirk matchers key on. A bare host with no scheme still parses: the |
| +// value is normalised to a URL first, so both "https://x.example" and |
| +// "x.example" match the same way. |
| +func EndpointHost() string { |
| + ep := Endpoint() |
| + if ep == "" { |
| + return "" |
| + } |
| + if !strings.Contains(ep, "://") { |
| + ep = "https://" + ep |
| + } |
| + u, err := url.Parse(ep) |
| + if err != nil { |
| + return "" |
| + } |
| + return u.Hostname() |
| +} |
| + |
| +// Options returns the S3 client option every CARLOS S3 client should apply. |
| +// With no override it is a NO-OP — the flagship's pure-AWS path must stay |
| +// byte-identical, so nothing here may touch a client that talks to real S3. |
| +func Options() func(*awss3.Options) { |
| + return func(o *awss3.Options) { |
| + ep := Endpoint() |
| + if ep == "" { |
| + return |
| + } |
| + o.BaseEndpoint = &ep |
| + o.UsePathStyle = true |
| + o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired |
| + o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired |
| + for _, q := range Quirks { |
| + if q.enabled(EndpointHost()) { |
| + q.Apply(o) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Quirk is one provider's documented deviation from S3 and the fix for it. |
| +type Quirk struct { |
| + // Name is the stable identifier, also the token CARLOS_S3_QUIRKS uses to |
| + // force the quirk on for a store whose hostname Match does not know. |
| + Name string |
| + // Reason is why this exists — ideally the measurement, not a guess. |
| + Reason string |
| + // Match reports whether the quirk applies to an endpoint host. A nil Match |
| + // means "every non-AWS store", which should be rare: prefer naming the |
| + // providers actually measured, so an unmeasured store fails loudly in its |
| + // own way rather than silently inheriting someone else's workaround. |
| + Match func(host string) bool |
| + // Apply adjusts the client. Wire-level fixes register SDK middleware here. |
| + Apply func(*awss3.Options) |
| +} |
| + |
| +func (q Quirk) enabled(host string) bool { |
| + for _, forced := range strings.Split(os.Getenv("CARLOS_S3_QUIRKS"), ",") { |
| + if strings.TrimSpace(forced) == q.Name { |
| + return true |
| + } |
| + } |
| + if q.Match == nil { |
| + return true |
| + } |
| + return q.Match(host) |
| +} |
| + |
| +// Quirks is the table. Add a provider by adding an entry. |
| +var Quirks = []Quirk{unquotedIfMatch} |
| + |
| +// isHetzner matches Hetzner Object Storage (Ceph RGW), e.g. |
| +// nbg1.your-objectstorage.com. Same suffix litestream's own |
| +// IsHetznerEndpoint uses. |
| +func isHetzner(host string) bool { |
| + return strings.HasSuffix(host, ".your-objectstorage.com") |
| +} |
| + |
| +// unquotedIfMatch is the fix for the compare-and-swap failure measured against |
| +// Hetzner Object Storage on 2026-08-07. |
| +// |
| +// RGW compares If-Match against its UNQUOTED internal etag, so the quoted |
| +// entity-tag form that S3 returns — and that the AWS SDK faithfully round-trips |
| +// — never matches. Measured against the carlos bucket: |
| +// |
| +// PUT If-Match: "<correct etag>" -> 412 (quoted, as AWS returns it) |
| +// PUT If-Match: <correct etag> -> OK (same etag, unquoted) |
| +// PUT If-Match: <wrong etag> -> 412 (still correctly rejected) |
| +// PUT If-None-Match: * (exists) -> 412 (correctly rejected) |
| +// |
| +// The third line is what makes this safe: the condition IS evaluated, only the |
| +// quoting form differs, so stripping the quotes fixes compare-and-swap rather |
| +// than disabling it. RFC 9110 entity-tags are quoted, so AWS is right and RGW |
| +// is wrong — but CARLOS still has to interoperate. |
| +// |
| +// Scoped to PutObject on purpose. Conditional DELETE accepts the quoted form |
| +// on the same store (it is how the lease releases today, and it works), and a |
| +// working path is not worth touching on the strength of an untested guess. |
| +// |
| +// What it unblocks: every CAS replace in CARLOS — release pointers and |
| +// manifests, the ledger head, appconfig generations, provision records, status |
| +// snapshots, metrics rollups — plus litestream's lease heartbeat, which renews |
| +// with PUT If-Match every TTL/3 and so lost the lease at the first renewal, |
| +// standing a woken instance back down five minutes later, every time. |
| +var unquotedIfMatch = Quirk{ |
| + Name: "unquoted-if-match", |
| + Reason: "Ceph RGW (Hetzner) compares If-Match against the unquoted etag; the quoted form always 412s", |
| + Match: isHetzner, |
| + Apply: func(o *awss3.Options) { |
| + o.APIOptions = append(o.APIOptions, unquoteIfMatchMiddleware) |
| + }, |
| +} |
| + |
| +// unquoteIfMatchMiddleware strips the surrounding quotes from a PutObject's |
| +// If-Match header. |
| +// |
| +// It runs in the BUILD step, which is after serialization has written the |
| +// header and before the Finalize step signs the request — the only window |
| +// where the change is both possible and covered by the signature. Rewriting |
| +// after signing would produce a SignatureDoesNotMatch instead of a fix. |
| +func unquoteIfMatchMiddleware(stack *middleware.Stack) error { |
| + return stack.Build.Add(middleware.BuildMiddlewareFunc("carlosUnquoteIfMatch", |
| + func(ctx context.Context, in middleware.BuildInput, next middleware.BuildHandler) ( |
| + middleware.BuildOutput, middleware.Metadata, error, |
| + ) { |
| + if awsmiddleware.GetOperationName(ctx) == "PutObject" { |
| + if req, ok := in.Request.(*smithyhttp.Request); ok { |
| + if v := req.Header.Get("If-Match"); v != "" { |
| + if u := strings.Trim(v, `"`); u != v { |
| + req.Header.Set("If-Match", u) |
| + } |
| + } |
| + } |
| + } |
| + return next.HandleBuild(ctx, in) |
| + }, |
| + ), middleware.After) |
| +} |
diff --git a/internal/s3compat/s3compat_test.go b/internal/s3compat/s3compat_test.go| new file mode 100644 |
| index 000000000..00268072c |
| --- /dev/null |
| +++ b/internal/s3compat/s3compat_test.go |
| @@ -0,0 +1,235 @@ |
| +package s3compat |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| +) |
| + |
| +// recorder is a fake S3 that records the conditional headers it was sent. |
| +type recorder struct { |
| + mu sync.Mutex |
| + seen map[string]string // "METHOD If-Match" -> value |
| + auth map[string]string // "METHOD" -> Authorization |
| +} |
| + |
| +func newRecorder(t *testing.T) (*recorder, string) { |
| + t.Helper() |
| + r := &recorder{seen: map[string]string{}, auth: map[string]string{}} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + r.mu.Lock() |
| + r.seen[req.Method+" If-Match"] = req.Header.Get("If-Match") |
| + r.auth[req.Method] = req.Header.Get("Authorization") |
| + r.mu.Unlock() |
| + w.Header().Set("ETag", `"deadbeef"`) |
| + w.WriteHeader(http.StatusOK) |
| + })) |
| + t.Cleanup(srv.Close) |
| + return r, srv.URL |
| +} |
| + |
| +func (r *recorder) get(k string) string { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return r.seen[k] |
| +} |
| + |
| +func (r *recorder) authFor(m string) string { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return r.auth[m] |
| +} |
| + |
| +func testClient(t *testing.T) *awss3.Client { |
| + t.Helper() |
| + cfg := aws.Config{ |
| + Region: "nbg1", |
| + Credentials: credentials.NewStaticCredentialsProvider("k", "s", ""), |
| + } |
| + return awss3.NewFromConfig(cfg, Options()) |
| +} |
| + |
| +// TestUnquotedIfMatch_RewritesPutOnly is the core of the fix: a PutObject's |
| +// If-Match must reach the wire unquoted (what Ceph RGW compares against), |
| +// while a DeleteObject's must be left exactly as the SDK wrote it — the quoted |
| +// form is what works for conditional DELETE on the same store, and a working |
| +// path is not worth touching. |
| +func TestUnquotedIfMatch_RewritesPutOnly(t *testing.T) { |
| + rec, url := newRecorder(t) |
| + t.Setenv("CARLOS_S3_ENDPOINT", url) |
| + // The httptest host is 127.0.0.1, not a Hetzner hostname, so force the |
| + // quirk on by name — which also exercises the CARLOS_S3_QUIRKS escape |
| + // hatch that lets an operator try a fix against an unrecognised store. |
| + t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") |
| + |
| + c := testClient(t) |
| + ctx := context.Background() |
| + quoted := `"deadbeef"` |
| + |
| + if _, err := c.PutObject(ctx, &awss3.PutObjectInput{ |
| + Bucket: aws.String("b"), Key: aws.String("k"), |
| + Body: strings.NewReader("x"), IfMatch: aws.String(quoted), |
| + }); err != nil { |
| + t.Fatalf("PutObject: %v", err) |
| + } |
| + if got := rec.get("PUT If-Match"); got != "deadbeef" { |
| + t.Errorf("PUT If-Match = %q, want %q (quotes stripped)", got, "deadbeef") |
| + } |
| + |
| + if _, err := c.DeleteObject(ctx, &awss3.DeleteObjectInput{ |
| + Bucket: aws.String("b"), Key: aws.String("k"), IfMatch: aws.String(quoted), |
| + }); err != nil { |
| + t.Fatalf("DeleteObject: %v", err) |
| + } |
| + if got := rec.get("DELETE If-Match"); got != quoted { |
| + t.Errorf("DELETE If-Match = %q, want %q (left untouched)", got, quoted) |
| + } |
| +} |
| + |
| +// TestUnquotedIfMatch_RewriteIsSigned guards the subtlety that makes this a |
| +// Build-step middleware rather than a Finalize one: If-Match is a SIGNED |
| +// header, so a rewrite after signing would produce SignatureDoesNotMatch |
| +// instead of a fix. If the signer did not see if-match, it is not in |
| +// SignedHeaders — which is observable from the Authorization header. |
| +func TestUnquotedIfMatch_RewriteIsSigned(t *testing.T) { |
| + rec, url := newRecorder(t) |
| + t.Setenv("CARLOS_S3_ENDPOINT", url) |
| + t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") |
| + |
| + c := testClient(t) |
| + if _, err := c.PutObject(context.Background(), &awss3.PutObjectInput{ |
| + Bucket: aws.String("b"), Key: aws.String("k"), |
| + Body: strings.NewReader("x"), IfMatch: aws.String(`"deadbeef"`), |
| + }); err != nil { |
| + t.Fatalf("PutObject: %v", err) |
| + } |
| + auth := rec.authFor("PUT") |
| + if !strings.Contains(auth, "SignedHeaders=") { |
| + t.Fatalf("no SignedHeaders in Authorization: %q", auth) |
| + } |
| + if !strings.Contains(auth, "if-match") { |
| + t.Errorf("if-match missing from SignedHeaders — the rewrite is outside the signature: %q", auth) |
| + } |
| +} |
| + |
| +// TestOptions_NoOpWithoutEndpoint is the flagship guard: on stock AWS this |
| +// package must change nothing at all, including registering no middleware. |
| +func TestOptions_NoOpWithoutEndpoint(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + t.Setenv("CARLOS_S3_QUIRKS", "") |
| + |
| + var o awss3.Options |
| + Options()(&o) |
| + |
| + if o.BaseEndpoint != nil { |
| + t.Errorf("BaseEndpoint = %q, want nil on stock AWS", *o.BaseEndpoint) |
| + } |
| + if o.UsePathStyle { |
| + t.Error("UsePathStyle = true, want false on stock AWS") |
| + } |
| + if len(o.APIOptions) != 0 { |
| + t.Errorf("APIOptions = %d, want 0 — no quirk middleware on stock AWS", len(o.APIOptions)) |
| + } |
| + if o.RequestChecksumCalculation != 0 { |
| + t.Errorf("RequestChecksumCalculation = %v, want the SDK default on stock AWS", o.RequestChecksumCalculation) |
| + } |
| +} |
| + |
| +// TestOptions_UniversalSettingsUnderEndpoint pins what every compatible store |
| +// gets regardless of provider. |
| +func TestOptions_UniversalSettingsUnderEndpoint(t *testing.T) { |
| + t.Setenv("CARLOS_S3_ENDPOINT", "https://nbg1.your-objectstorage.com") |
| + t.Setenv("CARLOS_S3_QUIRKS", "") |
| + |
| + var o awss3.Options |
| + Options()(&o) |
| + |
| + if o.BaseEndpoint == nil || *o.BaseEndpoint != "https://nbg1.your-objectstorage.com" { |
| + t.Errorf("BaseEndpoint = %v, want the override", o.BaseEndpoint) |
| + } |
| + if !o.UsePathStyle { |
| + t.Error("UsePathStyle = false, want true under an override") |
| + } |
| + if o.RequestChecksumCalculation != aws.RequestChecksumCalculationWhenRequired { |
| + t.Error("RequestChecksumCalculation should be when-required under an override") |
| + } |
| + if len(o.APIOptions) != 1 { |
| + t.Errorf("APIOptions = %d, want 1 (the Hetzner If-Match quirk matched by host)", len(o.APIOptions)) |
| + } |
| +} |
| + |
| +func TestEndpointHost(t *testing.T) { |
| + for _, tt := range []struct{ ep, want string }{ |
| + {"https://nbg1.your-objectstorage.com", "nbg1.your-objectstorage.com"}, |
| + {"nbg1.your-objectstorage.com", "nbg1.your-objectstorage.com"}, // no scheme |
| + {"http://localhost:9000", "localhost"}, |
| + {"", ""}, |
| + } { |
| + t.Setenv("CARLOS_S3_ENDPOINT", tt.ep) |
| + if got := EndpointHost(); got != tt.want { |
| + t.Errorf("EndpointHost() with %q = %q, want %q", tt.ep, got, tt.want) |
| + } |
| + } |
| +} |
| + |
| +// TestQuirkMatching keeps the table honest: a quirk measured on one provider |
| +// must not leak onto stores nobody has tested it against. |
| +func TestQuirkMatching(t *testing.T) { |
| + t.Setenv("CARLOS_S3_QUIRKS", "") |
| + for _, tt := range []struct { |
| + host string |
| + want bool |
| + }{ |
| + {"nbg1.your-objectstorage.com", true}, |
| + {"fsn1.your-objectstorage.com", true}, |
| + {"s3.eu-west-1.amazonaws.com", false}, |
| + {"minio.internal", false}, |
| + {"evil-your-objectstorage.com", false}, // suffix match needs the dot |
| + } { |
| + if got := unquotedIfMatch.enabled(tt.host); got != tt.want { |
| + t.Errorf("unquoted-if-match enabled for %q = %v, want %v", tt.host, got, tt.want) |
| + } |
| + } |
| +} |
| + |
| +// TestQuirkForceEnable covers the escape hatch: trying a known fix against an |
| +// unrecognised store should not need a rebuild. |
| +func TestQuirkForceEnable(t *testing.T) { |
| + t.Setenv("CARLOS_S3_QUIRKS", "unquoted-if-match") |
| + if !unquotedIfMatch.enabled("some.unknown.store") { |
| + t.Error("CARLOS_S3_QUIRKS did not force the quirk on for an unmatched host") |
| + } |
| + t.Setenv("CARLOS_S3_QUIRKS", " other-quirk , unquoted-if-match ") |
| + if !unquotedIfMatch.enabled("some.unknown.store") { |
| + t.Error("CARLOS_S3_QUIRKS should tolerate spaces and a list") |
| + } |
| + t.Setenv("CARLOS_S3_QUIRKS", "something-else") |
| + if unquotedIfMatch.enabled("some.unknown.store") { |
| + t.Error("an unrelated CARLOS_S3_QUIRKS entry must not enable this quirk") |
| + } |
| +} |
| + |
| +// TestQuirkNamesAreUnique — the table is looked up by name from an env var, so |
| +// duplicates would make CARLOS_S3_QUIRKS ambiguous. |
| +func TestQuirkNamesAreUnique(t *testing.T) { |
| + seen := map[string]bool{} |
| + for _, q := range Quirks { |
| + if q.Name == "" { |
| + t.Error("a quirk has no Name; CARLOS_S3_QUIRKS could not address it") |
| + } |
| + if q.Reason == "" { |
| + t.Errorf("quirk %q has no Reason — the table exists to record WHY", q.Name) |
| + } |
| + if seen[q.Name] { |
| + t.Errorf("duplicate quirk name %q", q.Name) |
| + } |
| + seen[q.Name] = true |
| + } |
| +} |
diff --git a/main.go b/main.go| index bcdc89bd5..a068aa33a 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -4,6 +4,7 @@ package main |
| import ( |
| "fmt" |
| + "io" |
| "log" |
| "os" |
| @@ -15,12 +16,21 @@ import ( |
| ) |
| func main() { |
| + // Failure is reported through log.Fatal everywhere in this binary, so |
| + // the logger is where the "this went wrong" colour goes — once, rather |
| + // than at a few hundred call sites. Off unless stderr is a terminal, so |
| + // journald and CI logs see exactly what they saw before. |
| + log.SetOutput(newErrorWriter(os.Stderr)) |
| if len(os.Args) < 2 { |
| - usage() |
| + usage(os.Stderr) |
| os.Exit(2) |
| } |
| cmd, args := os.Args[1], os.Args[2:] |
| switch cmd { |
| + // Asking for help is not an error: it prints on stdout and exits 0, so |
| + // `carlos help | less` works. A bare `carlos` still fails (stderr, 2). |
| + case "help", "-h", "--help": |
| + usage(os.Stdout) |
| case "version": |
| fmt.Printf("carlos %s\n", version.Build) |
| case "edge": |
| @@ -70,83 +80,51 @@ func main() { |
| case "canary": |
| log.Fatalf("not implemented: %s", cmd) |
| default: |
| - usage() |
| + usage(os.Stderr) |
| os.Exit(2) |
| } |
| } |
| -func usage() { |
| - fmt.Fprintln(os.Stderr, `usage: carlos <command> |
| +// usage prints the top-level command list, grouped by the role you are in |
| +// when you type the command: shipping your own app, standing up and |
| +// administering a deployment, or the verbs a host runs on itself. The |
| +// one-liners here are deliberately thin — every command prints its own full |
| +// usage (bare, or with -h), and that is where the detail belongs. A flat |
| +// alphabetical list of every verb with every caveat inline was the previous |
| +// shape, and it read as a wall rather than as an offer of what to do next. |
| +func usage(w io.Writer) { |
| + fmt.Fprintln(w, `usage: carlos <command> [flags] |
| -commands: |
| - version print the build id |
| - edge run the mesh front door (router) |
| - agent run edge + activator + release adoption on this host |
| - add | remove | instances manage routes (bare "instances" lists THIS |
| - box's registry routes; "instances enable" |
| - opts an app into instance provisioning — |
| - signed-in mode pins it to the account's |
| - platform subdomain; "instances |
| - create|list|delete" manage instance records |
| - through the console API, signed-in only) |
| - route -host <h> | -app <a> [-account <sqid|name|all>] |
| - [-channel <c>] [-environment <name>] [-dry-run]: |
| - change which release channel and/or named |
| - config environment a route follows — at least |
| - one of -channel/-environment is required, both |
| - may be combined (-account defaults to ops; |
| - -app is all-or-nothing and skips |
| - platform-minted routes; -channel refuses a |
| - version change unless -allow-version-change is |
| - passed; -environment refuses a bundle that |
| - does not exist yet, and "-" clears to default) |
| - -host <h> (-grant | -revoke) <port:n|host-role>: |
| - grant or revoke an operator-consented capability |
| - for ONE route on THIS box (never -app, never |
| - combined with a repoint; 22/80/443 are reserved |
| - and ungrantable) |
| - -host <h> -hibernate[=false]: hibernate when idle |
| - (refused for a route whose release declares the |
| - daemon capability) |
| - -host <h> -backing <unit|"">: who owns this |
| - route's process — a carlos-app@<host> systemd |
| - unit, or an exec child of the agent (the |
| - migration's one write; grants need unit backing) |
| - auth login | whoami | logout — who this terminal is |
| - (headless? don't log in: pass -owner instead) |
| - accounts create | list | migrate — the tenancy primitive |
| - apps create | delete | restore — claim a new app |
| - name (console API only, rate limited per |
| - address per day; the console form is not), |
| - trash an app for 30 days (routes drop |
| - everywhere; refused while flagged production), |
| - or undo that within the window (no routes come |
| - back; carlos add <host> after); delete/restore |
| - run signed-in via the console or headless |
| - against the bucket (-account-id required |
| - headless); no purge here — "Delete now" is |
| - console-only |
| - env set | unset | list — plain per-app config vars |
| - secrets set | unset | list | genkey — sealed per-app secrets |
| - ship | promote | channels publish and promote releases (-account <sqid|name>) |
| - promote climbs the ladder canary -> edge -> beta |
| - -> stable; -hotfix is the recorded bypass |
| - ledger append | head | list | publish | blob | verify — |
| - the open-ledger primitive (carlos ledger -h for |
| - the full usage); every sub but "verify -url" |
| - goes through the console API, never bucket |
| - credentials — verify -url needs no credentials |
| - at all |
| - release-keygen mint the deployment's root signing key, or with |
| - -scope <rungs> -name <label> [-expires <dur>] a |
| - SCOPED key that can only sign those rungs |
| - (stable is never delegable; needs the root key |
| - in CARLOS_RELEASE_KEY to sign the grant) |
| - adopt run one release-adoption pass (the hourly timer's verb) |
| - canary ship this worktree to an ephemeral canary channel |
| - bootstrap prepare a host (units, dirs, user) |
| - ops litestream | restore-verify |
| - status -dry-run <dir>: fleet status page leak self-check |
| - (live publish has no CLI mode; the agent owns it) |
| - vet check an app release against the platform contract`) |
| +One binary, three roles. "carlos <command> -h" prints a command's own flags. |
| + |
| +Your apps — ship and run software on a CARLOS deployment |
| + auth log in, see who this terminal is, log out |
| + apps create | delete | restore an app name |
| + ship publish a release of an app |
| + promote move a version up the ladder: canary → edge → beta → stable |
| + rollback point a channel back at an earlier version |
| + channels show what each of an app's channels is serving |
| + env plain per-app config vars: set | unset | list |
| + secrets sealed per-app secrets: set | unset | list | genkey |
| + instances an app's instances: enable | create | list | delete |
| + ledger open ledgers: append | head | list | publish | blob | verify |
| + vet check a release against the platform contract |
| + |
| +Your deployment — stand up and administer boxes, routes and accounts |
| + bootstrap prepare a host: units, dirs, user |
| + accounts the tenancy primitive: create | list | migrate |
| + add route a hostname on this box to an app |
| + remove drop one of this box's routes |
| + route repoint a route's channel or config environment, grant or |
| + revoke a capability, hibernate it, change its backing |
| + instances with no sub-verb: list THIS box's registry routes |
| + release-keygen mint the root signing key, or a scoped signing key |
| + status -live: what every box last reported; -dry-run: leak check |
| + |
| +On the host — what systemd runs; rarely typed by hand |
| + edge the mesh front door (router) |
| + agent edge + activator + release adoption for this host |
| + adopt one release-adoption pass (the hourly timer's verb) |
| + ops box operations: litestream | restore-verify |
| + version print the build id`) |
| } |
diff --git a/testdata/echoapp/main.go b/testdata/echoapp/main.go| index ef5b2264a..4295cb70e 100644 |
| --- a/testdata/echoapp/main.go |
| +++ b/testdata/echoapp/main.go |
| @@ -2,6 +2,23 @@ |
| // contract — it accepts -socket (unix) or -addr (TCP), or inherits a |
| // systemd-activated listener (LISTEN_FDS, the carlos-app@.socket contract), |
| // answers GET /healthz with "ok", and echoes its BUILD env everywhere else. |
| +// |
| +// It also accepts -db and ignores it. The two backends spawn a tenant |
| +// DIFFERENTLY: carlos-app@.service runs `live/<host> serve` with no flags |
| +// (the listener arrives as LISTEN_FDS), while the activator's ExecBackend |
| +// runs `live/<host> --socket <s> --db <path>` (backend_exec.go). An app that |
| +// declares only -socket/-addr therefore works unit-backed and dies instantly |
| +// exec-backed — Go's flag package rejects the unknown --db, prints usage and |
| +// exits before binding anything, so the activator waits out its 30s health |
| +// timeout on a socket that will never appear and the edge serves "instance |
| +// is waking; retry shortly" forever. |
| +// |
| +// That is exactly what happened on a live deployment: an instance |
| +// provisioned through `carlos instances create` could not boot, because this |
| +// tenant — the one the e2e suite treats as the reference app — only ever met |
| +// the unit-backed half of the contract it claims to honour. echoapp keeps no |
| +// state, so the flag is accepted and discarded; the point is to be spawnable |
| +// by BOTH backends. |
| package main |
| import ( |
| @@ -16,6 +33,10 @@ import ( |
| func main() { |
| socket := flag.String("socket", "", "unix socket to listen on") |
| addr := flag.String("addr", "", "TCP host:port to listen on") |
| + // Accepted and ignored: the activator always passes it (every route the |
| + // provisioning pass mints carries a DBPath), and a tenant that cannot |
| + // PARSE it never reaches its listener. echoapp keeps no state. |
| + _ = flag.String("db", "", "instance database path (accepted, unused: echoapp is stateless)") |
| flag.Parse() |
| mux := http.NewServeMux() |