Merge pull request #348 from carlosframework/region-at-the-door
region at the door: armed-placement validation on instance create + CARLOS_REGION to exec children (#329)
9 files changed,
+457
−46
docs/superpowers/specs/2026-08-28-instance-move-design.md+187 −0internal/activator/backend_exec.go+13 −0internal/activator/backend_exec_test.go+47 −0internal/console/apiinstances.go+20 −0internal/console/apiinstances_test.go+49 −0internal/console/instancesplacement.go+47 −12internal/host/agent.go+6 −3internal/host/edgesfile.go+20 −7internal/host/edgesfile_test.go+68 −24
diff --git a/docs/superpowers/specs/2026-08-28-instance-move-design.md b/docs/superpowers/specs/2026-08-28-instance-move-design.md| new file mode 100644 |
| index 000000000..f283f8a97 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-28-instance-move-design.md |
| @@ -0,0 +1,187 @@ |
| +# Instance moves: two-phase, data-following (issue #331) |
| + |
| +Status: DRAFT for review — 2026-08-28 |
| +Companion issues: #329 (region on create — shipped separately), #330 |
| +(replica_regions — shape only, untouched here), titogo residence design |
| +§5/§8.3 (the consumer contract this serves). |
| + |
| +## Decisions taken in conversation (Paul, 2026-08-28) |
| + |
| +- Cross-bucket IAM is **push-only into a staging prefix** (`moves/`), |
| + never into `instances/` — a box can stage state toward a sibling |
| + region but can never touch another region's live replica lineage. |
| +- The platform moves **the instance DB and record only**. The uploads |
| + bulk-copy stays with the tenant (titogo's router), which owns the |
| + bucket, the credentials, and the knowledge of which keys belong to |
| + which instance. The move state machine exposes the phases so the |
| + tenant can sequence its own copy inside the frozen window. If a |
| + per-instance uploads prefix ever becomes a declared platform concept |
| + (a record field), the move widens to carry it without unwinding this |
| + design. |
| + |
| +## The invariant |
| + |
| +*Residence is a property of a route; the record's `region` field is the |
| +residence carrier* (ingress spec §Anticipated #2). This spec never adds |
| +a second arbiter: at every instant exactly one region's box is in scope |
| +for the record, and the flip of `region` is the LAST act of the source. |
| +Never two writers falls out of that plus the freeze the tenant holds |
| +across the cut. |
| + |
| +## Wire shape |
| + |
| +The record gains one additive, omitempty object — present only while a |
| +move is in flight: |
| + |
| +```json |
| +"move": { |
| + "to": "uk", |
| + "state": "preparing" | "ready" | "cutting", |
| + "requested_at": "<RFC3339>", |
| + "ready_at": "<RFC3339, when state >= ready>" |
| +} |
| +``` |
| + |
| +Records written before this field parse unchanged (wire-shape rule). |
| +`move` is written by the console (the two verbs below) and by the |
| +SOURCE box (the `preparing → ready` promotion and the terminal |
| +clear-and-flip); each write is the narrow CAS `SetRecordRegion` already |
| +uses. The record's `region` stays the SOURCE region until the cut |
| +completes — every reader that knows nothing about moves keeps a |
| +consistent answer throughout. |
| + |
| +## The two verbs |
| + |
| +Console API, on one instance, gated exactly like every other instance |
| +door (app bearer; the tenant's router holds the token — never the |
| +instance itself): |
| + |
| +- `POST .../instances/{host}/move` body `{"region":"uk"}` — validated |
| + against the armed-placement set (#329's gate, mandatory here: a move |
| + to an unarmed region is always a 400, dormant never applies because a |
| + move NEEDS a live target box). Refused 409 if a move is already in |
| + flight, 400 if `to` equals the current region. Writes |
| + `move:{to,state:"preparing"}`. |
| +- `POST .../instances/{host}/move/cut` — refused 409 unless |
| + `move.state == "ready"`. Writes `move.state:"cutting"`. The tenant |
| + calls this only after freezing writes; the platform does not verify |
| + the freeze (it cannot — the freeze is app-level), it documents it. |
| +- `DELETE .../instances/{host}/move` — abort, legal in `preparing` or |
| + `ready` (clears `move`; the source drops its second replica; staged |
| + bytes in the target bucket are garbage that the next prepare |
| + overwrites and the target's post-restore cleanup deletes). Refused |
| + 409 in `cutting`: past that point the move completes forward. |
| +- State is read, not pushed: the existing single-record GET already |
| + returns the record verbatim, so `move.state` is pollable today with |
| + zero new read surface. |
| + |
| +## What the source box does |
| + |
| +The reconcile/activator pass on the box whose `CARLOS_REGION` matches |
| +`region` (the source — the record still points at it): |
| + |
| +1. **preparing**: alongside its normal own-bucket replication, the |
| + activator opens a SECOND litestream replica of the instance DB to |
| + `s3://<target-data-bucket>/moves/<host>/` — the lease package's |
| + `ReplicateWithLease` pointed at the sibling bucket's staging prefix. |
| + Target bucket name and AWS region are derived, not configured |
| + per-move: each box publishes its data bucket coordinates as two new |
| + additive fields in its edges file (`data_bucket`, `data_bucket_region` |
| + — same additive rule as `placement`), so the source reads the |
| + target's coordinates from the same file set everything else reads. |
| + A wake is forced if the instance is asleep? NO — a parked instance's |
| + state is already fully in the source bucket; for a parked instance |
| + the "second replica" is a bucket-to-staging copy performed by the |
| + source box (its IAM can read its own bucket and push to the sibling's |
| + staging), and prepare completes without ever waking the child. |
| +2. **ready promotion**: once the staging replica reports caught up |
| + (litestream generation synced; for the parked case, copy complete), |
| + the source CASes `move.state: "preparing" → "ready"`, stamping |
| + `ready_at`. The second replica keeps streaming — ready is a level, |
| + not an edge; writes after ready keep flowing to staging. |
| +3. **cutting**: on seeing `state:"cutting"`, the source (in order): |
| + final `SyncAndWait` on the staging replica → stop the child (normal |
| + stopping path, which also checkpoints to the source's OWN bucket — |
| + the abort-recovery copy) → close both replicators → **delete the |
| + local DB, WAL/SHM, and litestream meta dirs** (the warm-cache purge; |
| + without it a later move back would resurrect a stale local file — the |
| + activator's warm-cache trust is exactly right for crashes and exactly |
| + wrong here) → one CAS that sets `region: <to>` and clears `move` → |
| + remove its own route row on its next pass (the record has left its |
| + scope, the existing retirement path does this unprompted). |
| + If the box dies mid-cut, the record still says `cutting` with the old |
| + region: the source resumes the sequence on restart (every step is |
| + idempotent — sync again, stop a stopped child, delete absent files, |
| + CAS already-flipped is a no-op conflict it re-reads). |
| + |
| +## What the target box does |
| + |
| +Nothing move-specific until the flip — then its ordinary pass sees a |
| +record newly in scope and mints the route. One addition in the |
| +activator's `Restore`: when the local DB is absent AND |
| +`instances/<host>/` in its own bucket has no replica, it checks |
| +`moves/<host>/` (own bucket, own IAM); if present, server-side copy |
| +`moves/<host>/ → instances/<host>/`, restore from there, `CheckDB` as |
| +always, and delete `moves/<host>/` on success. A corrupt staged replica |
| +quarantines exactly like a corrupt own one — and the abort story still |
| +stands, because the source's last own-bucket checkpoint is intact and |
| +`region` can be pointed back by hand (`SetRecordRegion` — the operator |
| +recovery door that already exists). |
| + |
| +## IAM (the infra half — platform-infrastructure + tito repo applies) |
| + |
| +`region-host` (and `region-remote-host`) gain an optional |
| +`sibling_staging_arns` list; when set, the role gains `s3:PutObject` on |
| +`<each sibling data bucket>/moves/*` — and nothing else: no Get, no |
| +List, no Delete, never `instances/*`. Residency-at-rest holds |
| +(cross-region reads remain impossible at IAM); the write blast radius |
| +is confined to staging keys no box ever trusts as live state without |
| +its own restore-time `CheckDB`. Litestream against a push-only prefix: |
| +the lease/generation dance needs List on its own prefix — checked |
| +during build; if litestream cannot run push-only, prepare falls back to |
| +periodic own-checkpoint + copy-into-staging (same convergence, chunkier |
| +cadence), and the spec's contract (`ready` = staged copy caught up) |
| +does not change. |
| + |
| +## Instance env |
| + |
| +Already shipped with #329: every exec child gets `CARLOS_REGION` (the |
| +box's placement code), agent-owned in both directions. An app that |
| +lands on the target box after a move reads the new value at boot — |
| +titogo's boot-clears-freeze rule needs exactly this. |
| + |
| +## Out of scope |
| + |
| +- Uploads (tenant-side, by decision above). |
| +- `replica_regions` behavior (#330 stays shape-only). |
| +- Moving unit-backed, addr-backed, static, or everywhere-class routes: |
| + the verbs refuse anything but an exec-backed `kind:"instance"` route |
| + (400 naming the kind) — the only class whose state the platform owns. |
| +- Fleet placements (`fleet/<name>`): refused; fleets keep D6 |
| + state-does-not-follow semantics on purpose. |
| +- Automatic freeze verification; region-aware unit economics; the |
| + member-facing residence door (titogo builds on these verbs). |
| + |
| +## Testing |
| + |
| +- Record CAS: move-field transitions (prepare/ready/cut/abort/conflict) |
| + against the fake store, including the 409 matrix. |
| +- Source pass: fake-store + fake-litestream harness proving the cut |
| + ordering (final sync BEFORE stop BEFORE local purge BEFORE flip), the |
| + parked-instance prepare (no wake), resume-from-`cutting` idempotence, |
| + and that abort leaves the source serving with its own replication |
| + intact. |
| +- Target restore: staging fallback (copy, restore, CheckDB, cleanup), |
| + corrupt-staging quarantine, and precedence (own `instances/` replica |
| + beats staging when both exist — a re-landed instance must not regress |
| + to an older staged copy). |
| +- Console: verb gates (bearer, kind, armed region, state machine), |
| + poll-through-GET. |
| +- The never-two-writers argument pinned by an ordering test: a request |
| + hitting the source during `cutting` after stop is a 503 from the edge |
| + (route gone on next pass) while the target has not yet minted. The |
| + length of that window is the target's reconcile latency, NOT assumed: |
| + measure it during build (the adopt path/timer cadence is the floor) |
| + and record it in the runbook — the tenant's freeze must be budgeted |
| + to cover it, and if it measures in minutes rather than seconds, a |
| + serial-bump nudge on the record write is the follow-up, not a redesign. |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index ddd0893b5..a8fac9f93 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -60,6 +60,14 @@ type ExecBackend struct { |
| // but a carlos app is a separate binary from the release store — there is |
| // nothing sensible to invent here, and the adopt pass owns the symlinks. |
| Materialize func(ctx context.Context, host string) error |
| + // Region is the box's CARLOS placement code (CARLOS_REGION), handed to |
| + // every exec child as its own CARLOS_REGION (issue #329, titogo's |
| + // residence design: the app reads where it lives — e.g. to clear a move |
| + // freeze after landing). Agent-owned in BOTH directions, exactly like |
| + // the control-channel vars below: set from what the agent knows, and |
| + // STRIPPED when the agent knows nothing — a tenant's config env must not |
| + // be able to tell an app it runs somewhere it does not. |
| + Region string |
| // Cache, when non-nil, adds the purge verb to each spawned instance's |
| // control socket so it can invalidate its own cached responses right |
| // after a write. Nil disables that ONE verb, not the socket: the control |
| @@ -626,6 +634,11 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| } else { |
| childVars = withoutVars(childVars, "CARLOS_PURGE") |
| } |
| + if b.Region != "" { |
| + childVars = withVar(childVars, "CARLOS_REGION", b.Region) |
| + } else { |
| + childVars = withoutVars(childVars, "CARLOS_REGION") |
| + } |
| // Record the build we're about to exec — read the symlink immediately before |
| // the start so it names the version the kernel will resolve. Reading it here, |
| // rather than back in wake() after the health wait, closes a real window: a |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 6bc46a846..7e3fb3a8a 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -1,10 +1,13 @@ |
| package activator |
| import ( |
| + "context" |
| "os" |
| "os/exec" |
| "path/filepath" |
| + "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -56,3 +59,47 @@ func TestSpawnInCgroupAppliesTheTier(t *testing.T) { |
| t.Errorf("memory.max = %q, want the children tier", got) |
| } |
| } |
| + |
| +// The box's placement code reaches every exec child as CARLOS_REGION (issue |
| +// #329) — agent-owned in both directions: set from ExecBackend.Region, and |
| +// STRIPPED when the backend has none, even when a tenant's own env file |
| +// tries to supply it. Same rule and same reason as CARLOS_CONTROL_SOCKET / |
| +// CARLOS_PURGE: an app must not be told it runs somewhere the agent never |
| +// vouched for. |
| +func TestExecBackendStartChildRegion(t *testing.T) { |
| + for _, tc := range []struct { |
| + name, backendRegion, want string |
| + wantSet bool |
| + }{ |
| + {"set from the backend", "uk", "uk", true}, |
| + {"stripped when the backend has none", "", "", false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b := testExecBackend(t) |
| + b.Region = tc.backendRegion |
| + host := "region-env.example.com" |
| + _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1") |
| + // A tenant-supplied value that must never survive as-is. |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte("CARLOS_REGION=forged\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "instance.sock")} |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) // fake binary never binds the socket; health-wait error expected |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + child := map[string]string{} |
| + for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") { |
| + if k, v, ok := strings.Cut(line, "="); ok { |
| + child[k] = v |
| + } |
| + } |
| + got, ok := child["CARLOS_REGION"] |
| + if ok != tc.wantSet || got != tc.want { |
| + t.Fatalf("child CARLOS_REGION = %q (set=%v), want %q (set=%v)", got, ok, tc.want, tc.wantSet) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 64650e82b..954ca0e79 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -604,6 +604,26 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadRequest, msg) |
| return |
| } |
| + // The armed-placement gate (issue #329): a non-empty region that names |
| + // no armed box is an instance NOTHING will ever mint — the record would |
| + // sit dark forever, which is worse than a refusal that lists the real |
| + // choices. Placed apps skipped (their value is a fleet, validated by its |
| + // own rail above); the unconstrained "" region skipped (it means "the |
| + // deployment's home", which always exists); and the whole gate is |
| + // dormant until some box publishes a placement code, so a console rolled |
| + // ahead of its fleet changes nothing. |
| + if placed == "" && region != "" { |
| + if set, armed := armedPlacements(ctx, s.cfg.Releases); armed && !set[region] { |
| + codes := make([]string, 0, len(set)) |
| + for c := range set { |
| + codes = append(codes, c) |
| + } |
| + slices.Sort(codes) |
| + jsonError(w, http.StatusBadRequest, |
| + "region "+region+" is not armed in this deployment — armed: "+strings.Join(codes, ", ")) |
| + return |
| + } |
| + } |
| channel := strings.TrimSpace(req.Channel) |
| if channel == "" { |
| // The manifest's channel, then the documented default (§3.1). An |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 8870c16df..b9a5bd7ff 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -2135,3 +2135,52 @@ func TestAPIInstanceListServedBy(t *testing.T) { |
| t.Errorf("get beta = %d %s, want 200 with no served_by key", code, out) |
| } |
| } |
| + |
| +// TestAPIInstanceCreateArmedPlacementGate (issue #329): once any box |
| +// publishes a placement code in its edges file, a create naming a region no |
| +// armed box serves is refused with the real choices — a record nothing will |
| +// ever mint is a dark instance, worse than a 400. Dormant before that |
| +// (legacy fleet: behaviour unchanged); a placement without an EIP is not a |
| +// landing target; the unconstrained "" region always passes. |
| +func TestAPIInstanceCreateArmedPlacementGate(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-armed") |
| + m := titoManifest() |
| + m.Regions = nil // the manifest allow-list is a separate rail; keep it out |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + put := func(key, body string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // DORMANT: no edges file carries a placement — any shape-valid region |
| + // is accepted, exactly as before the gate existed. |
| + put("control/edges/legacy.json", `{"v":1,"box":"legacy","eip":"9.9.9.9","hosts":{}}`) |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"zz-typo"}`); code != http.StatusCreated { |
| + t.Fatalf("dormant create = %d %s, want 201", code, out) |
| + } |
| + |
| + // ARMED: uk has an EIP, se does not, legacy still publishes none. |
| + put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","region":"eu-west-2","placement":"uk","hosts":{}}`) |
| + put("control/edges/se-box.json", `{"v":1,"box":"se-box","eip":"","region":"eu-north-1","placement":"se","hosts":{}}`) |
| + |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"beta.go.tito.io","region":"uk"}`); code != http.StatusCreated { |
| + t.Fatalf("create region uk (armed) = %d %s, want 201", code, out) |
| + } |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"gamma.go.tito.io"}`); code != http.StatusCreated { |
| + t.Fatalf("create with unconstrained region = %d %s, want 201", code, out) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"delta.go.tito.io","region":"zz-typo"}`) |
| + if code != http.StatusBadRequest { |
| + t.Fatalf("create region zz-typo (armed) = %d %s, want 400", code, out) |
| + } |
| + if !strings.Contains(out, "armed: uk") { |
| + t.Fatalf("refusal %q does not name the armed set", out) |
| + } |
| + // se published a placement but no EIP: not a landing target. |
| + if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"eps.go.tito.io","region":"se"}`); code != http.StatusBadRequest { |
| + t.Fatalf("create region se (no EIP) = %d %s, want 400", code, out) |
| + } |
| +} |
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| index 9ae896c8e..649325f8d 100644 |
| --- a/internal/console/instancesplacement.go |
| +++ b/internal/console/instancesplacement.go |
| @@ -33,10 +33,12 @@ type servedBy struct { |
| // capacity, and "where is this instance" is the question, not "who is its |
| // write home" (that is the home map's question, internal/edge). |
| type edgesFilePlacementView struct { |
| - V int `json:"v"` |
| - Box string `json:"box"` |
| - Region string `json:"region"` |
| - Hosts map[string]json.RawMessage `json:"hosts"` |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + EIP string `json:"eip"` |
| + Region string `json:"region"` |
| + Placement string `json:"placement"` |
| + Hosts map[string]json.RawMessage `json:"hosts"` |
| } |
| // loadEdgesJoin folds every readable edges file into host → the boxes that |
| @@ -49,14 +51,31 @@ type edgesFilePlacementView struct { |
| // all, the same shape filter the other readers apply. |
| func loadEdgesJoin(ctx context.Context, s release.Store) map[string][]servedBy { |
| out := map[string][]servedBy{} |
| + for _, f := range readEdgesFiles(ctx, s) { |
| + for host := range f.Hosts { |
| + out[host] = append(out[host], servedBy{Box: f.Box, Region: f.Region}) |
| + } |
| + } |
| + for host := range out { |
| + sort.Slice(out[host], func(i, j int) bool { return out[host][i].Box < out[host][j].Box }) |
| + } |
| + return out |
| +} |
| + |
| +// readEdgesFiles is the shared best-effort read under both consumers: |
| +// list, shape-filter, parse, skip anything unreadable or unversioned. A |
| +// failed list is an empty answer, logged — the callers each degrade to |
| +// their pre-#337/#329 behaviour rather than failing the request. |
| +func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementView { |
| if s == nil { |
| - return out |
| + return nil |
| } |
| keys, err := s.List(ctx, edgesFilePrefix) |
| if err != nil { |
| log.Printf("console: api instances: could not list %s for the placement join: %v", edgesFilePrefix, err) |
| - return out |
| + return nil |
| } |
| + var out []edgesFilePlacementView |
| for _, key := range keys { |
| name, ok := strings.CutPrefix(key, edgesFilePrefix) |
| if !ok || !strings.HasSuffix(name, ".json") || strings.Contains(name, "/") { |
| @@ -72,12 +91,28 @@ func loadEdgesJoin(ctx context.Context, s release.Store) map[string][]servedBy { |
| log.Printf("console: api instances: skipping %s in the placement join: not a versioned edges file", key) |
| continue |
| } |
| - for host := range f.Hosts { |
| - out[host] = append(out[host], servedBy{Box: f.Box, Region: f.Region}) |
| - } |
| - } |
| - for host := range out { |
| - sort.Slice(out[host], func(i, j int) bool { return out[host][i].Box < out[host][j].Box }) |
| + out = append(out, f) |
| } |
| return out |
| } |
| + |
| +// armedPlacements folds the fleet's edges files into the set of placement |
| +// codes an instance may be created into (issue #329): every code published |
| +// by a box that also has a dial address (an EIP — a box nothing can reach |
| +// is not a landing target). armed reports whether ANY file carried a |
| +// placement code at all: false means the fleet predates the field (or the |
| +// read failed) and the gate stays DORMANT — the door then accepts what it |
| +// always accepted, so rolling boxes and console in either order is safe. |
| +func armedPlacements(ctx context.Context, s release.Store) (set map[string]bool, armed bool) { |
| + set = map[string]bool{} |
| + for _, f := range readEdgesFiles(ctx, s) { |
| + if f.Placement == "" { |
| + continue |
| + } |
| + armed = true |
| + if f.EIP != "" { |
| + set[f.Placement] = true |
| + } |
| + } |
| + return set, armed |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index bd8f84dbb..13bb1580c 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -270,9 +270,12 @@ func RunAgent(args []string) { |
| // gate is still the hibernate bucket env even though unit tenants |
| // never touch the bucket — one opt-in switch for the whole activator. |
| eb := &activator.ExecBackend{ |
| - Cfg: *cfg, |
| - OptDir: release.OptDir(), |
| - DataDir: dd, |
| + Cfg: *cfg, |
| + OptDir: release.OptDir(), |
| + DataDir: dd, |
| + // The box's placement code, into every exec child's env |
| + // (issue #329) — same env the reconcile pass scopes by. |
| + Region: strings.TrimSpace(os.Getenv("CARLOS_REGION")), |
| Cache: cache, |
| Materialize: materializeHook(reg, dd), |
| Cgroups: cgm, |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index c2e724dcf..d57bbd1aa 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -73,10 +73,19 @@ type edgeHost struct { |
| // batch-wide if it ever reached here (plan review catch, 2026-08-23). |
| // edgesFileRegion resolves the real thing. |
| type edgesFile struct { |
| - V int `json:"v"` |
| - Box string `json:"box"` |
| - EIP string `json:"eip"` |
| - Region string `json:"region,omitempty"` |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + EIP string `json:"eip"` |
| + Region string `json:"region,omitempty"` |
| + // Placement is this box's CARLOS placement code — CARLOS_REGION verbatim |
| + // ("ie", "uk"), the OTHER region vocabulary (see Region above, issue |
| + // #329). Additive and omitempty like Region: a pre-placement body parses |
| + // unchanged, and a box with no CARLOS_REGION set (single-box |
| + // deployments, the legal unset state) publishes no key at all. The |
| + // console's create door folds every file's Placement into the armed set |
| + // a requested instance region is validated against — dormant until at |
| + // least one box publishes the field, so roll order does not matter. |
| + Placement string `json:"placement,omitempty"` |
| Generation string `json:"generation"` |
| Hosts map[string]edgeHost `json:"hosts"` |
| } |
| @@ -114,7 +123,7 @@ type edgesFile struct { |
| // once per alias per process (cleared when the alias publishes). |
| var withheldAliasSaid sync.Map |
| -func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip, region string, last *[]byte) error { |
| +func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, box, eip, region, placement string, last *[]byte) error { |
| routes, err := reg.List() |
| if err != nil { |
| return fmt.Errorf("edges: list: %w", err) |
| @@ -248,7 +257,7 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry, |
| } |
| } |
| - f := edgesFile{V: 1, Box: box, EIP: eip, Region: region, Hosts: hosts} |
| + f := edgesFile{V: 1, Box: box, EIP: eip, Region: region, Placement: placement, Hosts: hosts} |
| gate, err := json.Marshal(f) |
| if err != nil { |
| return fmt.Errorf("edges: marshal: %w", err) |
| @@ -359,6 +368,10 @@ func edgesFileLoop(reg *registry.Registry) { |
| // per pass). readPlacementRegion is the one IMDS call; edgesFileRegion |
| // applies the CARLOS_AWS_REGION override on top, pure. |
| region := edgesFileRegion(os.Getenv, readPlacementRegion()) |
| + // The placement code alongside it: CARLOS_REGION, the same env the |
| + // reconcile pass scopes by — the two vocabularies travel in one file so |
| + // a reader can join them without a second source. |
| + placement := strings.TrimSpace(os.Getenv("CARLOS_REGION")) |
| log.Printf("agent: edges file loop armed (5min + jitter, %s)", edgesFileKey(box)) |
| last := seedEdgesFileGate(ctx, store, box) |
| delay := time.Minute |
| @@ -366,7 +379,7 @@ func edgesFileLoop(reg *registry.Registry) { |
| time.Sleep(delay) |
| delay = 5*time.Minute + rand.N(time.Minute) |
| eip := readPublicIP() |
| - if err := edgesFilePass(ctx, store, reg, box, eip, region, &last); err != nil { |
| + if err := edgesFilePass(ctx, store, reg, box, eip, region, placement, &last); err != nil { |
| log.Printf("agent: edges file pass: %v", err) |
| } |
| } |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 11c140ffa..2c219db67 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -55,7 +55,7 @@ func TestEdgesFilePublishesHomedHosts(t *testing.T) { |
| s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| seedRoute(t, reg, "app.example.com") |
| var lastBody []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -86,7 +86,7 @@ func TestEdgesFilePublishesWithEmptyEIP(t *testing.T) { |
| s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| seedRoute(t, reg, "app.example.com") |
| var lastBody []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -112,7 +112,7 @@ func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) { |
| seedRoute(t, reg, "app.example.com") |
| var lastBody []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| if s.puts != 1 { |
| @@ -122,7 +122,7 @@ func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) { |
| // Second pass, identical registry state: a fresh generation timestamp |
| // would differ from the first pass's, but the change-gate excludes |
| // Generation, so this must not write. |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| if s.puts != 1 { |
| @@ -131,7 +131,7 @@ func TestEdgesFileSkipsWriteWhenUnchanged(t *testing.T) { |
| // A registry change makes the next pass write again. |
| seedRoute(t, reg, "second.example.com") |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| if s.puts != 2 { |
| @@ -151,12 +151,12 @@ func TestEdgesFileHoldsLastKnownGoodEIPOnTransientBlank(t *testing.T) { |
| seedRoute(t, reg, "app.example.com") |
| var last []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| // Simulate a transient IMDS blip: this tick's probe returns "". |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| @@ -186,7 +186,7 @@ func TestEdgesFileExcludesNonReadyRows(t *testing.T) { |
| } |
| var last []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -219,7 +219,7 @@ func TestEdgesFileWildcardRowPublishesVerbatim(t *testing.T) { |
| } |
| var last []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -250,7 +250,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) { |
| seedRoute(t, reg, "app.example.com") |
| var last []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "quiet-harbour", "203.0.113.10", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "quiet-harbour", "203.0.113.10", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json") |
| @@ -302,7 +302,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| t.Run("unset region is omitted entirely", func(t *testing.T) { |
| var last []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "quiet-harbour", "203.0.113.10", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "quiet-harbour", "203.0.113.10", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json") |
| @@ -321,7 +321,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| s2, reg2 := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| seedRoute(t, reg2, "app.example.com") |
| var last []byte |
| - if err := edgesFilePass(context.Background(), s2, reg2, "quiet-harbour", "203.0.113.10", "eu-west-1", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s2, reg2, "quiet-harbour", "203.0.113.10", "eu-west-1", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| got, _, err := s2.Get(context.Background(), "control/edges/quiet-harbour.json") |
| @@ -423,7 +423,7 @@ func TestEdgesFileSeedGateSkipsWriteOnUnchangedBucketState(t *testing.T) { |
| // A prior process (or the prior pass of this one) already published. |
| var priming []byte |
| - if err := edgesFilePass(context.Background(), real, reg, "annie", "1.2.3.4", "", &priming); err != nil { |
| + if err := edgesFilePass(context.Background(), real, reg, "annie", "1.2.3.4", "", "", &priming); err != nil { |
| t.Fatal(err) |
| } |
| @@ -435,7 +435,7 @@ func TestEdgesFileSeedGateSkipsWriteOnUnchangedBucketState(t *testing.T) { |
| } |
| counting := &putCountingStore{Store: real} |
| - if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| if counting.puts != 0 { |
| @@ -454,7 +454,7 @@ func TestEdgesFileSeedHoldsEIPThroughRestartBlip(t *testing.T) { |
| seedRoute(t, reg, "app.example.com") |
| var priming []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &priming); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &priming); err != nil { |
| t.Fatal(err) |
| } |
| @@ -466,7 +466,7 @@ func TestEdgesFileSeedHoldsEIPThroughRestartBlip(t *testing.T) { |
| // This tick both hits a transient IMDS blip AND sees a real registry |
| // change, so the pass must write. |
| seedRoute(t, reg, "second.example.com") |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| @@ -495,13 +495,13 @@ func TestEdgesFileUnseededLastPublishesBlankEIPOnFirstTickBlip(t *testing.T) { |
| seedRoute(t, reg, "app.example.com") |
| var priming []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &priming); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &priming); err != nil { |
| t.Fatal(err) |
| } |
| // The pre-fix shape: a fresh process's *last starts nil (no seed read). |
| var unseeded []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", &unseeded); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "", "", "", &unseeded); err != nil { |
| t.Fatal(err) |
| } |
| @@ -545,7 +545,7 @@ func TestEdgesFileSeedPreRegionBucketWritesOnceWhenRegionArrives(t *testing.T) { |
| // This build now has CARLOS_REGION set: the first pass must write, once. |
| counting := &putCountingStore{Store: real} |
| - if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "eu-west-1", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "eu-west-1", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| if counting.puts != 1 { |
| @@ -565,7 +565,7 @@ func TestEdgesFileSeedPreRegionBucketWritesOnceWhenRegionArrives(t *testing.T) { |
| // A second, identical-region pass must not write again — the gate now |
| // includes region on both sides. |
| - if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "eu-west-1", &last); err != nil { |
| + if err := edgesFilePass(context.Background(), counting, reg, "annie", "1.2.3.4", "eu-west-1", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| if counting.puts != 1 { |
| @@ -588,7 +588,7 @@ func TestEdgesFileMarksPlatformRows(t *testing.T) { |
| } |
| } |
| var lastBody []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -654,7 +654,7 @@ func TestEdgesFilePublishesPointingAliasesOfServedPrimaries(t *testing.T) { |
| publish := func() map[string]hostView { |
| t.Helper() |
| var lastBody []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -734,7 +734,7 @@ func TestEdgesFilePublishesDelegatedWildcardParents(t *testing.T) { |
| } |
| } |
| var lastBody []byte |
| - if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", &lastBody); err != nil { |
| + if err := edgesFilePass(context.Background(), s, reg, "annie", "1.2.3.4", "", "", &lastBody); err != nil { |
| t.Fatal(err) |
| } |
| body, _, err := s.Get(context.Background(), "control/edges/annie.json") |
| @@ -797,7 +797,7 @@ func TestEdgesFileBoxNameJoinsTheStatusSnapshotLabel(t *testing.T) { |
| s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| seedRoute(t, reg, "app.example.com") |
| var last []byte |
| - if err := edgesFilePass(ctx, s, reg, box, "203.0.113.7", "eu-west-1", &last); err != nil { |
| + if err := edgesFilePass(ctx, s, reg, box, "203.0.113.7", "eu-west-1", "", &last); err != nil { |
| t.Fatal(err) |
| } |
| if err := status.WriteBox(ctx, s, snapshot); err != nil { |
| @@ -808,3 +808,47 @@ func TestEdgesFileBoxNameJoinsTheStatusSnapshotLabel(t *testing.T) { |
| t.Fatalf("fold = %v, want this box's own EIP — the two writers disagree about its name", got) |
| } |
| } |
| + |
| +// The placement code (issue #329): CARLOS_REGION verbatim, additive between |
| +// region and generation, omitted entirely when the box has none — the |
| +// console's armed-placement gate stays dormant on a fleet that never |
| +// publishes it, which is what makes roll order irrelevant. |
| +func TestEdgesFileRawWireBodyPlacementField(t *testing.T) { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + if err := edgesFilePass(context.Background(), s, reg, "quiet-harbour", "203.0.113.10", "eu-west-2", "uk", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var probe struct { |
| + Placement string `json:"placement"` |
| + Generation string `json:"generation"` |
| + } |
| + if err := json.Unmarshal(got, &probe); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","generation":"` + |
| + probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| + if string(got) != want { |
| + t.Fatalf("raw wire body (placement set) =\n%s\nwant\n%s", got, want) |
| + } |
| + |
| + // Unset placement publishes no key at all. |
| + s2, reg2 := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg2, "app.example.com") |
| + var last2 []byte |
| + if err := edgesFilePass(context.Background(), s2, reg2, "quiet-harbour", "203.0.113.10", "", "", &last2); err != nil { |
| + t.Fatal(err) |
| + } |
| + got2, _, err := s2.Get(context.Background(), "control/edges/quiet-harbour.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(string(got2), "placement") { |
| + t.Fatalf("raw wire body with no CARLOS_REGION carries a placement key: %s", got2) |
| + } |
| +} |