edges file: publish serves_shared_pool and accepts_inbound (nearest-region §6.1)
Computed from the reconcile config the box mints with, so an automatic region choice can never land on a box that refuses ordinary instances.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+176
−7
internal/host/edgesfile.go+29 −3internal/host/edgesfile_test.go+70 −4internal/provision/reconcile.go+12 −0internal/provision/reconcile_eligibility_test.go+65 −0
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index 323fd1c89..c969126ac 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -203,9 +203,22 @@ type edgesFile struct { |
| // IAM role cannot reach the bucket it names, or whose |
| // CARLOS_HIBERNATE_REGION is wrong, publishes true here and still fails |
| // every wake. Naming it for what it actually knows is the point. |
| - HibernationConfigured bool `json:"hibernation_configured"` |
| - Generation string `json:"generation"` |
| - Hosts map[string]edgeHost `json:"hosts"` |
| + HibernationConfigured bool `json:"hibernation_configured"` |
| + |
| + // ServesSharedPool and AcceptsInbound are this box's own answers to |
| + // "would my reconcile pass mint an ORDINARY instance record" — one in |
| + // the shared "" pool, needing inbound service (nearest-region spec |
| + // §6.1). Deliberately NOT omitempty, HibernationConfigured's precedent: |
| + // absent is a writer that predates the field (unknown), false is a box |
| + // that said no. The console needs both positively true before it makes |
| + // an AUTOMATIC choice of this box's region; explicit requests ignore |
| + // them. Computed from the same ReconcileConfig the reconcile pass uses |
| + // (edgesFileEligibility), never a second reading of the environment. |
| + ServesSharedPool bool `json:"serves_shared_pool"` |
| + AcceptsInbound bool `json:"accepts_inbound"` |
| + |
| + Generation string `json:"generation"` |
| + Hosts map[string]edgeHost `json:"hosts"` |
| } |
| // edgesFilePass builds this box's edges file from the routes currently in |
| @@ -541,6 +554,14 @@ func edgesFileRegion(getenv func(string) string, probedRegion string) string { |
| return strings.TrimSpace(probedRegion) |
| } |
| +// edgesFileEligibility derives the two published eligibility answers from |
| +// the reconcile config this box actually runs with — provisionTickConfig, |
| +// the same parse the provision tick uses — so a spelling the reconciler |
| +// reads one way can never be published another. |
| +func edgesFileEligibility(getenv func(string) string) (sharedPool, inbound bool) { |
| + return provisionTickConfig(getenv).SharedInstanceEligibility() |
| +} |
| + |
| // edgesFileLoop publishes this box's edges file every 5 minutes + jitter — |
| // the same cadence and arming site as registrySnapshotLoop — AND within one |
| // watch interval of any change to this box's registry (#174). |
| @@ -588,6 +609,9 @@ func edgesFileLoop(reg *registry.Registry) { |
| return |
| } |
| box := metricsBoxLabel(os.Getenv, readMachineID()) |
| + // Eligibility is read once here at arm, from the reconcile config's own |
| + // parse, never a second reading of CARLOS_POOLS or CARLOS_INBOUND. |
| + sharedPool, inbound := edgesFileEligibility(os.Getenv) |
| // Region resolved once here at arm — the domains loop's read-once |
| // precedent (domainsLoop reads its env once before the loop and threads |
| // the result through every tick via cfg, never re-reading or re-probing |
| @@ -608,6 +632,8 @@ func edgesFileLoop(reg *registry.Registry) { |
| // guarantee that is to derive both from the one read rather than to |
| // ask two questions that could drift. |
| HibernationConfigured: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")) != "", |
| + ServesSharedPool: sharedPool, |
| + AcceptsInbound: inbound, |
| } |
| log.Printf("agent: edges file loop armed (5min + jitter, and %s after a registry change, %s)", edgesFileWatchInterval, edgesFileKey(box)) |
| last := seedEdgesFileGate(ctx, store, box) |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 75436e949..7b1d516fc 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -312,7 +312,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) { |
| t.Fatalf("generation %q is not RFC3339: %v", probe.Generation, err) |
| } |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` + |
| probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want) |
| @@ -354,7 +354,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| t.Fatal(err) |
| } |
| gen := genOf(t, got) |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` + |
| gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region unset) =\n%s\nwant\n%s", got, want) |
| @@ -373,7 +373,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| t.Fatal(err) |
| } |
| gen := genOf(t, got) |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":false,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` + |
| gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region set) =\n%s\nwant\n%s", got, want) |
| @@ -926,7 +926,7 @@ func TestEdgesFileRawWireBodyPlacementField(t *testing.T) { |
| 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","hibernation_configured":false,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"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) |
| @@ -1205,3 +1205,69 @@ func TestEdgesFilePublishesNoOwnerForAMixedPathPrefixHostname(t *testing.T) { |
| wantOwner(t, hosts, "one.example", "bae", "titogo") |
| wantOwner(t, hosts, "b.classic.tito.io", "bae", "classic") |
| } |
| + |
| +// The two eligibility fields are NOT omitempty: absent means "a writer |
| +// that predates the field" (unknown), false means "no" (spec §6.1). |
| +func TestEdgesFileRawWireBodyEligibilityFields(t *testing.T) { |
| + for _, want := range []bool{true, false} { |
| + s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t) |
| + seedRoute(t, reg, "app.example.com") |
| + var last []byte |
| + ident := edgesFile{Box: "quiet-harbour", Region: "eu-west-2", Placement: "uk", |
| + ServesSharedPool: want, AcceptsInbound: want} |
| + if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var raw map[string]any |
| + if err := json.Unmarshal(got, &raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range []string{"serves_shared_pool", "accepts_inbound"} { |
| + v, present := raw[k] |
| + if !present || v != want { |
| + t.Fatalf("%s = %v (present %v), want %v always published", k, v, present, want) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// What the loop publishes is the reconcile config's own answer, for every |
| +// spelling an operator might write. CARLOS_POOLS=",tito" parses to |
| +// ["tito"] (agent.go's parser drops empty elements), so it does NOT serve |
| +// the shared pool and must publish false (codex R3-3). |
| +func TestEdgesFileEligibilityFollowsTheReconcileConfig(t *testing.T) { |
| + cases := []struct { |
| + pools, inbound string |
| + shared, accept bool |
| + }{ |
| + {"", "", true, true}, |
| + {"tito", "", false, true}, |
| + {",tito", "", false, true}, |
| + {"", "none", true, false}, |
| + {"tito", "NONE", false, false}, |
| + } |
| + for _, c := range cases { |
| + getenv := func(k string) string { |
| + switch k { |
| + case "CARLOS_POOLS": |
| + return c.pools |
| + case "CARLOS_INBOUND": |
| + return c.inbound |
| + } |
| + return "" |
| + } |
| + shared, inbound := edgesFileEligibility(getenv) |
| + cfg := provisionTickConfig(getenv) |
| + wantShared, wantInbound := cfg.SharedInstanceEligibility() |
| + if shared != wantShared || inbound != wantInbound { |
| + t.Fatalf("%+v: published (%v, %v) but the reconciler says (%v, %v)", c, shared, inbound, wantShared, wantInbound) |
| + } |
| + if shared != c.shared || inbound != c.accept { |
| + t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 8b3d2d9e8..ffef7f3a9 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -1343,6 +1343,18 @@ func poolServed(pools []string, pool string) bool { |
| return slices.Contains(pools, pool) |
| } |
| +// SharedInstanceEligibility answers, from this box's own reconcile config, |
| +// whether it mints an ORDINARY instance record: one in the shared "" |
| +// pool, needing inbound service. The edges file publishes both answers |
| +// (serves_shared_pool, accepts_inbound) so the console never offers an |
| +// automatic choice of a region whose boxes would refuse the record |
| +// (nearest-region spec §6.1). It is the reconciler's own predicates — |
| +// poolServed and the CARLOS_INBOUND=none refusal in the reconcile pass — so |
| +// publication and minting cannot disagree. |
| +func (cfg ReconcileConfig) SharedInstanceEligibility() (sharedPool, inbound bool) { |
| + return poolServed(cfg.Pools, ""), cfg.Inbound != InboundNone |
| +} |
| + |
| // seedJSON renders a manifest's seed argv as the wire text SetSeed and a |
| // minted route's Seed field carry: "" for nil/empty (no seed), else the |
| // JSON array of the argv strings. |
diff --git a/internal/provision/reconcile_eligibility_test.go b/internal/provision/reconcile_eligibility_test.go| new file mode 100644 |
| index 000000000..6f9ac5556 |
| --- /dev/null |
| +++ b/internal/provision/reconcile_eligibility_test.go |
| @@ -0,0 +1,65 @@ |
| +package provision |
| + |
| +import "testing" |
| + |
| +// SharedInstanceEligibility must be the reconciler's own predicates, never a |
| +// second reading of the environment (spec §6.1, codex R3-3). |
| +func TestSharedInstanceEligibilityIsTheMintPredicate(t *testing.T) { |
| + cases := []struct { |
| + pools []string |
| + inbound string |
| + shared, accept bool |
| + }{ |
| + {nil, "", true, true}, |
| + {[]string{"tito"}, "", false, true}, |
| + {[]string{"", "tito"}, "", true, true}, |
| + {nil, InboundNone, true, false}, |
| + {[]string{"tito"}, InboundNone, false, false}, |
| + } |
| + for _, c := range cases { |
| + cfg := ReconcileConfig{Pools: c.pools, Inbound: c.inbound} |
| + shared, inbound := cfg.SharedInstanceEligibility() |
| + if shared != poolServed(c.pools, "") || inbound != (c.inbound != InboundNone) { |
| + t.Fatalf("%+v: eligibility diverges from the mint predicates", c) |
| + } |
| + if shared != c.shared || inbound != c.accept { |
| + t.Fatalf("%+v: got (%v, %v), want (%v, %v)", c, shared, inbound, c.shared, c.accept) |
| + } |
| + } |
| +} |
| + |
| +// spec §16, codex plan-R3 4: each published answer pairs with the reconcile |
| +// pass's own behaviour on an ORDINARY record (the shared "" pool, routed). |
| +// A box that publishes both true mints it; a box that publishes either |
| +// false refuses it, so the console's eligibility never offers a region |
| +// whose box would refuse the record. |
| +func TestEligibilityPairsWithTheReconcileRefusal(t *testing.T) { |
| + for _, c := range []struct { |
| + pools []string |
| + inbound string |
| + }{ |
| + {nil, ""}, {[]string{"tito"}, ""}, {[]string{"", "tito"}, ""}, |
| + {nil, InboundNone}, {[]string{"tito"}, InboundNone}, |
| + } { |
| + s, sqid := setup(t) |
| + if err := PutManifest(ctx, s, sqid, "tito", Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := acmeRecord(sqid) |
| + rec.Pool = "" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: c.pools, DataDir: "/data", Inbound: c.inbound} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := reg.Get(rec.Host) |
| + minted := err == nil && rt != nil |
| + shared, inbound := cfg.SharedInstanceEligibility() |
| + if minted != (shared && inbound) { |
| + t.Errorf("pools %q inbound %q: minted %v, but the box publishes serves_shared_pool=%v accepts_inbound=%v", c.pools, c.inbound, minted, shared, inbound) |
| + } |
| + } |
| +} |