carlos / platform Public

Merge branch 'hosting-capability-wire' into main

claude agent, operated by paul@keymail.dev 851a531df06b165931fa088671e82c09acaad203 parent 2ace60dparent 06c972b
12 files changed, +517 −162
  • infra/modules/region-host/README.md +27 −1
  • infra/modules/region-host/variables.tf +13 −8
  • internal/console/apiinstancemove.go +37 −4
  • internal/console/apiinstancemove_test.go +6 −3
  • internal/console/apiinstances.go +23 −2
  • internal/console/instancesplacement.go +119 −60
  • internal/console/instancesplacement_test.go +73 −45
  • internal/console/placementdoors_test.go +137 −0
  • internal/edge/edge.go +28 −22
  • internal/edge/noactivator_test.go +11 −9
  • internal/host/edgesfile.go +39 −4
  • internal/host/edgesfile_test.go +4 −4

CI cancelled — superseded by a newer push — run details

diff --git a/infra/modules/region-host/README.md b/infra/modules/region-host/README.md
index ace82e086..b10c3a8c0 100644
--- a/infra/modules/region-host/README.md
+++ b/infra/modules/region-host/README.md
@@ -29,6 +29,30 @@ meant to be.
AWS_REGION=eu-west-1
```
+ That is enough to make the box an **edge**: it will terminate TLS, adopt
+ releases, converge routes and serve static sites. It is not enough to run
+ an app. If this region is to **host instances**, it also needs its own
+ data bucket — the one `tofu apply` already made and granted the host role:
+
+ ```sh
+ CARLOS_HIBERNATE_BUCKET=carlos-myco-ie-123456789012
+ CARLOS_HIBERNATE_REGION=eu-west-1
+ CARLOS_HIBERNATE_PREFIX=instances
+ ```
+
+ Without those, the hibernation activator never arms, and nothing on the
+ box ever starts an instance's process. The failure is quiet from the
+ box's side — the record is accepted, the route is minted and the binary
+ is staged, and only the request finds out — so the box now publishes
+ what it was told as `hibernation_configured` in its edges file, and the
+ console refuses instance creates into a region whose boxes report false
+ rather than accepting one that would strand. Add the variables and
+ restart `carlos-edge` and the region arms itself within a minute or two.
+
+ `CARLOS_HIBERNATE_REGION` is the AWS region of the **data bucket**, and
+ like `AWS_REGION` above it is a real AWS region name, not a placement
+ code.
+
`AWS_REGION` is the one that bites. It must name the region the
**deployment bucket** lives in, not the region this box is in. The AWS Go
SDK does not follow S3's cross-region redirects, so a box in Sydney
@@ -64,7 +88,9 @@ half never leaves your secret store and never touches a box. There is
nothing region-specific to create, copy or rotate when you add a region.
What a region does need is in the four steps above and nothing more: the
-binary, `host.env`, `carlos bootstrap`, start the edge.
+binary, `host.env`, `carlos bootstrap`, start the edge. The only thing that
+is easy to leave out is the hibernation block in step 2 — and leaving it out
+is what makes a region an edge that cannot host.
## Arming the root passes
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf
index 89dda8a1b..44553da43 100644
--- a/infra/modules/region-host/variables.tf
+++ b/infra/modules/region-host/variables.tf
@@ -40,14 +40,19 @@ variable "data_bucket_arn" {
Granting it is half the job. The box must also be TOLD the bucket, as
CARLOS_HIBERNATE_BUCKET (plus CARLOS_HIBERNATE_REGION) in
- /etc/carlos/host.env, or its activator never arms and the box is an edge
- that cannot host: it will accept an instance, mint the route, stage the
- binary — and then serve 502 forever, because only an armed activator ever
- creates the socket the edge is dialling. Five of tito's six regions sat in
- exactly that state (discussion #110). The console now reads the box's
- published data_bucket as its hosting signal, so a region that has not been
- told is reported unarmed and refused at create rather than accepted and
- stranded.
+ /etc/carlos/host.env, or its hibernation activator never arms and the box
+ is an edge that cannot host: it will accept an instance, mint the route
+ and stage the binary, and then nothing on it ever starts the process.
+
+ This module does not write host.env — user_data is yours — so it is a
+ step that has to be remembered, and the bring-up README's minimum env
+ does not list it.
+
+ The box publishes what it was told, as hibernation_configured in its
+ control/edges/<box>.json, and the console reads that: a region whose
+ boxes report false is refused at instance create rather than accepted and
+ stranded. The field reports CONFIGURATION only — a box naming a bucket
+ its IAM role cannot reach still reports true and still fails every wake.
EOT
type = string
}
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 23e674b7c..0c7466c91 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -110,6 +110,25 @@ func armedCodes(set map[string]bool) string {
return strings.Join(codes, ", ")
}
+// stagingCodes is armedCodes for the move door's stricter bar: the regions
+// that publish a staging bucket, which is the set a move can actually name.
+// A separate renderer rather than a flag on armedCodes, because a refusal
+// that listed "armed" regions a move still could not use would send the
+// caller round the same loop again.
+func stagingCodes(caps map[string]placementCapability) string {
+ codes := make([]string, 0, len(caps))
+ for c, cap := range caps {
+ if cap.Reachable && cap.Staging {
+ codes = append(codes, c)
+ }
+ }
+ slices.Sort(codes)
+ if len(codes) == 0 {
+ return "(none)"
+ }
+ return strings.Join(codes, ", ")
+}
+
// homeRegionIn/Out translate between the home region's NAME (Config's
// CONSOLE_HOME_REGION, what pickers and callers speak — issue #346) and the
// record's "" (the unconstrained value the fleet's reconcile has always
@@ -184,13 +203,27 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
// The armed gate is MANDATORY for moves — unlike create there is no
// dormant path, because a move needs a live target box to restore on;
// a deployment with no armed placements cannot be a move target at all.
- set, armed := armedPlacements(r.Context(), s.cfg.Releases)
- if !armed {
+ //
+ // And the bar here is STAGING, not merely hosting: the requirement is
+ // unconditional and is the target's published data-bucket coordinates,
+ // because that is precisely what the worker on the other side demands.
+ // internal/host's moveTargets skips any target whose edges file carries
+ // no data_bucket, and runMoveRound then answers "target region %s
+ // publishes no data bucket yet — waiting" on every pass, forever. Before
+ // this the door accepted such a target on reachability alone and the
+ // move wedged in preparing with no way out but a manual cancel — the
+ // door and the worker disagreeing about the same fact, which is the
+ // failure this whole family of gates exists to prevent. Unknown is not
+ // an acceptable answer for a move the way it is for a create: a create
+ // into an unconfigured box strands one instance, while a move stakes a
+ // LIVE instance's residence on a lane that does not exist.
+ caps, published := placements(r.Context(), s.cfg.Releases)
+ if !published {
jsonError(w, http.StatusBadRequest, "this deployment publishes no armed regions — nothing to move to")
return
}
- if !set[to] {
- jsonError(w, http.StatusBadRequest, "region "+to+" is not armed in this deployment — armed: "+armedCodes(set))
+ if !caps[to].Reachable || !caps[to].Staging {
+ jsonError(w, http.StatusBadRequest, "region "+to+" cannot take a move in this deployment — it publishes no staging bucket. Regions that can: "+stagingCodes(caps))
return
}
s.moveTransition(w, r, acct, app, host, provision.MoveRequest(to, time.Now()), http.StatusAccepted)
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index 6b4a8a136..be75ef761 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -38,9 +38,12 @@ func TestAPIInstanceMoveVerbs(t *testing.T) {
put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"data-uk","data_bucket_region":"eu-west-2","hosts":{}}`)
moveURL := u + "/acme.go.tito.io/move"
- // Unarmed target → 400 naming the set.
- if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"se"}`); code != http.StatusBadRequest || !strings.Contains(out, "armed: uk") {
- t.Fatalf("move to unarmed = %d %s, want 400 naming the set", code, out)
+ // A target with no staging bucket → 400 naming the set that can take one.
+ // The move door's bar is the target's published data_bucket, because
+ // that is exactly what internal/host's moveTargets demands before a move
+ // can even be prepared.
+ if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"se"}`); code != http.StatusBadRequest || !strings.Contains(out, "Regions that can: uk") {
+ t.Fatalf("move to a region with no staging bucket = %d %s, want 400 naming the set", code, out)
}
// Same region → 409 (the transition's own conflict).
if code, _, out := rawReq(t, http.MethodPost, u+"/acme.go.tito.io/move", token, `{"region":"ie"}`); code != http.StatusBadRequest && code != http.StatusConflict {
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index de186a440..3b648aaab 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -667,10 +667,31 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// 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.
+ //
+ // The gate has TWO bars, and which one applies depends on what is being
+ // created. Every kind needs the region to be REACHABLE — a box nothing
+ // can dial homes nothing. Only a socket-backed instance additionally
+ // needs the region to HOST, because only that kind is spawned by an
+ // activator: a static route is served from bucket bytes by every armed
+ // edge with no process at all, and an addr-backed service proxies to a
+ // process the platform never starts. Gating those two on hosting would
+ // refuse a perfectly serveable static site in a region for want of a
+ // hibernation bucket it would never have used. The predicate is
+ // provision.Record's own (DerivesPaths), asked of a probe record rather
+ // than restated here, so it cannot drift from what reconcile will mint.
if placed == "" && region != "" {
- if set, published := armedPlacements(ctx, s.cfg.Releases); published && !set[region] {
+ probe := provision.Record{Kind: strings.TrimSpace(req.Kind), Addrs: req.Addrs}
+ caps, published := placements(ctx, s.cfg.Releases)
+ switch {
+ case !published:
+ // Dormant: the fleet predates the placement field.
+ case !caps[region].Reachable:
+ jsonError(w, http.StatusBadRequest,
+ "region "+region+" is not armed in this deployment — armed: "+armedCodes(reachableSet(caps)))
+ return
+ case probe.DerivesPaths() && !caps[region].Hosting:
jsonError(w, http.StatusBadRequest,
- "region "+region+" is not armed in this deployment — armed: "+armedCodes(set))
+ "region "+region+" is reachable but cannot host an instance — no box there has its hibernation activator configured. Regions that can: "+armedCodes(hostingSet(caps)))
return
}
}
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go
index 18d9d9824..ba26f71d9 100644
--- a/internal/console/instancesplacement.go
+++ b/internal/console/instancesplacement.go
@@ -40,10 +40,19 @@ type edgesFilePlacementView struct {
Placement string `json:"placement"`
// DataBucket is the box's CARLOS_HIBERNATE_BUCKET, verbatim
// (internal/host/edgesfile.go writes it from that env and omits the key
- // entirely when it is unset). It is read here as the box's own answer to
- // "can I run an instance" — see armedPlacements.
- DataBucket string `json:"data_bucket"`
- Hosts map[string]json.RawMessage `json:"hosts"`
+ // entirely when it is unset). It carries the move lane's actual
+ // coordinates, and doubles as positive hosting evidence from a writer
+ // too old to publish HibernationConfigured.
+ DataBucket string `json:"data_bucket"`
+ // HibernationConfigured is the box's own answer to "is my hibernation
+ // activator wired" (internal/host/edgesfile.go). A POINTER because
+ // absent and false are different facts and the difference is the whole
+ // point: absent is a writer that predates the field, whose capability is
+ // UNKNOWN, while false is a box that published the field and said no.
+ // Reading absence as no is what made an earlier version withdraw healthy
+ // regions mid-roll — see placements.
+ HibernationConfigured *bool `json:"hibernation_configured"`
+ Hosts map[string]json.RawMessage `json:"hosts"`
}
// loadEdgesJoin folds every readable edges file into host → the boxes that
@@ -101,70 +110,120 @@ func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementVi
return out
}
-// armedPlacements folds the fleet's edges files into every published
-// placement code and whether it can take an instance today (issue #329):
-// armed means some box publishing the code can be reached AND can host, so
-// set[code] false is a region that EXISTS but cannot land one, which the
-// regions endpoint (#346) shows and the create door refuses alike. The
-// second return reports whether ANY file carried a placement code at all:
-// false means the fleet predates the field (or the read failed) and the
-// create gate stays DORMANT — the door then accepts what it always
-// accepted, so rolling boxes and console in either order is safe.
+// placementCapability is what one placement code's boxes can do, folded
+// across every box publishing that code. Three separate facts, because the
+// three doors that consult them are asking three different questions, and
+// conflating those is how a static site came to be refused for want of an
+// activator it never needed.
+type placementCapability struct {
+ // Reachable: some box publishing this code has a dial address. A box
+ // nothing can reach is not a landing target for anything.
+ Reachable bool
+ // Hosting: some reachable box publishing this code has its hibernation
+ // activator configured. Only a SOCKET-BACKED INSTANCE needs this — a
+ // static route is served from bucket bytes by every edge, and an
+ // addr-backed service proxies to a process the platform never spawns.
+ Hosting bool
+ // Staging: some reachable box publishing this code publishes actual
+ // data-bucket COORDINATES, which is the move door's requirement and is
+ // strictly stronger than Hosting. internal/host's moveTargets drops any
+ // target whose file carries no data_bucket, and runMoveRound then
+ // reports "target region %s publishes no data bucket yet — waiting" for
+ // as long as the record lives. A door admitting such a move promises
+ // something the worker refuses forever.
+ Staging bool
+}
+
+// placements folds the fleet's edges files into each published placement
+// code's capability (issue #329, discussion #110). The second return reports
+// whether ANY file carried a placement code at all: false means the fleet
+// predates the field (or the read failed) and the create gate stays DORMANT,
+// accepting what it always accepted, so rolling boxes and console in either
+// order is safe.
//
-// Reachable is an EIP: a box nothing can dial is not a landing target.
+// The hosting question is answered PER BOX, from that box's own
+// hibernation_configured field, and is never inferred across boxes. That
+// distinction is what discussion #110's review paid for. An earlier version
+// keyed hosting on data_bucket alone — and because an absent data_bucket is
+// exactly what an OLD WRITER and a bucket-less new box both produce, it had
+// to decide when silence meant "no". It decided by asking whether any OTHER
+// box had spoken, so the first box in a fleet to adopt a bucket-publishing
+// build withdrew every region that had not yet rolled, all of them healthy:
+// minutes for an uninterrupted fleet-wide roll, hours at one box an hour,
+// unbounded if a roll stalled. One writer's upgrade cannot establish another
+// writer's capabilities.
//
-// Can host is a published data bucket, and it is the half discussion #110
-// paid for. Before it, armed was reachability alone — an EDGE-MEMBERSHIP
-// fact — while every consumer of this set reads it as "a region that can
-// take an instance right now". On tito those two diverged for five of six
-// regions: the boxes served TLS, converged routes and staged binaries
-// perfectly, so they were reachable and published their placement codes,
-// but none had been told a CARLOS_HIBERNATE_BUCKET. Their activators never
-// armed. An instance created into one of them was accepted, placed, staged
-// and routed, and then answered 502 forever, because the edge proxies a
-// hibernate-flagged route straight to a socket that only an armed
-// activator would ever create.
+// So each file answers only for itself, and UNKNOWN is not NO:
//
-// data_bucket is the box's own answer and not an inference: internal/host's
-// edges-file pass writes CARLOS_HIBERNATE_BUCKET into it verbatim and omits
-// the key when that env is unset, and it is the SAME env, read in the same
-// process, that decides whether activator.ConfigFromEnv arms the activator
-// at all (internal/host/agent.go). So a published data bucket means that
-// box's activator is armed, which is exactly the question a region picker
-// is asking. It heals with no console change: the moment a region box's
-// host.env grows the variable, its next edges-file pass publishes the key
-// and the region arms itself.
+// hibernation_configured present → that box's own answer, believed
+// absent → a writer predating the field: unknown,
+// and it keeps the eligibility it had
+// before the field existed
//
-// DORMANT until some box publishes one, the same roll-order rule the
-// placement field itself got and for the same reason. A fleet whose boxes
-// all predate the data_bucket field would otherwise go from "every region
-// armed" to "no region armed" the moment this console rolled, which is a
-// worse failure than the one it fixes — it would refuse creates into
-// regions that host perfectly well. With the gate dormant such a fleet
-// keeps the reachability-only answer it has always had.
-func armedPlacements(ctx context.Context, s release.Store) (set map[string]bool, published bool) {
- files := readEdgesFiles(ctx, s)
- // The dormancy read, taken over every file before any verdict: one box
- // publishing a data bucket is what makes the fleet's silence on the
- // field meaningful for every OTHER box.
- hostingKnown := false
- for _, f := range files {
- if f.DataBucket != "" {
- hostingKnown = true
- break
- }
- }
- set = map[string]bool{}
- for _, f := range files {
+// That last line is what makes a box roll safe in either direction, and it
+// is deliberately generous. A genuinely unconfigured box on an old build
+// stays eligible until it rolls and says so, so a create into it can still
+// strand — but that is the bug we already had, whereas refusing creates into
+// regions that host perfectly well would be a new one, caused BY the fix.
+//
+// None of these three is a promise that a wake will SUCCEED. A box whose IAM
+// role cannot reach the bucket it names, or whose CARLOS_HIBERNATE_REGION is
+// wrong, publishes hibernation_configured true and fails every lease. This
+// is configuration, which is all an edges file can honestly carry.
+func placements(ctx context.Context, s release.Store) (caps map[string]placementCapability, published bool) {
+ caps = map[string]placementCapability{}
+ for _, f := range readEdgesFiles(ctx, s) {
if f.Placement == "" {
continue
}
published = true
- armed := f.EIP != ""
- if hostingKnown {
- armed = armed && f.DataBucket != ""
+ c := caps[f.Placement]
+ // Every capability below is gated on reachability: a box with no
+ // dial address contributes nothing, however well configured it is.
+ // The code is still RECORDED, so an unreachable region reads as
+ // known-and-unarmed rather than as absent.
+ if f.EIP == "" {
+ caps[f.Placement] = c
+ continue
+ }
+ // Unknown is not no. An absent hibernation_configured with no
+ // data_bucket is a writer that predates the field, and it keeps the
+ // eligibility it had before the field existed.
+ hosting := true
+ if f.HibernationConfigured != nil {
+ hosting = *f.HibernationConfigured
}
- set[f.Placement] = set[f.Placement] || armed
+ c.Reachable = true
+ c.Hosting = c.Hosting || hosting
+ c.Staging = c.Staging || f.DataBucket != ""
+ caps[f.Placement] = c
+ }
+ return caps, published
+}
+
+// armedPlacements is the picker answer in the shape its callers have always
+// taken: code -> "can take an instance right now", which is Reachable AND
+// Hosting.
+func armedPlacements(ctx context.Context, s release.Store) (set map[string]bool, published bool) {
+ caps, published := placements(ctx, s)
+ return hostingSet(caps), published
+}
+
+// hostingSet and reachableSet project a capability map onto the one axis a
+// particular refusal is about, so the list a caller is handed names regions
+// that would actually satisfy the bar they just failed.
+func hostingSet(caps map[string]placementCapability) map[string]bool {
+ set := make(map[string]bool, len(caps))
+ for code, c := range caps {
+ set[code] = c.Reachable && c.Hosting
+ }
+ return set
+}
+
+func reachableSet(caps map[string]placementCapability) map[string]bool {
+ set := make(map[string]bool, len(caps))
+ for code, c := range caps {
+ set[code] = c.Reachable
}
- return set, published
+ return set
}
diff --git a/internal/console/instancesplacement_test.go b/internal/console/instancesplacement_test.go
index 656914dd1..748c91100 100644
--- a/internal/console/instancesplacement_test.go
+++ b/internal/console/instancesplacement_test.go
@@ -5,16 +5,16 @@ import (
"testing"
)
-// The hosting half of armed (discussion #110). Before it, armed was
-// reachability alone — an edge-membership fact — while every consumer reads
-// the set as "regions that can take an instance right now". On tito those two
-// diverged for five of six regions: reachable boxes that had never been told
-// a CARLOS_HIBERNATE_BUCKET, so their activators never armed, so an instance
-// placed there was accepted, staged and routed and then served 502 forever. A
-// box's published data_bucket is its own answer to "can I host", and it is
-// the same env that arms the activator.
-func TestArmedPlacementsRequiresHosting(t *testing.T) {
- fx, _ := instanceFixture(t, "code-inst-armedhosting")
+// Hosting is answered PER BOX, from that box's own hibernation_configured
+// field, and is never inferred from what another box publishes. An earlier
+// version keyed hosting on data_bucket alone, and because an absent
+// data_bucket is what an OLD WRITER and a bucket-less new box both produce,
+// it decided when silence meant "no" by asking whether any OTHER box had
+// spoken. One box adopting a bucket-publishing build then withdrew every
+// region that had not yet rolled, all of them healthy. These tests pin the
+// three states apart.
+func TestPlacementsAnswersHostingPerBox(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-inst-placementcaps")
ctx := context.Background()
put := func(key, body string) {
t.Helper()
@@ -23,53 +23,81 @@ func TestArmedPlacementsRequiresHosting(t *testing.T) {
}
}
- // Dormant: no box in the fleet publishes a data bucket at all, so the
- // hosting gate must not fire and armed stays exactly the reachability
- // answer these boxes have always had. A fleet whose boxes predate the
- // field must not lose every region the day this console rolls.
+ // uk: an old writer — no hibernation_configured, no data_bucket. UNKNOWN,
+ // and unknown keeps the eligibility it had before the field existed.
put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hosts":{}}`)
- put("control/edges/se-box.json", `{"v":1,"box":"se-box","eip":"","placement":"se","hosts":{}}`)
- set, published := armedPlacements(ctx, fx.rel)
+ // ie: a new writer that can host.
+ put("control/edges/ie-box.json", `{"v":1,"box":"ie-box","eip":"5.6.7.8","placement":"ie","data_bucket":"carlos-ie","hibernation_configured":true,"hosts":{}}`)
+ // de: a new writer that CANNOT host, and says so explicitly. This is the
+ // state no earlier version could express.
+ put("control/edges/de-box.json", `{"v":1,"box":"de-box","eip":"9.9.9.9","placement":"de","hibernation_configured":false,"hosts":{}}`)
+ // se: reachable by nothing.
+ put("control/edges/se-box.json", `{"v":1,"box":"se-box","eip":"","placement":"se","data_bucket":"carlos-se","hibernation_configured":true,"hosts":{}}`)
+
+ caps, published := placements(ctx, fx.rel)
if !published {
- t.Fatal("published = false, want true: both files carry a placement code")
+ t.Fatal("published = false, want true")
}
- if !set["uk"] {
- t.Error("uk unarmed while the fleet publishes no data bucket at all — the gate must stay dormant")
+
+ // The whole point: ie publishing a bucket must not make uk's silence
+ // mean "no". uk stays eligible until uk itself says otherwise.
+ if !caps["uk"].Hosting {
+ t.Error("uk not hosting: it is an OLD WRITER with no opinion, and one box's upgrade must not withdraw another box's region")
}
- if set["se"] {
- t.Error("se armed with no EIP — the reachability half must still apply")
+ if !caps["ie"].Hosting || !caps["ie"].Staging {
+ t.Errorf("ie = %+v, want hosting and staging", caps["ie"])
}
-
- // One box publishes a data bucket. That is what makes every other box's
- // silence on the field meaningful, so uk — reachable, but never told a
- // hibernate bucket — is now correctly unarmed.
- put("control/edges/ie-box.json", `{"v":1,"box":"ie-box","eip":"5.6.7.8","placement":"ie","data_bucket":"carlos-tito-ie-1","hosts":{}}`)
- set, published = armedPlacements(ctx, fx.rel)
- if !published {
- t.Fatal("published = false, want true")
+ if caps["de"].Hosting {
+ t.Error("de hosting: it published hibernation_configured false, which is an explicit no")
}
- if !set["ie"] {
- t.Error("ie unarmed: it is reachable and publishes a data bucket")
+ if !caps["de"].Reachable {
+ t.Error("de unreachable: it has an EIP — a box that cannot host is still an edge")
}
- if set["uk"] {
- t.Error("uk armed: reachable, but it publishes no data bucket, so its activator is not armed and an instance placed there would serve 502 forever")
+ if caps["se"].Reachable || caps["se"].Hosting || caps["se"].Staging {
+ t.Errorf("se = %+v, want nothing: no EIP gates every capability", caps["se"])
}
- if set["se"] {
- t.Error("se armed: neither reachable nor hosting")
+
+ // Staging is strictly stronger than Hosting, and uk is where they part:
+ // eligible to host (unknown), but publishing no bucket coordinates, so
+ // no move can name it — internal/host's moveTargets would drop it and
+ // the worker would wait forever.
+ if caps["uk"].Staging {
+ t.Error("uk staging: it publishes no data_bucket, so a move there could never be prepared")
}
+}
- // A region arms itself the moment its box's host.env grows the variable —
- // no console change and no console restart, just the box's next
- // edges-file pass republishing the file it always publishes.
- _, etag, err := fx.rel.Get(ctx, "control/edges/uk-box.json")
- if err != nil {
- t.Fatal(err)
+// An explicit false is believed even when a SIBLING box in the same region
+// can host: the fold is an OR across boxes, so one capable box arms the
+// region, and that is correct — the record only needs one box to land on.
+func TestPlacementsFoldsAcrossBoxesInARegion(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-inst-placementfold")
+ ctx := context.Background()
+ put := func(key, body string) {
+ t.Helper()
+ if _, err := fx.rel.Put(ctx, key, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
}
- if _, err := fx.rel.Put(ctx, "control/edges/uk-box.json",
- []byte(`{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","data_bucket":"carlos-tito-uk-1","hosts":{}}`), etag); err != nil {
+ put("control/edges/uk-a.json", `{"v":1,"box":"uk-a","eip":"1.1.1.1","placement":"uk","hibernation_configured":false,"hosts":{}}`)
+ put("control/edges/uk-b.json", `{"v":1,"box":"uk-b","eip":"2.2.2.2","placement":"uk","data_bucket":"carlos-uk","hibernation_configured":true,"hosts":{}}`)
+
+ caps, _ := placements(ctx, fx.rel)
+ if !caps["uk"].Hosting || !caps["uk"].Staging {
+ t.Errorf("uk = %+v, want hosting and staging: one capable box in the region is enough", caps["uk"])
+ }
+}
+
+// The dormancy that remains is the one the placement field itself shipped
+// with, and it is about placement codes, not about hosting: a fleet where no
+// box publishes a code at all leaves the create gate entirely dormant.
+func TestPlacementsDormantWithoutAnyPlacementCode(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-inst-placementdormant")
+ ctx := context.Background()
+ if _, err := fx.rel.Put(ctx, "control/edges/solo.json",
+ []byte(`{"v":1,"box":"solo","eip":"1.2.3.4","hosts":{}}`), ""); err != nil {
t.Fatal(err)
}
- if set, _ = armedPlacements(ctx, fx.rel); !set["uk"] {
- t.Error("uk still unarmed after publishing a data bucket")
+ if _, published := placements(ctx, fx.rel); published {
+ t.Error("published = true with no placement code anywhere — the gate must stay dormant")
}
}
diff --git a/internal/console/placementdoors_test.go b/internal/console/placementdoors_test.go
new file mode 100644
index 000000000..da30abc65
--- /dev/null
+++ b/internal/console/placementdoors_test.go
@@ -0,0 +1,137 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// edgesFor writes a fleet's edges files and returns the fixture's put helper
+// shape, so each door test states its fleet in one place.
+func putEdges(t *testing.T, fx *consoleFixture, files map[string]string) {
+ t.Helper()
+ for name, body := range files {
+ if _, err := fx.rel.Put(context.Background(), "control/edges/"+name+".json", []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// Only a SOCKET-BACKED INSTANCE needs an armed activator on the target box.
+// A static route is served from bucket bytes by every armed edge with no
+// process at all, so refusing one for want of a hibernation bucket refuses a
+// site that would have served perfectly. The gate ran before the record's
+// kind was even read, so it applied to every kind alike.
+func TestCreateDoorLetsStaticIntoANonHostingRegion(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-statictonohost")
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ // uk is reachable and explicitly cannot host; ie can.
+ putEdges(t, fx, map[string]string{
+ "uk-box": `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hibernation_configured":false,"hosts":{}}`,
+ "ie-box": `{"v":1,"box":"ie-box","eip":"5.6.7.8","placement":"ie","data_bucket":"carlos-ie","hibernation_configured":true,"hosts":{}}`,
+ })
+ u := instancesURL(fx, "bab", "titogo")
+
+ // A static site into uk: allowed. It needs an edge, not an activator.
+ code, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"brochure.go.tito.io","region":"uk","kind":"static"}`)
+ if code != http.StatusCreated {
+ t.Fatalf("create static into a reachable non-hosting region = %d %s, want 201 — a static route runs no process", code, out)
+ }
+
+ // An instance into the same region: refused, and the refusal says why
+ // and lists somewhere that would work.
+ code, _, out = rawReq(t, http.MethodPost, u, token,
+ `{"host":"app.go.tito.io","region":"uk"}`)
+ if code != http.StatusBadRequest {
+ t.Fatalf("create instance into a non-hosting region = %d %s, want 400", code, out)
+ }
+ if !strings.Contains(out, "cannot host") || !strings.Contains(out, "ie") {
+ t.Errorf("refusal %q should say the region cannot host and name one that can", out)
+ }
+}
+
+// An addr-backed service proxies to a process the platform never spawns, so
+// it needs the region to exist, not to host.
+func TestCreateDoorLetsAddrBackedServiceIntoANonHostingRegion(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-svctonohost")
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ putEdges(t, fx, map[string]string{
+ "uk-box": `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hibernation_configured":false,"hosts":{}}`,
+ })
+ code, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"svc.go.tito.io","region":"uk","kind":"service","addrs":["10.0.0.4:8080"]}`)
+ if code != http.StatusCreated {
+ t.Fatalf("create addr-backed service into a non-hosting region = %d %s, want 201", code, out)
+ }
+}
+
+// An UNREACHABLE region is refused for every kind — a box nothing can dial
+// homes nothing, static included.
+func TestCreateDoorRefusesEveryKindIntoAnUnreachableRegion(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-unreachable")
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ putEdges(t, fx, map[string]string{
+ "se-box": `{"v":1,"box":"se-box","eip":"","placement":"se","hosts":{}}`,
+ "ie-box": `{"v":1,"box":"ie-box","eip":"5.6.7.8","placement":"ie","data_bucket":"carlos-ie","hibernation_configured":true,"hosts":{}}`,
+ })
+ code, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token,
+ `{"host":"brochure.go.tito.io","region":"se","kind":"static"}`)
+ if code != http.StatusBadRequest || !strings.Contains(out, "not armed") {
+ t.Fatalf("create static into an unreachable region = %d %s, want 400 not-armed", code, out)
+ }
+}
+
+// The move door's bar is the target's published STAGING bucket, and it is
+// unconditional. internal/host's moveTargets drops any target whose edges
+// file carries no data_bucket, and runMoveRound then answers "publishes no
+// data bucket yet — waiting" forever, so a door that admitted the move would
+// wedge a LIVE instance's residence on a lane that does not exist.
+func TestMoveDoorRequiresAStagingBucket(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-movestaging")
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ // uk is an old writer: reachable, eligible to HOST (unknown is not no),
+ // but it publishes no bucket coordinates, so it can take no move.
+ putEdges(t, fx, map[string]string{
+ "uk-box": `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hosts":{}}`,
+ "ie-box": `{"v":1,"box":"ie-box","eip":"5.6.7.8","placement":"ie","data_bucket":"carlos-ie","hibernation_configured":true,"hosts":{}}`,
+ })
+ u := instancesURL(fx, "bab", "titogo")
+
+ // uk IS a legal create target — unknown hosting stays eligible. This
+ // instance is created in ie, though, so the move below is a genuine
+ // cross-region request and cannot be refused by the same-region conflict
+ // instead of by the bar under test.
+ if code, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"mover.go.tito.io","region":"ie"}`); code != http.StatusCreated {
+ t.Fatalf("create into ie = %d %s, want 201", code, out)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"stayer.go.tito.io","region":"uk"}`); code != http.StatusCreated {
+ t.Fatalf("create into an unknown-hosting region = %d %s, want 201 — unknown is not no", code, out)
+ }
+ // uk is NOT a legal move target: it publishes no staging bucket.
+ code, _, out := rawReq(t, http.MethodPost, u+"/mover.go.tito.io/move", token, `{"region":"uk"}`)
+ if code != http.StatusBadRequest {
+ t.Fatalf("move to a region with no staging bucket = %d %s, want 400", code, out)
+ }
+ if !strings.Contains(out, "staging bucket") {
+ t.Errorf("refusal %q should name the missing staging bucket — the worker's own reason", out)
+ }
+ if !strings.Contains(out, "ie") {
+ t.Errorf("refusal %q should list the regions that can take a move", out)
+ }
+}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index e11c2a27e..16b7751a3 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -651,32 +651,38 @@ func (e *Edge) Reload() error {
if hib {
managed[t.Host] = true
}
- // A hibernate-flagged instance route on a box with NO activator is
- // unservable, and silently so — which is what discussion #110 cost.
- // The row is real, the binary is staged, the socket path is derived,
- // and nothing will ever create that socket, because only an armed
- // activator spawns the child that binds it. Every request falls into
- // unixProxy's dial and comes back as the stdlib ReverseProxy's bare
- // 502 — a status that reads as "your backend is broken" for a
- // backend that was never going to exist. On tito this ran for
- // fifteen minutes across five regions before anyone opened an SSM
- // session, and the box knew the answer the whole time.
+ // A hibernate-flagged instance route on a box with no activator has
+ // nothing on this box that would ever spawn its process: the row is
+ // real and the socket path is derived, but only an armed activator
+ // starts the child that binds it, so a request that reaches this
+ // route here falls into unixProxy's dial and comes back as the
+ // stdlib ReverseProxy's bare, bodiless 502 — a status that reads as
+ // "your backend is broken" about a backend nothing here will start.
//
- // The fix for the CONSUMER half is the console's (armedPlacements
- // now requires a published data bucket, so such a region is not
- // offered and create refuses it). This is the box's half: say it
- // out loud, name the variable, and let anyone reading the journal
- // stop looking at the app.
+ // Diagnostic only, and deliberately careful about what it claims. It
+ // says what this box knows — an eligible route, no activator — and
+ // not that every request 502s, because it cannot see whether
+ // something else is answering: a process started outside the
+ // platform could be bound to that socket already. The one deployment
+ // shape it was written for is a box whose /etc/carlos/host.env was
+ // hand-assembled and never given CARLOS_HIBERNATE_BUCKET, which the
+ // region-host bring-up docs make easy to do.
//
- // Throttled, because it is a standing condition — true on every
- // reload for as long as the row and the missing env both exist — and
- // keyed per host, a value the box itself controls (logThrottled's
- // rule). Not an error and not a refusal to serve: `carlos edge` run
- // without the agent has no activator by design, and taking the route
- // out of the table would only trade a 502 for a 404.
+ // The advice names both roles rather than guessing which one is
+ // running, because the fix differs and only one of them has one:
+ // `carlos edge` standalone passes a nil activator regardless of the
+ // environment (RunWithActivator), so an unconditional "set this
+ // variable" would send that operator after something that cannot
+ // help them. Saying both costs one clause and is true either way.
+ //
+ // Throttled: a standing condition, true on every reload for as long
+ // as the row and the missing activator both exist. Keyed per host —
+ // a value the box itself controls, per logThrottled's rule. Not a
+ // refusal to serve: dropping the route would only trade a 502 for a
+ // 404, and the row may yet be served once an activator arms.
if e.act == nil && t.Kind == "instance" && t.Hibernate && !viaTunnel {
e.logThrottled("no-activator "+t.Host, fmt.Sprintf(
- "route %s is a hibernating instance but this box has no activator — nothing will ever create %s, so every request 502s. Set CARLOS_HIBERNATE_BUCKET in /etc/carlos/host.env and restart carlos-edge.",
+ "route %s is a hibernating instance but this box has no activator, so nothing here will create %s or start its process. If this box runs `carlos agent`, set CARLOS_HIBERNATE_BUCKET in /etc/carlos/host.env and restart carlos-edge; `carlos edge` on its own never runs instances.",
t.Host, t.Socket))
}
m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib}
diff --git a/internal/edge/noactivator_test.go b/internal/edge/noactivator_test.go
index aba0af722..9300cd573 100644
--- a/internal/edge/noactivator_test.go
+++ b/internal/edge/noactivator_test.go
@@ -7,14 +7,16 @@ import (
"github.com/carlosframework/platform/internal/registry"
)
-// A hibernate-flagged instance route on a box with no activator is
-// unservable, and before discussion #110 it was unservable in silence: the
-// row is real, the socket path is derived, and nothing will ever create that
-// socket, so every request comes back as unixProxy's bare stdlib 502. On tito
-// that ran for fifteen minutes across five regions before anyone opened an
-// SSM session, and the box knew the answer the whole time. Reload must name
-// the condition, the host, the socket nobody will bind, and the variable that
-// fixes it.
+// A hibernate-flagged instance route on a box with no activator has nothing
+// on that box that would ever spawn its process, and before this it said so
+// nowhere: the row is real and the socket path is derived, so a request
+// reaching it here dials a socket nothing will bind and comes back as a
+// bodiless 502 that reads as a broken backend. Reload must name the host,
+// the socket, and the fix.
+//
+// The message is deliberately careful about what it claims — it reports what
+// this box knows, not that every request 502s, since it cannot see whether
+// something outside the platform is already bound to that socket.
func TestReloadNamesAHibernatingRouteWithNoActivator(t *testing.T) {
buf := captureLog(t)
@@ -39,7 +41,7 @@ func TestReloadNamesAHibernatingRouteWithNoActivator(t *testing.T) {
"region-probe.example.com",
"/run/carlos/region-probe.example.com.sock",
"CARLOS_HIBERNATE_BUCKET",
- "502",
+ "carlos edge",
} {
if !strings.Contains(got, want) {
t.Errorf("reload log does not mention %q — an operator reading the journal still cannot tell this from a broken app.\ngot: %s", want, got)
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index e6b89b944..dd5e55791 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -94,10 +94,40 @@ type edgesFile struct {
// from the same file set everything else reads (instance-move spec).
// Additive + omitempty like Placement; a box with no hibernate bucket
// (activator disabled) publishes neither key.
- DataBucket string `json:"data_bucket,omitempty"`
- DataBucketRegion string `json:"data_bucket_region,omitempty"`
- Generation string `json:"generation"`
- Hosts map[string]edgeHost `json:"hosts"`
+ DataBucket string `json:"data_bucket,omitempty"`
+ DataBucketRegion string `json:"data_bucket_region,omitempty"`
+ // HibernationConfigured is this box's own answer to "is my hibernation
+ // activator wired" — true exactly when CARLOS_HIBERNATE_BUCKET is set,
+ // which is the same env, read in the same process, that decides whether
+ // activator.ConfigFromEnv builds a Config at all (internal/host/agent.go
+ // arms the activator iff that Config is non-nil, and log.Fatalfs rather
+ // than continuing if the env is set but malformed — so a live agent
+ // process cannot publish this field disagreeing with its own activator).
+ //
+ // Deliberately NOT omitempty, and this is the whole point of the field.
+ // A reader needs to tell three states apart, and only an explicitly
+ // published `false` can carry the third:
+ //
+ // absent → the writer predates this field; its capability is UNKNOWN
+ // true → the activator is configured
+ // false → the activator is not configured
+ //
+ // Before it, the only signal was data_bucket, and its absence was
+ // ambiguous in exactly the way that matters: an old writer and a
+ // bucket-less new box produce byte-identical files. A console reading
+ // that had to guess, and the guess it made was to infer one box's
+ // silence from ANOTHER box's speech — so the first box in a fleet to
+ // publish a bucket withdrew every region that had not yet rolled, all
+ // of them healthy. One writer's upgrade cannot establish another
+ // writer's capabilities; this field is how each box answers for itself.
+ //
+ // It reports CONFIGURATION, not verified hosting readiness: a box whose
+ // 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"`
}
// edgesFilePass builds this box's edges file from the routes currently in
@@ -407,6 +437,11 @@ func edgesFileLoop(reg *registry.Registry) {
Placement: strings.TrimSpace(os.Getenv("CARLOS_REGION")),
DataBucket: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")),
DataBucketRegion: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")),
+ // Same env, same trim, one line apart, deliberately: this boolean
+ // must never disagree with the bucket beside it, and the way to
+ // 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")) != "",
}
log.Printf("agent: edges file loop armed (5min + jitter, %s)", edgesFileKey(box))
last := seedEdgesFileGate(ctx, store, box)
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
index d43ea1c99..58bd8d686 100644
--- a/internal/host/edgesfile_test.go
+++ b/internal/host/edgesfile_test.go
@@ -268,7 +268,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","generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":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)
@@ -310,7 +310,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) {
t.Fatal(err)
}
gen := genOf(t, got)
- want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":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)
@@ -329,7 +329,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","generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":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)
@@ -882,7 +882,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","generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","hibernation_configured":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)