host, provision: read CARLOS_INBOUND, refuse routed mints on a no-inbound box
4 files changed,
+109
−2
internal/host/agent.go+13 −1internal/host/host_test.go+23 −0internal/provision/reconcile.go+19 −1internal/provision/reconcile_test.go+54 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 4cfbaf395..c0ce683fe 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1530,7 +1530,10 @@ func domainsTickConfig(getenv func(string) string, publicIP string) (domains.Con |
| // hasn't been told its region/pools still serves only ""-scoped records, |
| // never someone else's residency promise. So this always returns a |
| // populated Config, and CARLOS_DATA_DIR is honored (default "/data") |
| -// regardless of whether region/pools are set. |
| +// regardless of whether region/pools are set. CARLOS_INBOUND (design §8) is |
| +// the same shape again: unset/"any" is today's behaviour exactly, and it is |
| +// a declaration about what work THIS BOX will accept, independent of a |
| +// record's own "no-inbound" placement request — see ReconcileConfig.Inbound. |
| func provisionTickConfig(getenv func(string) string) provision.ReconcileConfig { |
| region := strings.TrimSpace(getenv("CARLOS_REGION")) |
| var pools []string |
| @@ -1543,11 +1546,20 @@ func provisionTickConfig(getenv func(string) string) provision.ReconcileConfig { |
| if dd == "" { |
| dd = "/data" |
| } |
| + // CARLOS_INBOUND is the same apps-domain-style inert gate as |
| + // CARLOS_REGION/CARLOS_POOLS above: unset (or "any") is today's |
| + // behaviour exactly, a legal "no constraint" state, never a "feature not |
| + // configured" one. Read verbatim, not trimmed against a closed |
| + // vocabulary here — provision.Reconcile/ReconcileSidecars are what |
| + // compare it against InboundNone, so this stays a pure passthrough like |
| + // Region above it. |
| + inbound := strings.TrimSpace(getenv("CARLOS_INBOUND")) |
| return provision.ReconcileConfig{ |
| Region: region, |
| Pools: pools, |
| DataDir: dd, |
| Now: time.Now, |
| + Inbound: inbound, |
| } |
| } |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 6f442c850..be918e582 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -784,6 +784,29 @@ func TestProvisionTickConfig(t *testing.T) { |
| } |
| } |
| +// TestProvisionTickConfigReadsInbound pins CARLOS_INBOUND's contract: an |
| +// unset var is the SAME apps-domain-style inert gate CARLOS_REGION/ |
| +// CARLOS_POOLS already use — a legal "no constraint" state, never a |
| +// "feature not configured" one — so it must read back as "" (today's |
| +// behaviour, byte for byte), and only an explicit "none" changes Config. |
| +func TestProvisionTickConfigReadsInbound(t *testing.T) { |
| + env := func(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| + } |
| + |
| + got := provisionTickConfig(env(map[string]string{ |
| + "CARLOS_REGION": "ie", "CARLOS_POOLS": "eleven", "CARLOS_INBOUND": "none", |
| + })) |
| + if got.Inbound != "none" { |
| + t.Errorf("Inbound = %q, want none", got.Inbound) |
| + } |
| + |
| + got = provisionTickConfig(env(map[string]string{})) |
| + if got.Inbound != "" { |
| + t.Fatal("an unset CARLOS_INBOUND must be an inert default — today's behaviour exactly") |
| + } |
| +} |
| + |
| func TestBootstrapIdempotent(t *testing.T) { |
| root := t.TempDir() |
| if err := bootstrap(root, nil); err != nil { |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index fee9be573..075db51bb 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -44,7 +44,10 @@ type ReconcileConfig struct { |
| // 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. |
| + // Read by ReconcileSidecars' placement gate (matches a record's own |
| + // "no-inbound" placement request against this box) AND by Reconcile's |
| + // mint gate below (refuses ALL routed instance minting outright — an |
| + // ordinary instance record carries no placement vocabulary to match). |
| 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 |
| @@ -519,6 +522,21 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| existing, ok := byHost[rec.Host] |
| switch { |
| case !ok: |
| + // A box that declared CARLOS_INBOUND=none (design §8) has told |
| + // this pass what work it will ACCEPT, not made a claim about its |
| + // network — fleet boxes are already outbound-only dial in this |
| + // estate. Ordinary instance records carry no placement |
| + // vocabulary of their own (that is ReconcileSidecars' gate, |
| + // Task 9, over SidecarRecord.Placement); every routed instance |
| + // this pass mints needs inbound service, so a no-inbound box |
| + // must refuse ALL of them outright, loudly, rather than mint |
| + // something it can never serve — a misfiled record becomes a |
| + // log line here, never a silent outage. |
| + if cfg.Inbound == InboundNone { |
| + log.Printf("provision: refusing to mint route %s (app %q acct %q): this box declares "+ |
| + "CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account) |
| + continue |
| + } |
| // The route's whole shape now comes from the record (routeFor): |
| // Kind, Addr, Unit, Environment, StatusNamed, Hibernate and the |
| // path derivation. A record carrying none of them still mints the |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index f9193a80d..099aee210 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -1,11 +1,15 @@ |
| package provision |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| + "log" |
| + "os" |
| "path/filepath" |
| "reflect" |
| + "strings" |
| "testing" |
| "time" |
| @@ -1898,3 +1902,53 @@ func TestShapeMismatchComparesAddrsAndHealth(t *testing.T) { |
| t.Fatal("equal shapes must match") |
| } |
| } |
| + |
| +// --- inbound placement (design §8, box half) -------------------------------- |
| + |
| +// TestReconcileNoInboundBoxRefusesRoutedInstances pins the instance pass's |
| +// half of design §8: a box that has declared CARLOS_INBOUND=none must never |
| +// mint a routed instance row, no matter what an account's own record asks |
| +// for — a box that cannot serve inbound traffic must never be handed |
| +// something to serve. This is independent of ReconcileSidecars' placement |
| +// gate (Task 9): that gate matches a RECORD's placement request against the |
| +// box; this one refuses ALL ordinary instance minting outright, because an |
| +// ordinary instance record carries no placement vocabulary at all. |
| +func TestReconcileNoInboundBoxRefusesRoutedInstances(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", Inbound: InboundNone} |
| + |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil { |
| + t.Fatalf("a no-inbound box minted a routed instance: %+v %v", rt, err) |
| + } |
| + if !strings.Contains(logged.String(), "acme.go.tito.io") || !strings.Contains(logged.String(), "no-inbound") { |
| + t.Fatalf("refusal must be logged loudly (host + reason), got: %s", logged.String()) |
| + } |
| +} |
| + |
| +// TestReconcileUnsetInboundMintsAsBefore pins the inert-default half: a box |
| +// whose CARLOS_INBOUND is unset (the zero value read by provisionTickConfig) |
| +// must mint exactly as it always has — the new guard must never fire for |
| +// "any"/"" boxes, which is every box that exists today. |
| +func TestReconcileUnsetInboundMintsAsBefore(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustGet(t, reg, "acme.go.tito.io") |
| +} |