carlos / platform Public

Merge remote-tracking branch 'origin/main' into worktree-console-app-pages

Paul Campbell 3b0e7a9fe3699503eac4a5ef92626f75a9f41c9d parent 863327eparent 82dbca9
28 files changed, +1974 −248
  • .github/workflows/ci.yml +107 −0
  • CLAUDE.md +5 −0
  • README.md +8 −0
  • accounts.go +37 −30
  • appconfig_cli.go +24 −25
  • apps.go +15 −13
  • auth.go +17 −14
  • cli.go +45 −43
  • color.go +222 −0
  • color_test.go +128 −0
  • docs/superpowers/specs/2026-08-04-instance-provisioning-design.md +33 −0
  • go.mod +1 −1
  • instances_cli.go +13 −12
  • internal/console/attachhint_test.go +132 −0
  • internal/console/server.go +79 −1
  • internal/console/server_test.go +10 −1
  • internal/console/templates/app_releases.html +7 −1
  • internal/host/probes.go +1 −1
  • internal/lease/endpoint.go +130 −0
  • internal/lease/endpoint_test.go +263 −0
  • internal/lease/lease.go +19 −19
  • internal/lease/lease_test.go +7 −3
  • internal/release/store.go +12 −9
  • internal/release/store_rgw_test.go +169 −0
  • internal/s3compat/s3compat.go +181 −0
  • internal/s3compat/s3compat_test.go +235 −0
  • main.go +53 −75
  • testdata/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/&lt;name&gt;</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/&lt;name&gt; &lt;host&gt;</code>.</p>
+<p class="small">Creates channel <code>canary/&lt;name&gt;</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&#39;s platform subdomain. The host comes live on its box&#39;s next provision pass.</p>
+{{else}}<pre><code>{{.AttachCmd}}</code></pre>
+<p class="small">The host comes live on its box&#39;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()