carlos / platform Public

host: CARLOS_STATUS_BUCKET defaults to CARLOS_DEPLOYMENT_BUCKET — one bucket for the common config, not two that must be kept in sync by hand

statusLoop read CARLOS_STATUS_BUCKET while the edge/console serve the
fleet status page from the deployment store, so setting only
CARLOS_DEPLOYMENT_BUCKET silently published status to nowhere anyone
looks. resolveStatusBucket (extracted for testing without env
pollution) now falls back to CARLOS_DEPLOYMENT_BUCKET when
CARLOS_STATUS_BUCKET is unset; an explicit CARLOS_STATUS_BUCKET still
wins. The effective bucket is logged at loop start. Doc comment +
README updated: the serving side must point at the same bucket, and
CARLOS_DEPLOYMENT_DIR deployments don't publish status yet (no bucket
for the fallback to resolve to).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 11cdddf294aa714f7020aaa2c9635b57d43258eb parent c0654d5
3 files changed, +93 −8
  • README.md +13 −3
  • internal/host/agent.go +47 −5
  • internal/host/host_test.go +33 −0
diff --git a/README.md b/README.md
index 300b564df..8b4deeba0 100644
--- a/README.md
+++ b/README.md
@@ -50,9 +50,19 @@ no single reader owns the whole contract:
- `CARLOS_STATUS_BUCKET` / `CARLOS_STATUS_PREFIX` / `CARLOS_STATUS_REGION`
— read by `carlos agent`'s status loop: the bucket each box writes its
snapshot to and, when it wins the lease, publishes the merged feed to;
- prefix and region also feed the cross-box status-lease election. An
- unset `CARLOS_STATUS_BUCKET` disables the whole loop — inert until
- configured, the same convention litestream replication uses.
+ prefix and region also feed the cross-box status-lease election. **The
+ serving side (the edge's `kind=status` route and the console's operator
+ view) must be pointed at this same bucket** — nothing enforces that
+ agreement automatically, publishing and serving are separately
+ configured. To make the common case foolproof, an unset
+ `CARLOS_STATUS_BUCKET` defaults to `CARLOS_DEPLOYMENT_BUCKET` (an
+ explicit `CARLOS_STATUS_BUCKET` still always wins), so a single-bucket
+ deployment needs only one env var for both release adoption and status.
+ Both unset disables the whole loop — inert until configured, the same
+ convention litestream replication uses. **Honest limitation**: that
+ default only ever resolves to an S3 bucket — a deployment adopting from
+ `CARLOS_DEPLOYMENT_DIR` (no bucket at all) does not publish fleet status
+ yet; give it its own `CARLOS_STATUS_BUCKET` if it wants the page.
- `STATUS_BOX_REGION` — also read by `carlos agent`'s status loop: an
operator-set, coarse region word (e.g. `"EU · Ireland"`) carried onto
this box's snapshot verbatim; unset omits the field. Free text the
diff --git a/internal/host/agent.go b/internal/host/agent.go
index b170ff6e8..16455f514 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -15,13 +15,26 @@
// read by statusLoop. CARLOS_STATUS_BUCKET names the S3 bucket each
// box writes its own status/boxes/<label>.json snapshot to and (when
// it wins the lease) publishes status/fleet.json + the viewer assets
-// to; unset -> the loop never starts, logging one line and returning —
-// the same "inert until configured" convention adoptLoop uses for
+// to. When CARLOS_STATUS_BUCKET is unset, it DEFAULTS to whatever
+// CARLOS_DEPLOYMENT_BUCKET is set to (resolveStatusBucket) — the edge
+// and console both serve the fleet status page out of the same
+// deployment bucket adoptLoop already publishes releases to, so the
+// common single-bucket deployment needs to set only one env var; an
+// explicit CARLOS_STATUS_BUCKET still always wins, for the deployments
+// that deliberately want a second, independent bucket. Both unset ->
+// the loop never starts, logging one line and returning — the same
+// "inert until configured" convention adoptLoop uses for
// CARLOS_DEPLOYMENT_BUCKET/_DIR, and litestream's own env-gated
// replication before that. CARLOS_STATUS_PREFIX and CARLOS_STATUS_REGION
// feed lease.Acquire (the single-writer election for the publish half
// of each tick); both may be empty (region falls back to the AWS
// default chain).
+// HONEST LIMIT: the CARLOS_DEPLOYMENT_BUCKET fallback only ever
+// resolves to an S3 bucket. A deployment adopting from
+// CARLOS_DEPLOYMENT_DIR (a plain directory — tests, or a single-box
+// no-AWS deployment) has nothing for the fallback to resolve to, so
+// DIR-based deployments do not publish fleet status yet; give them
+// their own CARLOS_STATUS_BUCKET explicitly if they want the page.
// - STATUS_BOX_REGION — an operator-set, coarse region word (e.g.
// "EU · Ireland") carried into status.Probes.Region and published
// verbatim on this box's snapshot; "" omits the field. Deliberately
@@ -168,6 +181,30 @@ func adoptLoop(reg *registry.Registry) {
}
}
+// resolveStatusBucket applies the status/deployment bucket defaulting
+// documented in this file's package comment: an explicit CARLOS_STATUS_BUCKET
+// always wins, and when it's unset the status loop falls back to whatever
+// CARLOS_DEPLOYMENT_BUCKET is set to, so the common single-bucket deployment
+// is one env var instead of two that must be kept in sync by hand. Returns
+// "" when neither is set — the loop stays disabled, same as before this fix.
+// Deliberately does NOT fall back to CARLOS_DEPLOYMENT_DIR: the status page
+// has no directory-store serving path (the edge/console read status/ out of
+// an S3 bucket, not a local directory), so a DIR-based deployment simply
+// doesn't get this fallback — see the "HONEST LIMIT" note in the package
+// comment above.
+//
+// Takes a getenv func rather than reading os.Getenv itself so the defaulting
+// logic is testable without mutating process-wide environment variables —
+// t.Setenv serializes tests within a package and the host package already
+// has other env-reading tests; a plain map/func seam avoids that coupling
+// entirely.
+func resolveStatusBucket(getenv func(string) string) string {
+ if b := getenv("CARLOS_STATUS_BUCKET"); b != "" {
+ return b
+ }
+ return getenv("CARLOS_DEPLOYMENT_BUCKET")
+}
+
// statusLoop runs the fleet status page's 5-minute tick, forever, once armed
// — see the package doc comment above for the full env contract. Missing
// CARLOS_STATUS_BUCKET disables the loop with one log line, the same
@@ -190,9 +227,9 @@ func adoptLoop(reg *registry.Registry) {
// other tick failure.
func statusLoop(reg *registry.Registry) {
ctx := context.Background()
- bucket := os.Getenv("CARLOS_STATUS_BUCKET")
+ bucket := resolveStatusBucket(os.Getenv)
if bucket == "" {
- log.Printf("agent: status loop disabled: CARLOS_STATUS_BUCKET not set")
+ log.Printf("agent: status loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set (CARLOS_DEPLOYMENT_DIR deployments don't publish status yet — set CARLOS_STATUS_BUCKET explicitly)")
return
}
prefix := os.Getenv("CARLOS_STATUS_PREFIX")
@@ -204,7 +241,12 @@ func statusLoop(reg *registry.Registry) {
log.Printf("agent: status loop disabled: %v", err)
return
}
- log.Printf("agent: status loop armed (5min + jitter)")
+ // The effective bucket is worth a log line on its own: it's easy to set
+ // CARLOS_DEPLOYMENT_BUCKET and CARLOS_STATUS_BUCKET to different values
+ // by accident (or on purpose) and end up with the agent writing status
+ // to a bucket the edge/console never look at — this line is the one
+ // place an operator can confirm which bucket actually won.
+ log.Printf("agent: status loop armed (5min + jitter), publishing to bucket %s", bucket)
// Jittered on the same shape as adoptLoop's delay: every box on the same
// 5-minute cadence would all hit the lease and the bucket in the same
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 3c0851bbb..31965f120 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -158,6 +158,39 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) {
}
}
+// TestResolveStatusBucketDefaultsToDeploymentBucket pins the I2 fix: the
+// status loop and the deployment (release-adoption) loop must agree on which
+// bucket to serve status out of by default, since the edge/console read
+// status/ from the deployment bucket — an explicit CARLOS_STATUS_BUCKET
+// still wins when set, and a CARLOS_DEPLOYMENT_DIR-only deployment (no S3
+// bucket to fall back to) gets no default at all.
+func TestResolveStatusBucketDefaultsToDeploymentBucket(t *testing.T) {
+ env := func(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+ }
+
+ cases := []struct {
+ name string
+ env map[string]string
+ want string
+ }{
+ {"explicit status bucket wins", map[string]string{
+ "CARLOS_STATUS_BUCKET": "status-bucket", "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket"}, "status-bucket"},
+ {"falls back to deployment bucket when status unset", map[string]string{
+ "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket"}, "deploy-bucket"},
+ {"neither set is still neither set", map[string]string{}, ""},
+ {"deployment dir gives no fallback (S3-only)", map[string]string{
+ "CARLOS_DEPLOYMENT_DIR": "/data/deploy"}, ""},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := resolveStatusBucket(env(c.env)); got != c.want {
+ t.Errorf("resolveStatusBucket(%v) = %q, want %q", c.env, got, c.want)
+ }
+ })
+ }
+}
+
// ---- Task 8: statusTick — the loop's pure part -----------------------------
// TestStatusTickWritesBoxAndPublishesOnlyWhenAsked is the plan's Step-1 test: