region at the door: edges files publish the placement code, creates validate against the armed set, exec children get CARLOS_REGION (#329)
Three additive pieces: - Each box's edges file gains "placement" — CARLOS_REGION verbatim, the OTHER vocabulary from the AWS "region" field beside it — so the two travel in one file and any reader can join them. - The instance-create door refuses a region no armed box serves, naming the real choices. Dormant until some box publishes a placement code: a console rolled ahead of its fleet changes nothing, so roll order does not matter. Placed apps and the unconstrained "" region are untouched rails. - Every exec child gets CARLOS_REGION from its box, agent-owned in both directions like CARLOS_CONTROL_SOCKET — a tenant's config env cannot tell an app it runs somewhere the agent never vouched for. (titogo's boot-clears-freeze rule for #331 needs exactly this.) The landing itself already worked: a record's region scopes reconcile, and the region box's activator replicates against its own data bucket. This closes the one gap — a typo'd region minting a record nothing will ever serve. Closes #329
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
8 files changed,
+270
−46
internal/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/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) |
| + } |
| +} |