host: fleet boxes speak the store door; retention stays a platform job (spec F5/F6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+506
−25
internal/host/agent.go+143 −17internal/host/agent_test.go+353 −0internal/host/metrics_loop.go+10 −8
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 11d17dc30..d22e0bdcf 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -68,6 +68,19 @@ |
| // — an invalid result refuses to arm the wake-time/startup binding |
| // publish (fleetbind.go) loudly, and degrades the heartbeat's label back |
| // to the hashed pseudonym rather than disabling it outright. |
| +// - CARLOS_TUNNEL_URL / CARLOS_TUNNEL_TOKEN — the tunnel env |
| +// tunnel_client.go already dials with, read a SECOND time here (spec |
| +// 2026-08-11-fleet-api-surface, F5) as the fleet store door's address |
| +// and credential: on a fleet box with NO bucket env set, openDeployStore |
| +// and the status/metrics store both become internal/fleetapi clients |
| +// speaking release.Store over the edge's /store/ routes as |
| +// "<fleet>/<label>", so the box needs no AWS credentials on the |
| +// platform's deployment bucket at all. A bucket still set WINS |
| +// everywhere (the migration fallback): every platform box, and any |
| +// grandfathered fleet box that still carries bucket credentials, keeps |
| +// direct S3 byte for byte. Missing tunnel env on a fleet box with no |
| +// bucket is a loud error, not a silent degrade — that box has no way to |
| +// reach a store at all. |
| // - 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 |
| @@ -127,6 +140,7 @@ import ( |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/fleetapi" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -1455,6 +1469,19 @@ func instancesLoop(reg *registry.Registry) { |
| // box that cannot verify pointers must not delete anything. |
| func retentionLoop(reg *registry.Registry) { |
| ctx := context.Background() |
| + // Platform boxes only (spec F6). Retention prunes the PLATFORM's release |
| + // objects — a customer's box scheduling deletes of them was already wrong |
| + // under direct S3, and through the store door it is refused outright (the |
| + // door's write/delete ACL scopes a fleet to fleets/<fleet>/**), so arming |
| + // here would buy nothing but an hourly loop of 404s. Reuses |
| + // shouldSkipPublisherElection because the question is exactly "is this a |
| + // fleet box", the same one instancesLoop asks — and it is deliberately the |
| + // bare CARLOS_FLEET test, not resolveFleetIdentity: a fleet box whose |
| + // LABEL is misconfigured is still a fleet box, and must still not prune. |
| + if shouldSkipPublisherElection(os.Getenv) { |
| + log.Printf("agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)") |
| + return |
| + } |
| store, err := openDeployStore(ctx) |
| if err != nil { |
| log.Printf("agent: retention loop disabled: %v", err) |
| @@ -1549,11 +1576,12 @@ func shouldSkipPublisherElection(getenv func(string) string) bool { |
| // other tick failure. |
| func statusLoop(reg *registry.Registry) { |
| ctx := context.Background() |
| + // bucket is read for the lease key alone (lease.Acquire below), which a |
| + // fleet box never reaches: shouldSkipPublisherElection short-circuits the |
| + // election before it, and a fleet box is the only shape that gets a store |
| + // with no bucket behind it (openStatusStore). Every other box resolves a |
| + // real bucket here exactly as before. |
| bucket := resolveStatusBucket(os.Getenv) |
| - if bucket == "" { |
| - 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") |
| region := os.Getenv("CARLOS_STATUS_REGION") |
| boxRegion := os.Getenv("STATUS_BOX_REGION") |
| @@ -1579,9 +1607,13 @@ func statusLoop(reg *registry.Registry) { |
| log.Printf("agent: %v — heartbeat will publish under the hashed pseudonym instead", err) |
| } |
| - s, err := release.NewS3Store(ctx, bucket) |
| + s, target, err := openStatusStore(ctx, os.Getenv) |
| if err != nil { |
| - log.Printf("agent: status loop disabled: %v", err) |
| + if errors.Is(err, errNoStatusStore) { |
| + 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)") |
| + } else { |
| + log.Printf("agent: status loop disabled: %v", err) |
| + } |
| return |
| } |
| // store is what statusTick actually reads/writes through: identical to s |
| @@ -1590,12 +1622,13 @@ func statusLoop(reg *registry.Registry) { |
| // release.NewPrefixStore and the package doc comment's |
| // CARLOS_STATUS_PREFIX entry. |
| store := release.NewPrefixStore(s, prefix) |
| - // 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 (%s + jitter), publishing to bucket %s", status.SnapshotPeriod, bucket) |
| + // The effective destination 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 (or, on a bucketless |
| + // fleet box, which door identity) actually won. |
| + log.Printf("agent: status loop armed (%s + jitter), publishing to %s", status.SnapshotPeriod, target) |
| // canaryStore/canaryPub resolve the release deployment config |
| // countExpiredCanaries needs to answer §5's "how many canaries on this |
| @@ -1920,19 +1953,112 @@ func bounceFn(svc *activator.Service) func(host string) bool { |
| } |
| // openDeployStore opens the release store the box adopts from: |
| -// CARLOS_DEPLOYMENT_BUCKET (S3) or CARLOS_DEPLOYMENT_DIR (a directory — |
| -// tests and single-box no-AWS deployments). Mirrors cli.go's openStore, but |
| -// returns the error instead of dying: the agent degrades, the CLI exits. |
| +// CARLOS_DEPLOYMENT_BUCKET (S3), CARLOS_DEPLOYMENT_DIR (a directory — tests |
| +// and single-box no-AWS deployments), or — on a fleet box with neither set — |
| +// the edge's store door over the tunnel env (spec F5, openFleetDoorStore). |
| +// Mirrors cli.go's openStore, but returns the error instead of dying: the |
| +// agent degrades, the CLI exits. |
| func openDeployStore(ctx context.Context) (release.Store, error) { |
| - if b := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| + return openDeployStoreEnv(ctx, os.Getenv) |
| +} |
| + |
| +// openDeployStoreEnv is openDeployStore with the environment injected — the |
| +// same getenv seam resolveStatusBucket already uses in this file, so the |
| +// selection rule can be tested without mutating process-wide env. |
| +// |
| +// Order is the whole contract (spec F5): a bucket or dir still set WINS, |
| +// before fleet identity is even consulted, so every platform box and every |
| +// grandfathered fleet box that still has bucket credentials keeps exactly |
| +// today's store, byte for byte. Only a box that resolves a fleet identity |
| +// AND has no bucket/dir at all reaches the door — that is the cutover |
| +// switch, per box, no flag day: drop the bucket var and the box is on the |
| +// API; put it back and it is off. |
| +func openDeployStoreEnv(ctx context.Context, getenv func(string) string) (release.Store, error) { |
| + if b := getenv("CARLOS_DEPLOYMENT_BUCKET"); b != "" { |
| return release.NewS3Store(ctx, b) |
| } |
| - if d := os.Getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| + if d := getenv("CARLOS_DEPLOYMENT_DIR"); d != "" { |
| return release.NewDirStore(d), nil |
| } |
| + identity, err := resolveFleetIdentity(getenv) |
| + switch { |
| + case err == nil: |
| + return openFleetDoorStore(getenv, identity) |
| + case !errors.Is(err, errNotFleetBox): |
| + // CARLOS_FLEET is set but the identity is unusable, and there is no |
| + // bucket to fall back to: this box has no store at all. Return the |
| + // identity error itself rather than the generic "no bucket" line — |
| + // the actual fix is the label, and the generic message would send an |
| + // operator hunting for a bucket var that is deliberately absent. |
| + return nil, err |
| + } |
| return nil, errors.New("neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") |
| } |
| +// openFleetDoorStore builds the fleetapi client a bucketless fleet box adopts |
| +// through: the edge origin it already tunnels to (CARLOS_TUNNEL_URL), its |
| +// channel identity ("<fleet>/<label>" — the same string tunnel_client.go |
| +// registers with and edge.fleetChannelFor dials), and the tunnel bearer token. |
| +// |
| +// Missing tunnel env here is an ERROR, never a silent no-store: the caller |
| +// established that this box is a fleet box with no bucket, so there is no |
| +// other store it could possibly be meant to use, and a quiet nil would leave |
| +// every loop in this file reporting "not configured" about a box that is in |
| +// fact configured — just with one variable missing. |
| +func openFleetDoorStore(getenv func(string) string, identity FleetIdentity) (release.Store, error) { |
| + base := strings.TrimSpace(getenv("CARLOS_TUNNEL_URL")) |
| + token := strings.TrimSpace(getenv("CARLOS_TUNNEL_TOKEN")) |
| + name := identity.Fleet + "/" + identity.Label |
| + if base == "" || token == "" { |
| + return nil, fmt.Errorf("fleet box %s has no deployment bucket and cannot reach the store door: both CARLOS_TUNNEL_URL and CARLOS_TUNNEL_TOKEN must be set", name) |
| + } |
| + return fleetapi.New(base, name, token) |
| +} |
| + |
| +// errNoStatusStore is openStatusStore's "this deployment simply hasn't |
| +// configured one" sentinel — distinct from a real construction failure, so |
| +// each loop can keep its own long-standing disabled-log wording for the |
| +// unconfigured case while sharing one selection rule. |
| +var errNoStatusStore = errors.New("no status store configured") |
| + |
| +// openStatusStore resolves the store statusLoop and metricsLoop publish |
| +// through, plus a human phrase naming where that is for their armed log |
| +// lines. Same F5 rule as openDeployStoreEnv, read through |
| +// resolveStatusBucket: an explicit CARLOS_STATUS_BUCKET (else the |
| +// CARLOS_DEPLOYMENT_BUCKET fallback) WINS and keeps direct S3; only a fleet |
| +// box with neither set speaks the door. |
| +// |
| +// LEDGER NOTE (spec F5): the door serves the DEPLOYMENT store. A fleet box |
| +// that sets CARLOS_STATUS_BUCKET to some OTHER bucket therefore keeps direct |
| +// S3 for status/metrics while its releases may come through the door — that |
| +// is the bucket-set-wins rule doing its job, not a bug: an operator who named |
| +// a bucket explicitly gets that bucket. |
| +// |
| +// The CARLOS_STATUS_PREFIX rebase both loops apply (release.NewPrefixStore) |
| +// is deliberately NOT applied here — it stays at each call site, unchanged, |
| +// and wraps whichever store this returns. |
| +func openStatusStore(ctx context.Context, getenv func(string) string) (release.Store, string, error) { |
| + if bucket := resolveStatusBucket(getenv); bucket != "" { |
| + s, err := release.NewS3Store(ctx, bucket) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + return s, "bucket " + bucket, nil |
| + } |
| + identity, err := resolveFleetIdentity(getenv) |
| + switch { |
| + case err == nil: |
| + s, derr := openFleetDoorStore(getenv, identity) |
| + if derr != nil { |
| + return nil, "", derr |
| + } |
| + return s, "the fleet store door as " + identity.Fleet + "/" + identity.Label, nil |
| + case !errors.Is(err, errNotFleetBox): |
| + return nil, "", err |
| + } |
| + return nil, "", errNoStatusStore |
| +} |
| + |
| // RunAdopt is the `carlos adopt` verb: one immediate adoption pass — the |
| // carlos-adopt.timer unit's ExecStart, and an operator's "converge now". |
| // Runs as root (see carlos-adopt.service): this is the ONLY place the |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index 00917f7a4..33b06e9a9 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -7,12 +7,15 @@ import ( |
| "encoding/base64" |
| "errors" |
| "log" |
| + "net/http" |
| + "net/http/httptest" |
| "os" |
| "path/filepath" |
| "runtime" |
| "slices" |
| "sort" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -1198,3 +1201,353 @@ func TestFastSweepBouncesAWarmHibernatingCanary(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- store selection: bucket, dir, or the fleet store door (spec F5) ------- |
| + |
| +// doorHit is one request the fake door saw, reduced to the parts these tests |
| +// reason about: which call it was, and the two headers that prove WHICH box |
| +// made it (fleetapi sends the tunnel token and channel name on every request). |
| +type doorHit struct{ Method, Path, Auth, Name string } |
| + |
| +// doorFake stands in for the edge's store door. It answers the single GET |
| +// these tests drive and records every request, so a test can assert both |
| +// halves of the F5 rule: that a bucketless fleet box's store really does |
| +// speak to the door under its channel identity, and — the regression pin that |
| +// matters more — that a bucket-configured box never sends it anything. |
| +type doorFake struct { |
| + url string |
| + mu sync.Mutex |
| + hits []doorHit |
| +} |
| + |
| +func newDoorFake(t *testing.T) *doorFake { |
| + t.Helper() |
| + d := &doorFake{} |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + d.mu.Lock() |
| + d.hits = append(d.hits, doorHit{r.Method, r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Carlos-Tunnel-Name")}) |
| + d.mu.Unlock() |
| + if r.Method == http.MethodGet && r.URL.Path == "/store/o/control/serial" { |
| + w.Header().Set("ETag", `"e1"`) |
| + _, _ = w.Write([]byte("42")) |
| + return |
| + } |
| + http.NotFound(w, r) |
| + })) |
| + t.Cleanup(srv.Close) |
| + d.url = srv.URL |
| + return d |
| +} |
| + |
| +func (d *doorFake) seen() []doorHit { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + return append([]doorHit(nil), d.hits...) |
| +} |
| + |
| +// fleetDoorEnv is the env a fleet box that has been CUT OVER carries: fleet |
| +// identity plus tunnel address/credential, and deliberately no bucket and no |
| +// AWS credentials at all — the whole point of the door. |
| +func fleetDoorEnv(doorURL string) map[string]string { |
| + return map[string]string{ |
| + "CARLOS_FLEET": "pi", |
| + "CARLOS_FLEET_BOX": "pi-box", |
| + "CARLOS_TUNNEL_URL": doorURL, |
| + "CARLOS_TUNNEL_TOKEN": "tok-123", |
| + } |
| +} |
| + |
| +// awsEnvForS3Store makes release.NewS3Store constructible in a test without |
| +// network or a developer's real profile — the same shape internal/release's |
| +// own S3 tests use. Nothing is ever called on the resulting client here; these |
| +// tests only assert which store TYPE the selection built. |
| +func awsEnvForS3Store(t *testing.T) { |
| + 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", "eu-west-1") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| +} |
| + |
| +// TestOpenDeployStoreSelection pins spec F5's whole rule for the store every |
| +// converge loop on the box reads through. The migration fallback is the |
| +// load-bearing half: a bucket or dir still set must win OUTRIGHT, before fleet |
| +// identity is even consulted, or rolling this branch out would silently move |
| +// every already-working fleet box onto a door it may not be able to reach. |
| +func TestOpenDeployStoreSelection(t *testing.T) { |
| + ctx := context.Background() |
| + dir := t.TempDir() |
| + |
| + t.Run("fleet box with no bucket speaks the door", func(t *testing.T) { |
| + door := newDoorFake(t) |
| + s, err := openDeployStoreEnv(ctx, envFunc(fleetDoorEnv(door.url))) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + body, etag, err := s.Get(ctx, "control/serial") |
| + if err != nil { |
| + t.Fatalf("Get through the door: %v", err) |
| + } |
| + if string(body) != "42" || etag != `"e1"` { |
| + t.Fatalf("Get = %q, %q; want \"42\", \"e1\"", body, etag) |
| + } |
| + hits := door.seen() |
| + if len(hits) != 1 { |
| + t.Fatalf("door saw %d requests, want 1: %+v", len(hits), hits) |
| + } |
| + if hits[0].Path != "/store/o/control/serial" { |
| + t.Fatalf("door path = %q, want /store/o/control/serial", hits[0].Path) |
| + } |
| + // The identity on the wire is the channel identity the edge dials — |
| + // "<fleet>/<label>", never the hashed status pseudonym. |
| + if hits[0].Name != "pi/pi-box" { |
| + t.Fatalf("Carlos-Tunnel-Name = %q, want pi/pi-box", hits[0].Name) |
| + } |
| + if hits[0].Auth != "Bearer tok-123" { |
| + t.Fatalf("Authorization = %q, want the tunnel token", hits[0].Auth) |
| + } |
| + }) |
| + |
| + t.Run("dir set wins on a fleet box, door untouched", func(t *testing.T) { |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_DEPLOYMENT_DIR"] = dir |
| + s, err := openDeployStoreEnv(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + if _, ok := s.(*release.DirStore); !ok { |
| + t.Fatalf("store = %T, want *release.DirStore — a configured dir must win", s) |
| + } |
| + if _, _, err := s.Get(ctx, "control/serial"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get = %v, want the dir store's not-exist", err) |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests — a dir-configured box must never reach it", hits) |
| + } |
| + }) |
| + |
| + t.Run("bucket set wins on a fleet box, door untouched", func(t *testing.T) { |
| + awsEnvForS3Store(t) |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_DEPLOYMENT_BUCKET"] = "carlos-deploy" |
| + s, err := openDeployStoreEnv(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + if _, ok := s.(*release.S3Store); !ok { |
| + t.Fatalf("store = %T, want *release.S3Store — a configured bucket must win", s) |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests — a bucket-configured box must never reach it", hits) |
| + } |
| + }) |
| + |
| + t.Run("platform box is unchanged", func(t *testing.T) { |
| + s, err := openDeployStoreEnv(ctx, envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": dir})) |
| + if err != nil { |
| + t.Fatalf("openDeployStoreEnv: %v", err) |
| + } |
| + if _, ok := s.(*release.DirStore); !ok { |
| + t.Fatalf("store = %T, want *release.DirStore", s) |
| + } |
| + _, err = openDeployStoreEnv(ctx, envFunc(map[string]string{})) |
| + if err == nil || !strings.Contains(err.Error(), "neither CARLOS_DEPLOYMENT_BUCKET nor CARLOS_DEPLOYMENT_DIR is set") { |
| + t.Fatalf("unconfigured platform box err = %v, want today's message verbatim", err) |
| + } |
| + }) |
| + |
| + t.Run("fleet box with no bucket and no tunnel env fails loudly", func(t *testing.T) { |
| + for _, missing := range []string{"CARLOS_TUNNEL_URL", "CARLOS_TUNNEL_TOKEN"} { |
| + env := fleetDoorEnv("https://tunnel.example.com") |
| + delete(env, missing) |
| + _, err := openDeployStoreEnv(ctx, envFunc(env)) |
| + if err == nil { |
| + t.Fatalf("missing %s: want an error, got a store", missing) |
| + } |
| + if !strings.Contains(err.Error(), "pi/pi-box") || !strings.Contains(err.Error(), missing) { |
| + t.Fatalf("missing %s: err = %v, want it to name the box and the variable", missing, err) |
| + } |
| + // The token must never be quoted back into an error a log line or |
| + // a status page could carry. |
| + if strings.Contains(err.Error(), "tok-123") { |
| + t.Fatalf("err leaks the tunnel token: %v", err) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("unusable fleet identity with no bucket names the identity", func(t *testing.T) { |
| + _, err := openDeployStoreEnv(ctx, envFunc(map[string]string{ |
| + "CARLOS_FLEET": "pi", |
| + "CARLOS_TUNNEL_NAME": "legacy-box", // no "pi/" prefix: underivable label |
| + "CARLOS_TUNNEL_URL": "https://tunnel.example.com", |
| + "CARLOS_TUNNEL_TOKEN": "tok-123", |
| + })) |
| + if err == nil || !strings.Contains(err.Error(), "CARLOS_TUNNEL_NAME") { |
| + t.Fatalf("err = %v, want the identity error, not the generic no-bucket line", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestOpenStatusStoreSelection pins the same rule for the status/metrics |
| +// store, including the ledger call it makes deliberately: the door serves the |
| +// DEPLOYMENT store, so a fleet box that names its own CARLOS_STATUS_BUCKET |
| +// keeps direct S3 for status — bucket-set-wins, on purpose. |
| +func TestOpenStatusStoreSelection(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("fleet box with no bucket speaks the door", func(t *testing.T) { |
| + door := newDoorFake(t) |
| + s, target, err := openStatusStore(ctx, envFunc(fleetDoorEnv(door.url))) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, "control/serial"); err != nil { |
| + t.Fatalf("Get through the door: %v", err) |
| + } |
| + if hits := door.seen(); len(hits) != 1 || hits[0].Name != "pi/pi-box" { |
| + t.Fatalf("door hits = %+v, want one request as pi/pi-box", hits) |
| + } |
| + if !strings.Contains(target, "pi/pi-box") { |
| + t.Fatalf("armed-log target = %q, want it to name the door identity", target) |
| + } |
| + }) |
| + |
| + t.Run("status bucket set on a fleet box keeps direct S3", func(t *testing.T) { |
| + awsEnvForS3Store(t) |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_STATUS_BUCKET"] = "carlos-status" |
| + s, target, err := openStatusStore(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + if _, ok := s.(*release.S3Store); !ok { |
| + t.Fatalf("store = %T, want *release.S3Store", s) |
| + } |
| + if target != "bucket carlos-status" { |
| + t.Fatalf("target = %q, want %q", target, "bucket carlos-status") |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests", hits) |
| + } |
| + }) |
| + |
| + t.Run("deployment bucket fallback still applies", func(t *testing.T) { |
| + awsEnvForS3Store(t) |
| + door := newDoorFake(t) |
| + env := fleetDoorEnv(door.url) |
| + env["CARLOS_DEPLOYMENT_BUCKET"] = "carlos-deploy" |
| + s, target, err := openStatusStore(ctx, envFunc(env)) |
| + if err != nil { |
| + t.Fatalf("openStatusStore: %v", err) |
| + } |
| + if _, ok := s.(*release.S3Store); !ok { |
| + t.Fatalf("store = %T, want *release.S3Store", s) |
| + } |
| + if target != "bucket carlos-deploy" { |
| + t.Fatalf("target = %q, want %q", target, "bucket carlos-deploy") |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero requests", hits) |
| + } |
| + }) |
| + |
| + t.Run("platform box with no bucket stays unconfigured", func(t *testing.T) { |
| + _, _, err := openStatusStore(ctx, envFunc(map[string]string{"CARLOS_DEPLOYMENT_DIR": t.TempDir()})) |
| + if !errors.Is(err, errNoStatusStore) { |
| + t.Fatalf("err = %v, want errNoStatusStore — a DIR deployment publishes no status", err) |
| + } |
| + }) |
| + |
| + t.Run("fleet box with no bucket and no tunnel env fails loudly", func(t *testing.T) { |
| + env := fleetDoorEnv("https://tunnel.example.com") |
| + delete(env, "CARLOS_TUNNEL_TOKEN") |
| + _, _, err := openStatusStore(ctx, envFunc(env)) |
| + if err == nil || errors.Is(err, errNoStatusStore) { |
| + t.Fatalf("err = %v, want a loud construction error, not the unconfigured sentinel", err) |
| + } |
| + }) |
| +} |
| + |
| +// TestRetentionLoopIsAPlatformBoxJob pins spec F6. A customer's box pruning |
| +// the PLATFORM's releases was wrong under direct S3 and is refused outright |
| +// through the door (its write ACL scopes a fleet to fleets/<fleet>/**), so a |
| +// fleet box must not arm the loop at all — one log line, then out, before it |
| +// ever opens a store. |
| +func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) { |
| + const skipLine = "agent: retention loop is a platform-box job — skipped on a fleet box (spec F6)" |
| + |
| + t.Run("fleet box skips before arming", func(t *testing.T) { |
| + logs := captureLog(t) |
| + door := newDoorFake(t) |
| + for k, v := range fleetDoorEnv(door.url) { |
| + t.Setenv(k, v) |
| + } |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + |
| + retentionLoop(nil) // must return; a fleet box never reaches the loop |
| + |
| + if got := logs(); !strings.Contains(got, skipLine) { |
| + t.Fatalf("log = %q, want the F6 skip line verbatim", got) |
| + } |
| + if hits := door.seen(); len(hits) != 0 { |
| + t.Fatalf("door saw %+v, want zero — the skip must land BEFORE the store opens", hits) |
| + } |
| + }) |
| + |
| + t.Run("platform box still arms", func(t *testing.T) { |
| + pub, _, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + t.Setenv("CARLOS_FLEET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| + |
| + // retentionLoop never returns once armed, so the arming log line is |
| + // what proves it: run it in its own goroutine (it parks in a |
| + // time.Sleep for a minute, long past the end of this test) against a |
| + // synchronized sink, and wait for the line to appear. |
| + var mu sync.Mutex |
| + var buf bytes.Buffer |
| + old, flags := log.Writer(), log.Flags() |
| + log.SetOutput(writerFunc(func(p []byte) (int, error) { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + return buf.Write(p) |
| + })) |
| + log.SetFlags(0) |
| + t.Cleanup(func() { log.SetOutput(old); log.SetFlags(flags) }) |
| + |
| + go retentionLoop(nil) |
| + |
| + deadline := time.Now().Add(5 * time.Second) |
| + for { |
| + mu.Lock() |
| + got := buf.String() |
| + mu.Unlock() |
| + if strings.Contains(got, "agent: retention loop armed") { |
| + if strings.Contains(got, skipLine) { |
| + t.Fatalf("platform box took the fleet skip: %q", got) |
| + } |
| + return |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("retention loop never armed on a platform box; log = %q", got) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + }) |
| +} |
| + |
| +// writerFunc adapts a func to io.Writer — the log sink above needs its own |
| +// mutex because the loop under test keeps running (and may keep logging) |
| +// after the test returns, which a bare bytes.Buffer would race on. |
| +type writerFunc func(p []byte) (int, error) |
| + |
| +func (f writerFunc) Write(p []byte) (int, error) { return f(p) } |
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go| index 140b69dd8..9e0cb945a 100644 |
| --- a/internal/host/metrics_loop.go |
| +++ b/internal/host/metrics_loop.go |
| @@ -740,14 +740,16 @@ func metricsLoop(execRoot string) { |
| }() |
| ctx := context.Background() |
| - bucket := resolveStatusBucket(os.Getenv) |
| - if bucket == "" { |
| - log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| - return |
| - } |
| - raw, err := release.NewS3Store(ctx, bucket) |
| + // Same selection rule statusLoop uses, in one place for both (agent.go's |
| + // openStatusStore): a status/deployment bucket wins and keeps direct S3; |
| + // a fleet box with neither set publishes through the edge's store door. |
| + raw, target, err := openStatusStore(ctx, os.Getenv) |
| if err != nil { |
| - log.Printf("agent: metrics loop disabled: %v", err) |
| + if errors.Is(err, errNoStatusStore) { |
| + log.Printf("agent: metrics loop disabled: neither CARLOS_STATUS_BUCKET nor CARLOS_DEPLOYMENT_BUCKET is set") |
| + } else { |
| + log.Printf("agent: metrics loop disabled: %v", err) |
| + } |
| return |
| } |
| // s is what metricsFlush/alertTick actually read/write through: the same |
| @@ -769,7 +771,7 @@ func metricsLoop(execRoot string) { |
| logRejectedAlertOverrides(os.Getenv) |
| webhookURL := os.Getenv("CARLOS_ALERT_WEBHOOK") |
| - log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket) |
| + log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to %s", target) |
| sm := &sampler{} |
| probes := realSampleProbes(execRoot) |