Scope each reconcile pass's removal authority to its own kind
5 files changed,
+1016
−23
internal/host/agent.go+20 −4internal/host/agent_test.go+38 −0internal/provision/reconcile.go+61 −19internal/provision/sidecar_reconcile.go+462 −0internal/provision/sidecar_reconcile_test.go+435 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a3b837067..4cfbaf395 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -694,14 +694,30 @@ func syncExecSerialized(ctx context.Context, reg *registry.Registry, store relea |
| // no Reconcile and is not this mutex's problem. |
| var reconcileMu sync.Mutex |
| -// reconcileSerialized is the one way any agent loop runs provision.Reconcile |
| -// — the mutex lives in a shared wrapper, not at the call sites, so a future |
| -// third caller cannot forget it. Pinned by |
| +// reconcileSerialized is the one way any agent loop runs the record-family |
| +// reconcile passes — the mutex lives in a shared wrapper, not at the call |
| +// sites, so a future third caller cannot forget it. Pinned by |
| // TestReconcileSerializedHoldsTheMutex. |
| +// |
| +// BOTH families run here, under ONE hold of the mutex: the instance pass and |
| +// the sidecar pass. Each pass's removal phase is scoped to its own route Kind |
| +// (provision.ReconcileSidecars' doc comment), so they cannot delete each |
| +// other's rows — but they read and write the same registry, and the |
| +// overlapping-pass hazard the mutex exists for (a route minted by a newer |
| +// pass looking exactly like a retired record to an older one) applies within |
| +// each family just as much. Running them as one serialized unit also means a |
| +// caller cannot converge instances while leaving sidecars a tick behind. |
| +// |
| +// Errors are JOINED rather than short-circuited: a sidecar-side failure must |
| +// not hide an instance-side one, and neither must stop the other running — |
| +// each pass is independently safe to run and independently valuable. |
| func reconcileSerialized(ctx context.Context, s release.Store, reg *registry.Registry, cfg provision.ReconcileConfig) error { |
| reconcileMu.Lock() |
| defer reconcileMu.Unlock() |
| - return provision.Reconcile(ctx, s, reg, cfg) |
| + return errors.Join( |
| + provision.Reconcile(ctx, s, reg, cfg), |
| + provision.ReconcileSidecars(ctx, s, reg, cfg), |
| + ) |
| } |
| // bounce builds the restart callback SyncExec calls for a route whose |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index a21ecdabe..4501821dc 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -22,6 +22,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -1054,6 +1055,43 @@ func TestReconcileSerializedHoldsTheMutex(t *testing.T) { |
| } |
| } |
| +// TestReconcileSerializedRunsBothRecordFamilies pins the wiring: the sidecar |
| +// pass runs from the SAME serialized entry point as the instance pass, under |
| +// the same mutex hold. Wired anywhere else it would either race the instance |
| +// pass over one registry, or (worse) be forgotten by one of the two loops and |
| +// converge only every five minutes — or never. |
| +func TestReconcileSerializedRunsBothRecordFamilies(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + sqid := domains.Sqid(2) |
| + manifest := []byte(`{"id":2,"sqid":"` + sqid + `","name":"Eleven","created_at":"2026-08-02T09:00:00Z"}`) |
| + if _, err := s.Put(ctx, "accounts/"+sqid+"/account.json", manifest, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.PutSidecarManifest(ctx, s, sqid, "eleven", provision.SidecarManifest{ |
| + Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Channel: "stable"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + host := provision.SidecarHost("bot", "eleven", sqid) |
| + if err := provision.CreateSidecarRecord(ctx, s, provision.SidecarRecord{ |
| + Schema: 1, Name: "bot", Host: host, App: "eleven", Account: sqid, Channel: "stable"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + reg := restartTestRegistry(t) |
| + if err := reconcileSerialized(ctx, s, reg, provision.ReconcileConfig{DataDir: t.TempDir()}); err != nil { |
| + t.Fatalf("reconcileSerialized: %v", err) |
| + } |
| + |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt == nil { |
| + t.Fatal("no sidecar route minted — the sidecar pass must run from reconcileSerialized, beside the instance pass and inside its mutex") |
| + } |
| +} |
| + |
| // TestFastTickActsOnSerialChangeNotBoot pins seed-don't-replay, in both of |
| // its halves. An ABSENT serial object seeds at zero — absence is not |
| // history, there is nothing to replay, and every production bucket starts |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 71ee5a3b7..fee9be573 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -39,6 +39,13 @@ type ReconcileConfig struct { |
| DataDir string |
| // Now is the clock seam; nil = time.Now. |
| Now func() time.Time |
| + // Inbound is this box's inbound-work declaration (CARLOS_INBOUND, design |
| + // §8). "" and InboundAny are today's behaviour exactly — an inert |
| + // default, the apps-domain-style gate this repo already uses — and |
| + // InboundNone means the box serves sidecar work only: no certs, no |
| + // inbound ports, possibly a private subnet with egress-only networking. |
| + // Read by ReconcileSidecars' placement gate. |
| + Inbound string |
| // DryRun reports what the pass would do and writes nothing — not to the |
| // registry, not anywhere. The backfill mitigation (design §4.3): a |
| // production route is adopted only after someone has read what adopting |
| @@ -47,6 +54,16 @@ type ReconcileConfig struct { |
| DryRun bool |
| } |
| +// InboundAny and InboundNone are the closed vocabulary ReconcileConfig.Inbound |
| +// is read against — the BOX's half of design §8's two independent halves (the |
| +// record's half is PlacementAny/PlacementNoInbound). "" reads as InboundAny: |
| +// a box that has not been told is a box that serves, which is every box that |
| +// exists today. |
| +const ( |
| + InboundAny = "any" |
| + InboundNone = "none" |
| +) |
| + |
| // acctApp identifies one app within one account — the unit both the record |
| // scan and the manifest/seed cache below key on, since app names are unique |
| // only within an account, never globally. |
| @@ -478,25 +495,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // minting is the reversible direction, and a bucket writer who could |
| // plant a marker to stall minting can already retire the instance |
| // outright by deleting its record. |
| - trashCache := map[acctApp]bool{} |
| - trashSeen := map[acctApp]bool{} |
| - trashedFor := func(acct, app string) bool { |
| - k := acctApp{acct, app} |
| - if trashSeen[k] { |
| - return trashCache[k] |
| - } |
| - trashSeen[k] = true |
| - tomb, err := release.ReadAppTombstone(ctx, s, acct, app) |
| - switch { |
| - case err != nil: |
| - log.Printf("provision: trash marker for %s/%s unreadable: %v — not minting for this app", acct, app, err) |
| - trashCache[k] = true |
| - case tomb != nil: |
| - log.Printf("provision: %s/%s is in the trash — not minting instance routes for it", acct, app) |
| - trashCache[k] = true |
| - } |
| - return trashCache[k] |
| - } |
| + trashedFor := trashGate(ctx, s, "instance") |
| // --- mint / converge / conflict ------------------------------------------- |
| routes, err := reg.List() |
| @@ -650,6 +649,17 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // the adoption guard is not mistaken for a standing guarantee. |
| if !readFailed { |
| for _, rt := range routes { |
| + // Removal authority is scoped to this pass's OWN kind. A sidecar |
| + // row is Provisioned too — that flag is what grants a pass removal |
| + // authority — and it can never appear in the INSTANCE record set, |
| + // so without this guard the two passes would delete each other's |
| + // rows on every tick, forever, with no error anywhere and nothing |
| + // in the logs that reads as a failure. ReconcileSidecars carries |
| + // the mirror of this guard; both directions are asserted |
| + // (TestInstanceReconcileNeverRemovesSidecarRows). |
| + if rt.Kind == SidecarKind { |
| + continue |
| + } |
| if !rt.Provisioned || rt.Region != cfg.Region || !poolServed(cfg.Pools, rt.Pool) { |
| continue |
| } |
| @@ -722,6 +732,38 @@ func knownAccounts(ctx context.Context, s release.Store) (known map[string]bool, |
| return known, readFailed, nil |
| } |
| +// trashGate builds the per-(acct, app) "is this app in the trash" predicate |
| +// both record passes gate MINTING on, caching each answer for the pass. what |
| +// names the route family in the log line ("instance", "sidecar") and is the |
| +// only thing that differs between the two callers — one implementation, so |
| +// the two families cannot come to disagree about what a trash marker means. |
| +// |
| +// The predicate answers TRUE for an unreadable or malformed marker as well as |
| +// a valid one: not minting is the reversible direction, and a bucket writer |
| +// who could plant a marker to stall minting can already retire the record |
| +// outright. |
| +func trashGate(ctx context.Context, s release.Store, what string) func(acct, app string) bool { |
| + cache := map[acctApp]bool{} |
| + seen := map[acctApp]bool{} |
| + return func(acct, app string) bool { |
| + k := acctApp{acct, app} |
| + if seen[k] { |
| + return cache[k] |
| + } |
| + seen[k] = true |
| + tomb, err := release.ReadAppTombstone(ctx, s, acct, app) |
| + switch { |
| + case err != nil: |
| + log.Printf("provision: trash marker for %s/%s unreadable: %v — not minting for this app", acct, app, err) |
| + cache[k] = true |
| + case tomb != nil: |
| + log.Printf("provision: %s/%s is in the trash — not minting %s routes for it", acct, app, what) |
| + cache[k] = true |
| + } |
| + return cache[k] |
| + } |
| +} |
| + |
| // poolServed reports whether pool is one this box serves. An empty |
| // cfg.Pools means the shared "" pool ONLY, never "every pool" — the same |
| // convention domains.Config's AppsDomain=="" carries: unconfigured means the |
diff --git a/internal/provision/sidecar_reconcile.go b/internal/provision/sidecar_reconcile.go| new file mode 100644 |
| index 000000000..c833986a0 |
| --- /dev/null |
| +++ b/internal/provision/sidecar_reconcile.go |
| @@ -0,0 +1,462 @@ |
| +package provision |
| + |
| +// ReconcileSidecars turns bucket SIDECAR records into registry routes, for |
| +// THIS agent's own region, pool set and inbound posture. Spec: |
| +// docs/superpowers/specs/2026-08-17-sidecar-members-design.md §4, §8. |
| +// |
| +// It is Reconcile's twin, one record family over, and it is deliberately a |
| +// separate pass rather than a widening of that one: the two families answer |
| +// different questions about a box (can it SERVE vs may it RUN), and a single |
| +// loop would have had to carry a Kind branch through every phase — including |
| +// the removal phase, where a missed branch deletes production rows. |
| +// |
| +// Which makes the collision this file exists to close worth stating plainly. |
| +// Reconcile's removal phase is scoped by Provisioned, region, pool and |
| +// absence-from-records. A sidecar row is Provisioned too (that flag is what |
| +// grants a pass removal authority over a row), and it can never appear in the |
| +// INSTANCE record set — so, unguarded, that pass deletes every sidecar row on |
| +// every tick: minted by this pass, destroyed by that one, forever, with no |
| +// error anywhere and nothing in the logs that reads as a failure. Each pass's |
| +// removal authority is therefore scoped to its OWN Kind, asserted from both |
| +// sides (TestInstanceReconcileNeverRemovesSidecarRows, |
| +// TestSidecarReconcileRemovesOnlyItsOwnKind). |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// SidecarKind is the route Kind every sidecar row carries — the whole basis |
| +// of each pass's removal authority, and the value the edge refuses outright. |
| +// A literal in two passes' removal conditions is a typo away from a fleet |
| +// deletion, so both read this constant. |
| +const SidecarKind = "sidecar" |
| + |
| +// sidecarCoSpawnSuffix names the LEGACY, file-staged co-spawn: a sidecar |
| +// attached to a host by dropping <DataDir>/<host>-sidecar.env |
| +// (internal/activator/backend_exec.go). It is legacy but LIVE — Eleven runs |
| +// on it today — so a host that already has one is refused a native sidecar |
| +// row. One host, one mechanism. |
| +const sidecarCoSpawnSuffix = "-sidecar.env" |
| + |
| +// sidecarArgsJSON renders a manifest's argv as the wire text registry.Route's |
| +// Args column carries: "" for nil/empty (no argv), else the JSON array. Same |
| +// shape and same reason as seedJSON — json.Marshal of a nil slice is "null", |
| +// which is neither empty nor a usable argv. |
| +func sidecarArgsJSON(m SidecarManifest) string { |
| + if len(m.Args) == 0 { |
| + return "" |
| + } |
| + b, err := json.Marshal(m.Args) // []string always marshals; err is unreachable |
| + if err != nil { |
| + return "" |
| + } |
| + return string(b) |
| +} |
| + |
| +// sidecarChannel resolves the channel a sidecar's row is pinned to: the |
| +// record's own if it states one, else the app manifest's. The record wins |
| +// because it is the per-sidecar authorization; the manifest is the app-wide |
| +// default a record may simply not repeat. |
| +func sidecarChannel(rec SidecarRecord, m SidecarManifest) string { |
| + if rec.Channel != "" { |
| + return rec.Channel |
| + } |
| + return m.Channel |
| +} |
| + |
| +// sidecarPlacement resolves the placement policy a record is placed under |
| +// (design §8). The record wins when it states one — it is the workload's own |
| +// requirement — and the manifest's PlacementOrDefault answers otherwise, so |
| +// an app-wide "everything I run must sit somewhere with no inbound path" |
| +// holds for records that never mention placement. |
| +func sidecarPlacement(rec SidecarRecord, m SidecarManifest) string { |
| + if rec.Placement != "" { |
| + return rec.Placement |
| + } |
| + return m.PlacementOrDefault() |
| +} |
| + |
| +// sidecarRouteFor is the one answer to "what route does this sidecar record |
| +// describe" — the mint builds from it and the converge branch compares |
| +// against it, so a shape this pass would mint and a shape it calls converged |
| +// can never drift apart (routeFor's rule, same reason). |
| +// |
| +// Socket and Addr are deliberately left EMPTY, and that is the whole |
| +// reachability story: a sidecar binds nothing and nothing dials it. The edge |
| +// refuses Kind == "sidecar" outright (design §6.2) rather than relying on |
| +// this omission, but the row must not carry an address for any future arm to |
| +// find either. |
| +// |
| +// DBPath, by contrast, IS derived: a sidecar gets a database like any other |
| +// tenant, and a hibernating route whose row carried no path would be swept up |
| +// by ops.FleetDBs' dataDir glob and replicated box-wide as if it never slept |
| +// (routeFor's comment records what that costs). |
| +// |
| +// The ceilings are materialized onto the row from the manifest precisely so |
| +// the activator's sweeper never needs a bucket read to decide a worker has |
| +// outrun its limits. |
| +func sidecarRouteFor(rec SidecarRecord, m SidecarManifest, dataDir string) registry.Route { |
| + return registry.Route{ |
| + Host: rec.Host, Slug: rec.Name, Kind: SidecarKind, |
| + App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| + Channel: sidecarChannel(rec, m), Environment: rec.Environment, |
| + DBPath: DefaultDBPath(dataDir, rec.Host), |
| + Hibernate: m.HibernateOrDefault(), |
| + Args: sidecarArgsJSON(m), |
| + IdleSeconds: int(m.IdleOrDefault() / time.Second), |
| + MaxRunSeconds: int(m.MaxRunOrDefault() / time.Second), |
| + Provisioned: true, |
| + Status: "ready", |
| + } |
| +} |
| + |
| +// coSpawnStaged reports whether host already has a legacy file-staged |
| +// co-spawn in dataDir. A read error other than "not there" answers TRUE: not |
| +// minting is the reversible direction, and a host whose staging state cannot |
| +// be established is exactly the host a second mechanism must not be attached |
| +// to. |
| +func coSpawnStaged(dataDir, host string) bool { |
| + _, err := os.Stat(filepath.Join(dataDir, host+sidecarCoSpawnSuffix)) |
| + return err == nil || !errors.Is(err, os.ErrNotExist) |
| +} |
| + |
| +// ReconcileSidecars materializes bucket sidecar records into registry routes |
| +// for this box's region, pools and inbound posture: mint on appearance, |
| +// converge the manifest-owned columns, remove on record deletion — removal |
| +// only for Kind "sidecar" routes this pass (or an earlier one running the |
| +// same scope) minted, marked by Provisioned. |
| +// |
| +// Per-item failures log and skip. A failed account-manifest or record read |
| +// additionally disables the removal phase for the WHOLE pass: a transient |
| +// read failure must never mass-retire a tenant's fleet. Minting is additive |
| +// and always safe, so it proceeds regardless. |
| +// |
| +// Three states decline to MINT while leaving the record in the pass's record |
| +// set — so removal never fires for them either, because declining to create |
| +// is reversible and deleting a row is not (registry.Delete purges the route's |
| +// capability grants, and nothing else would put the row back): |
| +// |
| +// - the app is in the trash (domains.Reconcile drops its routes on its own |
| +// authority; minting here would re-raise them every tick), |
| +// - the app's sidecars manifest is absent or unreadable (no argv to run, |
| +// and no ceilings to stamp), |
| +// - the record's placement is not one this box satisfies (design §8). |
| +func ReconcileSidecars(ctx context.Context, s release.Store, reg *registry.Registry, cfg ReconcileConfig) error { |
| + if cfg.Now == nil { |
| + cfg.Now = time.Now |
| + } |
| + |
| + known, readFailed, err := knownAccounts(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + // --- collect in-scope records -------------------------------------------- |
| + keys, err := s.List(ctx, "apps/") |
| + if err != nil { |
| + return fmt.Errorf("provision: list apps: %w", err) |
| + } |
| + recs := map[string]SidecarRecord{} // host -> record, in-scope only |
| + // protect holds hosts whose record KEY parsed but whose body could not be |
| + // trusted — unparseable JSON, a body disagreeing with its own path, or a |
| + // shape this binary refuses. Such a host must NEVER read as "the bucket |
| + // retired it": a non-additive record encoding change reaching an older |
| + // binary would make EVERY record fail exactly this way, and one tick would |
| + // then delete every sidecar in scope. Narrower than readFailed (which |
| + // vetoes removal for the whole pass): only the unusable host is held back. |
| + protect := map[string]bool{} |
| + for _, key := range keys { |
| + acct, app, name, ok := ParseSidecarKey(key, known) |
| + if !ok { |
| + continue |
| + } |
| + // Derived from the KEY, never from the body: the body is what is |
| + // under suspicion in every branch below. |
| + host := SidecarHost(name, app, acct) |
| + raw, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("provision: skipping sidecar record %s: read failed: %v", key, err) |
| + // Its absence from recs below must not read as "the bucket retired |
| + // it" — a transient Get failure disables removal for the whole |
| + // pass, same as an unreadable account manifest. |
| + readFailed = true |
| + continue |
| + } |
| + var rec SidecarRecord |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + log.Printf("provision: skipping sidecar record %s: malformed: %v", key, err) |
| + protect[host] = true |
| + continue |
| + } |
| + if rec.Name != name || rec.Account != acct || rec.App != app { |
| + log.Printf("provision: skipping sidecar record %s: disagrees with its own path (name=%q account=%q app=%q)", |
| + key, rec.Name, rec.Account, rec.App) |
| + protect[host] = true |
| + continue |
| + } |
| + // ValidateSidecarShape is the console create door's policy check — but |
| + // a door is not a guarantee about what is already in the bucket: |
| + // anything with bucket write access, and any record written by a |
| + // different (older, or future) writer, arrives here unexamined. Both |
| + // doors hold the invariant, neither trusts the other. Protected like a |
| + // corrupted body, and for the same reason: a record that cannot be |
| + // used is not a record that was retired. |
| + if err := ValidateSidecarShape(rec); err != nil { |
| + log.Printf("provision: skipping sidecar record %s: invalid shape: %v", key, err) |
| + protect[host] = true |
| + continue |
| + } |
| + // The host is DERIVED from the identity, never taken on trust: it |
| + // becomes a database path and a registry primary key, and a record |
| + // free to state its own host could name any row on the box. |
| + if rec.Host != host { |
| + log.Printf("provision: skipping sidecar record %s: host %q is not %q", key, rec.Host, host) |
| + protect[host] = true |
| + continue |
| + } |
| + if rec.Region != cfg.Region || !poolServed(cfg.Pools, rec.Pool) { |
| + continue // another agent's jurisdiction, not this pass's business |
| + } |
| + // Two records claiming one host is a collision this pass cannot |
| + // arbitrate silently: whichever this listing reaches FIRST wins, and |
| + // the loser is skipped loudly rather than overwriting the winner in |
| + // the map with no trace of the conflict. |
| + if prev, dup := recs[rec.Host]; dup { |
| + log.Printf("provision: duplicate sidecar host %s: keeping %s, skipping %s", rec.Host, |
| + SidecarRecordKey(prev.Account, prev.App, prev.Name), key) |
| + continue |
| + } |
| + recs[rec.Host] = rec |
| + } |
| + |
| + // --- manifest cache, per (acct, app), lazily ------------------------------ |
| + // known=false covers BOTH "no manifest" and "the read failed", and in both |
| + // cases this pass declines to mint or converge: every manifest-owned |
| + // column (argv, the ceilings, hibernation) would otherwise be stamped from |
| + // a zero value — an empty argv over a working one, on a blip. This is |
| + // seedFor's seedKnown discipline, applied to a family where the manifest |
| + // carries the RUN instruction rather than a seed. |
| + manCache := map[acctApp]SidecarManifest{} |
| + manKnown := map[acctApp]bool{} |
| + manSeen := map[acctApp]bool{} |
| + manifestFor := func(acct, app string) (SidecarManifest, bool) { |
| + k := acctApp{acct, app} |
| + if manSeen[k] { |
| + return manCache[k], manKnown[k] |
| + } |
| + manSeen[k] = true |
| + m, err := GetSidecarManifest(ctx, s, acct, app) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + // Absence IS the answer (design §3.1): sidecars are not |
| + // enabled for this app. Not a read failure — removal stays |
| + // armed for the pass — but the records themselves stay in |
| + // recs, so nothing of theirs is retired on it. |
| + log.Printf("provision: %s/%s has sidecar records but no sidecars manifest — not minting", acct, app) |
| + return SidecarManifest{}, false |
| + } |
| + log.Printf("provision: sidecar manifest %s/%s: read failed: %v", acct, app, err) |
| + readFailed = true |
| + return SidecarManifest{}, false |
| + } |
| + manCache[k], manKnown[k] = *m, true |
| + return manCache[k], manKnown[k] |
| + } |
| + |
| + trashed := trashGate(ctx, s, "sidecar") |
| + |
| + // --- mint / converge ------------------------------------------------------- |
| + routes, err := reg.List() |
| + if err != nil { |
| + return fmt.Errorf("provision: list routes: %w", err) |
| + } |
| + byHost := map[string]registry.Route{} |
| + for _, rt := range routes { |
| + byHost[rt.Host] = rt |
| + } |
| + |
| + for _, rec := range recs { |
| + if trashed(rec.Account, rec.App) { |
| + continue |
| + } |
| + m, mKnown := manifestFor(rec.Account, rec.App) |
| + if !mKnown { |
| + continue |
| + } |
| + // Placement (design §8). "none" is a property of the BOX's network; |
| + // "no-inbound" is a requirement of the WORKLOAD, and the point of the |
| + // two halves being independent is that a member gets a mechanical |
| + // guarantee rather than an operator's promise. |
| + if place := sidecarPlacement(rec, m); place == PlacementNoInbound && cfg.Inbound != InboundNone { |
| + log.Printf("provision: not placing sidecar %s here: it asks for %q placement and this box accepts inbound work (CARLOS_INBOUND=%q)", |
| + rec.Host, place, cfg.Inbound) |
| + continue |
| + } |
| + existing, ok := byHost[rec.Host] |
| + switch { |
| + case !ok: |
| + // One host, one mechanism: a host already carrying the legacy |
| + // file-staged co-spawn must not ALSO get a native sidecar row, or |
| + // the same workload runs twice under two owners that know nothing |
| + // of each other. |
| + if coSpawnStaged(cfg.DataDir, rec.Host) { |
| + log.Printf("provision: REFUSING to mint sidecar %s: %s%s is staged on this box — "+ |
| + "the legacy file co-spawn and a native sidecar row are two mechanisms for one host; "+ |
| + "remove the file to migrate this host onto sidecar records", |
| + rec.Host, rec.Host, sidecarCoSpawnSuffix) |
| + continue |
| + } |
| + nr := sidecarRouteFor(rec, m, cfg.DataDir) |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would mint sidecar route %s (app %q acct %q channel %q args %s)", |
| + rec.Host, rec.App, rec.Account, nr.Channel, nr.Args) |
| + continue |
| + } |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("provision: mint sidecar %s: %v", rec.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: minted sidecar route %s (app %q acct %q args %s)", rec.Host, rec.App, rec.Account, nr.Args) |
| + case existing.Kind == SidecarKind && existing.Provisioned && |
| + existing.App == rec.App && existing.Account == rec.Account: |
| + convergeSidecar(reg, existing, rec, m, cfg) |
| + default: |
| + // A row exists at this host that this pass does not own. There is |
| + // deliberately NO adoption path here, unlike the instance pass: |
| + // the .sidecar.internal suffix is reserved and refused at every |
| + // door a name could enter through, so no hand-added row to |
| + // backfill can legitimately exist — and adoption is an authority |
| + // TRANSFER (it is what grants the removal phase power over a row), |
| + // never a shape coincidence. |
| + log.Printf("provision: %s exists but is not a sidecar row this pass owns (kind %q provisioned %v app %q acct %q) — leaving it alone", |
| + rec.Host, existing.Kind, existing.Provisioned, existing.App, existing.Account) |
| + } |
| + } |
| + |
| + // --- removal, only when nothing failed to read this pass -------------------- |
| + // Four conditions, each load-bearing: this pass's OWN Kind (the guard at |
| + // the top of this file — the instance pass carries its mirror), Provisioned |
| + // (the flag, not the app and not the channel, is what grants removal |
| + // authority; a hand-added row never has it), scope (this box's own |
| + // region/pool jurisdiction), and absence from the record set this pass just |
| + // read (the bucket's own decision to retire the sidecar, not a guess). |
| + if !readFailed { |
| + for _, rt := range routes { |
| + if rt.Kind != SidecarKind || !rt.Provisioned { |
| + continue |
| + } |
| + if rt.Region != cfg.Region || !poolServed(cfg.Pools, rt.Pool) { |
| + continue |
| + } |
| + if _, ok := recs[rt.Host]; ok { |
| + continue |
| + } |
| + if protect[rt.Host] { |
| + log.Printf("provision: not removing sidecar %s: its record exists but could not be read cleanly this pass", rt.Host) |
| + continue |
| + } |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would remove retired sidecar route %s (record gone)", rt.Host) |
| + continue |
| + } |
| + if err := reg.Delete(rt.Host); err != nil { |
| + log.Printf("provision: remove retired sidecar route %s: %v", rt.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: removed retired sidecar route %s (record gone)", rt.Host) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// convergeSidecar applies the columns the record and the manifest are |
| +// authoritative for onto a row this pass owns. Narrow UPDATEs throughout, |
| +// never a re-Upsert: a read-modify-write racing the activator's own writes |
| +// (a worker's reported version, its next-due stamp) would clobber them. |
| +// |
| +// The split of authority is the same one the design states: the RECORD owns |
| +// channel and environment (what this sidecar runs and which config it gets — |
| +// the #130 discipline), the MANIFEST owns argv, the ceilings and hibernation |
| +// (app-wide policy). Both are re-stamped every pass, exactly as an instance's |
| +// seed is, because truth lives in the bucket and a box-local edit to one of |
| +// these columns is drift, not a decision. |
| +// |
| +// Every write logs on success as well as on failure: these are the writes |
| +// that REVERT an operator's own `carlos route` edit, and a row that moved with |
| +// nothing saying so is the failure class behind #130 seen from the outside. |
| +func convergeSidecar(reg *registry.Registry, existing registry.Route, rec SidecarRecord, m SidecarManifest, cfg ReconcileConfig) { |
| + want := sidecarRouteFor(rec, m, cfg.DataDir) |
| + |
| + if want.Channel != "" && existing.Channel != want.Channel { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge sidecar %s channel %s -> %s", rec.Host, existing.Channel, want.Channel) |
| + } else if err := reg.SetChannel(rec.Host, want.Channel); err != nil { |
| + log.Printf("provision: converge channel on sidecar %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged sidecar %s channel %s -> %s", rec.Host, existing.Channel, want.Channel) |
| + } |
| + } |
| + if existing.Environment != want.Environment { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge sidecar %s environment %q -> %q", rec.Host, existing.Environment, want.Environment) |
| + } else if err := reg.SetEnvironment(rec.Host, want.Environment); err != nil { |
| + log.Printf("provision: converge environment on sidecar %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged sidecar %s environment %q -> %q", rec.Host, existing.Environment, want.Environment) |
| + } |
| + } |
| + if existing.Hibernate != want.Hibernate { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge sidecar %s hibernate %v -> %v", rec.Host, existing.Hibernate, want.Hibernate) |
| + } else if err := reg.SetHibernate(rec.Host, want.Hibernate); err != nil { |
| + log.Printf("provision: converge hibernate on sidecar %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged sidecar %s hibernate %v -> %v", rec.Host, existing.Hibernate, want.Hibernate) |
| + } |
| + } |
| + if existing.Args != want.Args { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge sidecar %s args %s -> %s", rec.Host, existing.Args, want.Args) |
| + } else if err := reg.SetArgs(rec.Host, want.Args); err != nil { |
| + log.Printf("provision: converge args on sidecar %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged sidecar %s args %s -> %s", rec.Host, existing.Args, want.Args) |
| + } |
| + } |
| + // One UPDATE for both ceilings, because they are one policy: the idle |
| + // window and the run ceiling together decide when the sweeper stops a |
| + // worker, and a row carrying the new idle under the old maximum (or the |
| + // reverse) is a combination the manifest never stated. |
| + if existing.IdleSeconds != want.IdleSeconds || existing.MaxRunSeconds != want.MaxRunSeconds { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge sidecar %s ceilings %d/%d -> %d/%d", rec.Host, |
| + existing.IdleSeconds, existing.MaxRunSeconds, want.IdleSeconds, want.MaxRunSeconds) |
| + } else if err := reg.SetWorkerLimits(rec.Host, want.IdleSeconds, want.MaxRunSeconds); err != nil { |
| + log.Printf("provision: converge ceilings on sidecar %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged sidecar %s ceilings %d/%d -> %d/%d", rec.Host, |
| + existing.IdleSeconds, existing.MaxRunSeconds, want.IdleSeconds, want.MaxRunSeconds) |
| + } |
| + } |
| + // The columns nothing converges in place: the database path (moving it |
| + // under a running process would point it at a second copy of its own |
| + // data) and the two reachability columns a sidecar row must never carry. |
| + // Reported, never rewritten — `carlos remove` purges a route's capability |
| + // grants, so it is a last resort, not a routine fix. |
| + if existing.DBPath != want.DBPath || existing.Socket != "" || existing.Addr != "" { |
| + log.Printf("provision: sidecar %s: row disagrees with the shape this record describes "+ |
| + "(db %q/%q socket %q addr %q) — not converged in place; a sidecar binds nothing, "+ |
| + "so a socket or addr on this row is a row that was not minted by this pass", |
| + rec.Host, existing.DBPath, want.DBPath, existing.Socket, existing.Addr) |
| + } |
| +} |
diff --git a/internal/provision/sidecar_reconcile_test.go b/internal/provision/sidecar_reconcile_test.go| new file mode 100644 |
| index 000000000..f0a8d7e17 |
| --- /dev/null |
| +++ b/internal/provision/sidecar_reconcile_test.go |
| @@ -0,0 +1,435 @@ |
| +package provision |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// --- fixtures ----------------------------------------------------------------- |
| + |
| +// failStore is the read-failure seam these tests need and no other store |
| +// shape provides: a Store whose Get (and List) refuse a chosen prefix, so a |
| +// pass can be put in exactly the state the fail-safe exists for — a record |
| +// that IS there and could not be read — rather than the state that looks the |
| +// same from the outside and means the opposite (a record the bucket retired). |
| +type failStore struct { |
| + release.Store |
| + fail []string |
| +} |
| + |
| +func (s *failStore) failReadsFor(prefix string) { s.fail = append(s.fail, prefix) } |
| + |
| +func (s *failStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + for _, p := range s.fail { |
| + if strings.HasPrefix(key, p) { |
| + return nil, "", fmt.Errorf("failStore: reads disabled under %s", p) |
| + } |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *failStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + for _, p := range s.fail { |
| + if strings.HasPrefix(prefix, p) { |
| + return nil, fmt.Errorf("failStore: reads disabled under %s", p) |
| + } |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +type sidecarFixture struct { |
| + store *failStore |
| + reg *registry.Registry |
| + dir string |
| + sqid string |
| +} |
| + |
| +// sidecarSetup writes a valid account manifest and hands back a fixture whose |
| +// data dir and registry are empty — the sidecar family's equivalent of |
| +// setup() above, minus the instances manifest (a sidecar app opts in through |
| +// its OWN manifest, sidecars.json). |
| +func sidecarSetup(t *testing.T) *sidecarFixture { |
| + t.Helper() |
| + s := &failStore{Store: dirStore(t)} |
| + sqid := writeAccountManifest(t, s, acctID, "Eleven") |
| + return &sidecarFixture{store: s, reg: regWith(t), dir: t.TempDir(), sqid: sqid} |
| +} |
| + |
| +func (f *sidecarFixture) host(name, app string) string { return SidecarHost(name, app, f.sqid) } |
| + |
| +func (f *sidecarFixture) putManifest(t *testing.T, app string, m SidecarManifest) { |
| + t.Helper() |
| + if err := PutSidecarManifest(ctx, f.store, f.sqid, app, m); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f *sidecarFixture) putRecord(t *testing.T, rec SidecarRecord) { |
| + t.Helper() |
| + if rec.Account == "" { |
| + rec.Account = f.sqid |
| + } |
| + if rec.Host == "" { |
| + rec.Host = SidecarHost(rec.Name, rec.App, rec.Account) |
| + } |
| + if err := CreateSidecarRecord(ctx, f.store, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f *sidecarFixture) mustUpsert(t *testing.T, rt registry.Route) { |
| + t.Helper() |
| + if err := f.reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f *sidecarFixture) reconcile(t *testing.T, cfg ReconcileConfig) { |
| + t.Helper() |
| + cfg.DataDir = f.dir |
| + if err := ReconcileSidecars(ctx, f.store, f.reg, cfg); err != nil { |
| + t.Fatalf("ReconcileSidecars: %v", err) |
| + } |
| +} |
| + |
| +// noRoute asserts a host has no row. registry.Get answers a missing host with |
| +// (nil, nil), so a bare `err == nil` check would pass whether the row is |
| +// there or not. |
| +func noRoute(t *testing.T, reg *registry.Registry, host, msg string) { |
| + t.Helper() |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt != nil { |
| + t.Fatal(msg) |
| + } |
| +} |
| + |
| +func hasRoute(t *testing.T, reg *registry.Registry, host, msg string) *registry.Route { |
| + t.Helper() |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt == nil { |
| + t.Fatal(msg) |
| + } |
| + return rt |
| +} |
| + |
| +// --- the removal-authority collision ------------------------------------------- |
| + |
| +// TestInstanceReconcileNeverRemovesSidecarRows is the guard on the OTHER |
| +// pass. A sidecar row is Provisioned (that flag is what grants a pass removal |
| +// authority) and can never appear in the instance pass's record set, so |
| +// without a Kind guard the instance pass deletes every sidecar row on every |
| +// tick — minted by one pass, destroyed by the other, forever, with no error |
| +// anywhere and nothing in the logs that reads as a failure. |
| +func TestInstanceReconcileNeverRemovesSidecarRows(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: host, Kind: "sidecar", Slug: "bot", |
| + App: "eleven", Account: f.sqid, Provisioned: true, Status: "ready"}) |
| + |
| + // No instance records at all: the pass's removal phase runs in full. |
| + if err := Reconcile(ctx, f.store, f.reg, ReconcileConfig{DataDir: f.dir}); err != nil { |
| + t.Fatalf("Reconcile: %v", err) |
| + } |
| + hasRoute(t, f.reg, host, "the instance pass deleted a sidecar row — each pass's removal authority is scoped to its OWN kind, or the two passes destroy each other's work forever") |
| +} |
| + |
| +// TestSidecarReconcileRemovesOnlyItsOwnKind is the same guard from the other |
| +// side: this pass retires its own kind and must never reach an instance row, |
| +// which can never appear in ITS record set either. |
| +func TestSidecarReconcileRemovesOnlyItsOwnKind(t *testing.T) { |
| + f := sidecarSetup(t) |
| + gone := f.host("gone", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: gone, Kind: "sidecar", Slug: "gone", |
| + App: "eleven", Account: f.sqid, Provisioned: true}) |
| + f.mustUpsert(t, registry.Route{Host: "app.example.com", Kind: "instance", |
| + App: "eleven", Account: f.sqid, Provisioned: true}) |
| + // A sidecar-kind row nothing provisioned: Provisioned is the flag that |
| + // GRANTS removal authority, so a row without it is not this pass's to |
| + // retire however well it matches on every other column. |
| + hand := f.host("hand", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: hand, Kind: "sidecar", Slug: "hand", |
| + App: "eleven", Account: f.sqid}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + noRoute(t, f.reg, gone, "a provisioned sidecar with no record must be removed") |
| + hasRoute(t, f.reg, "app.example.com", "the sidecar pass must never touch an instance row") |
| + hasRoute(t, f.reg, hand, "a sidecar row that was never provisioned is not this pass's to remove") |
| +} |
| + |
| +// --- mint / converge / remove -------------------------------------------------- |
| + |
| +func TestSidecarReconcileMints(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{ |
| + Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}, Channel: "stable"}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Channel: "stable"}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt := hasRoute(t, f.reg, f.host("bot", "eleven"), "no sidecar route minted") |
| + if rt.Kind != "sidecar" { |
| + t.Fatalf("Kind = %q", rt.Kind) |
| + } |
| + if rt.Socket != "" || rt.Addr != "" { |
| + t.Fatalf("a sidecar row must carry NO socket and NO addr, got socket=%q addr=%q", rt.Socket, rt.Addr) |
| + } |
| + if rt.Slug != "bot" || rt.App != "eleven" || rt.Account != f.sqid || rt.Channel != "stable" { |
| + t.Fatalf("route identity = %+v", rt) |
| + } |
| + if rt.DBPath != DefaultDBPath(f.dir, rt.Host) || !rt.Provisioned || rt.Args != `["sidecar","run"]` { |
| + t.Fatalf("route = %+v", rt) |
| + } |
| + if !rt.Hibernate { |
| + t.Fatal("Hibernate defaults true (HibernateOrDefault), and the row is what the activator reads") |
| + } |
| + if rt.IdleSeconds != 300 || rt.MaxRunSeconds != 3600 { |
| + t.Fatalf("ceilings = %d/%d, want the manifest defaults 300/3600 materialized onto the row — the sweeper must never need a bucket read", |
| + rt.IdleSeconds, rt.MaxRunSeconds) |
| + } |
| +} |
| + |
| +func TestSidecarReconcileConvergesArgsFromTheManifest(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven"}) |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"worker", "run"}}) |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt := hasRoute(t, f.reg, f.host("bot", "eleven"), "route gone") |
| + if rt.Args != `["worker","run"]` { |
| + t.Fatalf("Args = %q — truth lives in the bucket and is re-stamped every pass, exactly as seed is", rt.Args) |
| + } |
| +} |
| + |
| +func TestSidecarReconcileConvergesTheCeilings(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", |
| + Args: []string{"sidecar", "run"}, IdleSeconds: 300, MaxRunSeconds: 3600}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven"}) |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", |
| + Args: []string{"sidecar", "run"}, IdleSeconds: 60, MaxRunSeconds: 120}) |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt := hasRoute(t, f.reg, f.host("bot", "eleven"), "route gone") |
| + if rt.IdleSeconds != 60 || rt.MaxRunSeconds != 120 { |
| + t.Fatalf("ceilings = %d/%d, want 60/120 — the sweeper reads the row, so the row must follow the manifest", rt.IdleSeconds, rt.MaxRunSeconds) |
| + } |
| +} |
| + |
| +// TestSidecarReconcileConvergesChannelEnvironmentAndHibernate pins the rest |
| +// of the owned columns: the record owns channel and environment (the #130 |
| +// discipline), and the manifest owns hibernation. |
| +func TestSidecarReconcileConvergesChannelEnvironmentAndHibernate(t *testing.T) { |
| + f := sidecarSetup(t) |
| + off := false |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", |
| + Args: []string{"sidecar", "run"}, Hibernate: &off}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", |
| + Channel: "canary/eleven", Environment: "relay"}) |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt := hasRoute(t, f.reg, f.host("bot", "eleven"), "route gone") |
| + if rt.Channel != "canary/eleven" || rt.Environment != "relay" || rt.Hibernate { |
| + t.Fatalf("minted route = channel %q env %q hibernate %v", rt.Channel, rt.Environment, rt.Hibernate) |
| + } |
| + |
| + // Drift the row the way `carlos route` would, then converge it back. |
| + if err := f.reg.SetChannel(rt.Host, "stable"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.reg.SetEnvironment(rt.Host, "other"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.reg.SetHibernate(rt.Host, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt = hasRoute(t, f.reg, f.host("bot", "eleven"), "route gone") |
| + if rt.Channel != "canary/eleven" || rt.Environment != "relay" || rt.Hibernate { |
| + t.Fatalf("after converge = channel %q env %q hibernate %v — the record and manifest own these columns", |
| + rt.Channel, rt.Environment, rt.Hibernate) |
| + } |
| +} |
| + |
| +// --- the fail-safe -------------------------------------------------------------- |
| + |
| +// TestSidecarReconcileSkipsRemovalWhenAReadFailed pins the fail-safe: a |
| +// transient read failure must never mass-retire a tenant's fleet. Minting |
| +// still proceeds, because minting is additive. |
| +func TestSidecarReconcileSkipsRemovalWhenAReadFailed(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven"}) |
| + keep := f.host("keep", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: keep, Kind: "sidecar", Slug: "keep", |
| + App: "eleven", Account: f.sqid, Provisioned: true}) |
| + |
| + // The record IS there; this pass simply cannot read it. |
| + f.store.failReadsFor(SidecarsPrefix(f.sqid, "eleven")) |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + hasRoute(t, f.reg, keep, "a read failure skipped nothing — one bad list must not retire a fleet") |
| +} |
| + |
| +// TestSidecarReconcileProtectsAMalformedRecord is the narrower guard: only |
| +// the host whose body could not be trusted is held back, and it is held back |
| +// rather than read as "the bucket retired it" — a non-additive record |
| +// encoding change reaching an older binary would otherwise delete every |
| +// sidecar in scope in one tick. |
| +func TestSidecarReconcileProtectsAMalformedRecord(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: host, Kind: "sidecar", Slug: "bot", |
| + App: "eleven", Account: f.sqid, Provisioned: true}) |
| + if _, err := f.store.Put(ctx, SidecarRecordKey(f.sqid, "eleven", "bot"), []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + hasRoute(t, f.reg, host, "a record that cannot be PARSED is not a record that was retired") |
| +} |
| + |
| +// --- placement ------------------------------------------------------------------- |
| + |
| +// TestSidecarReconcilePlacementGate pins §8's second half: a no-inbound |
| +// workload lands only on a box that declares it accepts no inbound work. Both |
| +// spellings are honoured — the record's own placement and the app manifest's. |
| +func TestSidecarReconcilePlacementGate(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Placement: PlacementNoInbound}) |
| + // A second app whose MANIFEST asks for the placement, record silent. |
| + f.putManifest(t, "relay", SidecarManifest{Schema: 1, App: "relay", |
| + Args: []string{"relay"}, Placement: PlacementNoInbound}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "hub", App: "relay"}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) // an ordinary box: accepts inbound work |
| + noRoute(t, f.reg, f.host("bot", "eleven"), "a no-inbound workload must not land on a box that accepts inbound work") |
| + noRoute(t, f.reg, f.host("hub", "relay"), "a manifest-stated no-inbound placement must gate exactly as a record-stated one does") |
| + |
| + f.reconcile(t, ReconcileConfig{Inbound: InboundNone}) |
| + hasRoute(t, f.reg, f.host("bot", "eleven"), "it must land on a box that declares CARLOS_INBOUND=none") |
| + hasRoute(t, f.reg, f.host("hub", "relay"), "it must land on a box that declares CARLOS_INBOUND=none") |
| +} |
| + |
| +// TestSidecarReconcilePlacementMismatchDoesNotRemove: a record this box may |
| +// not RUN is still a record the bucket has not retired. Declining to mint is |
| +// the reversible direction; deleting the row is not (registry.Delete purges |
| +// the route's capability grants), and nothing else would put it back. |
| +func TestSidecarReconcilePlacementMismatchDoesNotRemove(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Placement: PlacementNoInbound}) |
| + f.mustUpsert(t, registry.Route{Host: host, Kind: "sidecar", Slug: "bot", |
| + App: "eleven", Account: f.sqid, Provisioned: true}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) // ordinary box: placement does not match |
| + |
| + rt := hasRoute(t, f.reg, host, "a placement this box cannot satisfy means DON'T MINT — it is not the bucket retiring the sidecar") |
| + if rt.Args != "" { |
| + t.Fatalf("Args = %q — a record this box may not place is a record this box does not act on at all", rt.Args) |
| + } |
| +} |
| + |
| +// --- the co-spawn conflict --------------------------------------------------------- |
| + |
| +// TestSidecarReconcileRefusesHostWithAStagedCoSpawnFile: the file-staged |
| +// co-spawn is legacy but LIVE (Eleven runs on it today). One host, one |
| +// mechanism. |
| +func TestSidecarReconcileRefusesHostWithAStagedCoSpawnFile(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + if err := os.WriteFile(filepath.Join(f.dir, host+"-sidecar.env"), []byte("X=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven"}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + noRoute(t, f.reg, host, "a host with a staged legacy co-spawn file must not also get a native sidecar row — one host, one mechanism") |
| +} |
| + |
| +// --- scope --------------------------------------------------------------------------- |
| + |
| +// TestSidecarReconcileSkipsAnotherBoxesScope: region and pool scope the |
| +// sidecar pass exactly as they scope the instance one — a record for another |
| +// agent's jurisdiction is neither minted nor (for the row) removed here. |
| +func TestSidecarReconcileSkipsAnotherBoxesScope(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven", Region: "ie", Pool: "eleven"}) |
| + // A foreign box's own provisioned sidecar row, visible in this registry. |
| + foreign := f.host("far", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: foreign, Kind: "sidecar", Slug: "far", App: "eleven", |
| + Account: f.sqid, Region: "de", Pool: "other", Provisioned: true}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) // this box: no region, no pools |
| + |
| + noRoute(t, f.reg, f.host("bot", "eleven"), "a record for another region/pool is not this pass's business") |
| + hasRoute(t, f.reg, foreign, "a foreign box's provisioned route is not this pass's to retire") |
| + |
| + f.reconcile(t, ReconcileConfig{Region: "ie", Pools: []string{"eleven"}}) |
| + hasRoute(t, f.reg, f.host("bot", "eleven"), "the in-scope box must mint it") |
| +} |
| + |
| +// TestSidecarReconcileDryRunWritesNothing mirrors the instance pass's §4.3 |
| +// mitigation: a pass that reports is a pass that writes nothing at all. |
| +func TestSidecarReconcileDryRunWritesNothing(t *testing.T) { |
| + f := sidecarSetup(t) |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven"}) |
| + gone := f.host("gone", "eleven") |
| + f.mustUpsert(t, registry.Route{Host: gone, Kind: "sidecar", Slug: "gone", |
| + App: "eleven", Account: f.sqid, Provisioned: true}) |
| + |
| + f.reconcile(t, ReconcileConfig{DryRun: true}) |
| + |
| + noRoute(t, f.reg, f.host("bot", "eleven"), "dry-run minted a route") |
| + hasRoute(t, f.reg, gone, "dry-run removed a route") |
| +} |
| + |
| +// TestSidecarReconcileLeavesAForeignRowAlone: a row this pass does not own at |
| +// a host a record names is left exactly as alone as before. There is no |
| +// adoption path for sidecars — the suffix is reserved and refused at every |
| +// door, so nothing but this pass can have minted one — and adoption is an |
| +// authority transfer, never a shape coincidence. |
| +func TestSidecarReconcileLeavesAForeignRowAlone(t *testing.T) { |
| + f := sidecarSetup(t) |
| + host := f.host("bot", "eleven") |
| + f.putManifest(t, "eleven", SidecarManifest{Schema: 1, App: "eleven", Args: []string{"sidecar", "run"}}) |
| + f.putRecord(t, SidecarRecord{Schema: 1, Name: "bot", App: "eleven"}) |
| + f.mustUpsert(t, registry.Route{Host: host, Kind: "sidecar", Slug: "bot", |
| + App: "eleven", Account: f.sqid, Args: `["hand","written"]`}) |
| + |
| + f.reconcile(t, ReconcileConfig{}) |
| + |
| + rt := hasRoute(t, f.reg, host, "route gone") |
| + if rt.Provisioned { |
| + t.Fatal("an un-provisioned row must not be adopted by this pass — adoption grants removal authority") |
| + } |
| + if rt.Args != `["hand","written"]` { |
| + t.Fatalf("Args = %q — a row this pass does not own must be left alone", rt.Args) |
| + } |
| +} |