regions: armed must mean "can host", not "is an edge" (#110)
Five of tito's six armed regions accepted an instance and then served 502 forever. The record was accepted, the box placed it, minted its own route, staged the binary and repointed the live symlink — and nothing ever ran it. No unit, no socket, nothing under /data, and a data bucket that was still completely empty fifteen minutes later. The empty bucket is the tell. Every wake claims an S3 lease before it does anything else, so a bucket with no lock object means no wake was ever attempted. The status code says the same thing from the other end: internal/activator's serve() answers 503 for every wake failure it has — lease held, wake timeout, spawn error — and declines the request only when the route is not a hibernating instance. A bare 502 is therefore not a failed wake. It is the edge proxying straight to the socket path the route derives, which only an armed activator would ever create, and getting the stdlib ReverseProxy's default handler. What arms an activator is CARLOS_HIBERNATE_BUCKET in the box's /etc/carlos/host.env, and nothing in this repo puts it there — not `carlos bootstrap`, not the region-host module, which grants the host role its region's data bucket and then never mentions it again. A region box brought up the documented way is an edge that cannot host. So the answer to the issue's actual question is: a region box today is edge-only, not by design but because nothing tells it otherwise and nothing checks. `armed` was the half that should have caught it. It was computed from a box publishing a placement code and having an EIP — an EDGE-MEMBERSHIP fact — while the regions endpoint, the create door and every consumer picker read the set as "regions that can take an instance right now". For five regions those two diverged completely, and from the consumer side there was no signal at all: create succeeded, placement was correct, the record looked healthy-ish, and the only symptom was a dead host. The hosting signal was already on the wire. Each box publishes data_bucket in its edges file, written from CARLOS_HIBERNATE_BUCKET verbatim and omitted when that env is unset — the SAME env, read in the same process, that decides whether the activator arms at all. So a published data bucket means that box's activator is armed, which is exactly what a region picker is asking. armedPlacements now requires it alongside the EIP. 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. Until then create refuses it by name and lists the regions that can really take one, instead of accepting an instance that will never run. DORMANT until some box publishes a data bucket, the roll-order rule the placement field itself got. A fleet whose boxes all predate the field would otherwise go from every region armed to none the moment this console rolled — refusing creates into regions that host perfectly well, which is worse than the bug being fixed. The box's own half: edge Reload now names the condition. A hibernate-flagged instance route on a box with no activator was unservable in silence — every request falling into a 13-second dial retry and coming back as a bodiless 502 that reads as "your backend is broken", for a backend that was never going to exist. It now says which host, which socket nobody will bind, and which variable fixes it, throttled because it is a standing condition. Not a refusal to serve: `carlos edge` without the agent has no activator by design, and dropping the route would only trade a 502 for a 404. Not fixed here, and both still open: per-instance DNS for a region-placed host is recorded but not built (apiinstances.go), so such a host still resolves to the home edge and arrives over the backhaul; and the console's health join reads its OWN local registry, so any route homed on another box reads "Setting up" forever regardless of region.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ggesa34CVBQ97guKaEi9jw
6 files changed,
+283
−18
infra/modules/region-host/variables.tf+15 −1internal/console/apiregions.go+3 −1internal/console/instancesplacement.go+67 −16internal/console/instancesplacement_test.go+75 −0internal/edge/edge.go+28 −0internal/edge/noactivator_test.go+95 −0
CI passed — run details
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf| index eefd8b9b5..1c9e3164e 100644 |
| --- a/infra/modules/region-host/variables.tf |
| +++ b/infra/modules/region-host/variables.tf |
| @@ -34,7 +34,21 @@ variable "deployment_bucket_arn" { |
| } |
| variable "data_bucket_arn" { |
| - description = "ARN of THIS region's data bucket — the only data bucket the host role may reach." |
| + description = <<-EOT |
| + ARN of THIS region's data bucket — the only data bucket the host role may |
| + reach. |
| + |
| + 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. |
| + EOT |
| type = string |
| } |
diff --git a/internal/console/apiregions.go b/internal/console/apiregions.go| index 59365e080..4c106bdd5 100644 |
| --- a/internal/console/apiregions.go |
| +++ b/internal/console/apiregions.go |
| @@ -17,7 +17,9 @@ import ( |
| // Codes are CARLOS placement codes, never AWS region strings. armed is |
| // EXACTLY the create door's predicate (#329's armedPlacements — a picker |
| // built on this can never offer what create would refuse): a box publishes |
| -// the code, and has a dial address. The home region appears under its NAME |
| +// the code, has a dial address, and can host (#110 — see armedPlacements |
| +// for why reachability alone advertised five regions that served 502). |
| +// The home region appears under its NAME |
| // (Config.HomeRegion) with armed true — create accepts it by normalizing |
| // the name to the record's "" — and is also the default. A deployment with |
| // no named home and no placement-publishing boxes answers empty lists, and |
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| index 41969cf15..18d9d9824 100644 |
| --- a/internal/console/instancesplacement.go |
| +++ b/internal/console/instancesplacement.go |
| @@ -33,12 +33,17 @@ 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"` |
| - EIP string `json:"eip"` |
| - Region string `json:"region"` |
| - Placement string `json:"placement"` |
| - 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"` |
| + // 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"` |
| } |
| // loadEdgesJoin folds every readable edges file into host → the boxes that |
| @@ -98,22 +103,68 @@ func readEdgesFiles(ctx context.Context, s release.Store) []edgesFilePlacementVi |
| // 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 also has a dial address (an |
| -// EIP — a box nothing can reach is not a landing target), 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. |
| +// 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. |
| +// |
| +// Reachable is an EIP: a box nothing can dial is not a landing target. |
| +// |
| +// 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. |
| +// |
| +// 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. |
| +// |
| +// 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 readEdgesFiles(ctx, s) { |
| + for _, f := range files { |
| if f.Placement == "" { |
| continue |
| } |
| published = true |
| - set[f.Placement] = set[f.Placement] || f.EIP != "" |
| + armed := f.EIP != "" |
| + if hostingKnown { |
| + armed = armed && f.DataBucket != "" |
| + } |
| + set[f.Placement] = set[f.Placement] || armed |
| } |
| return set, published |
| } |
diff --git a/internal/console/instancesplacement_test.go b/internal/console/instancesplacement_test.go| new file mode 100644 |
| index 000000000..656914dd1 |
| --- /dev/null |
| +++ b/internal/console/instancesplacement_test.go |
| @@ -0,0 +1,75 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "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") |
| + ctx := context.Background() |
| + put := func(key, body string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(ctx, key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + // 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. |
| + 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) |
| + if !published { |
| + t.Fatal("published = false, want true: both files carry a placement code") |
| + } |
| + if !set["uk"] { |
| + t.Error("uk unarmed while the fleet publishes no data bucket at all — the gate must stay dormant") |
| + } |
| + if set["se"] { |
| + t.Error("se armed with no EIP — the reachability half must still apply") |
| + } |
| + |
| + // 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 !set["ie"] { |
| + t.Error("ie unarmed: it is reachable and publishes a data bucket") |
| + } |
| + 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 set["se"] { |
| + t.Error("se armed: neither reachable nor hosting") |
| + } |
| + |
| + // 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) |
| + } |
| + 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 { |
| + t.Fatal(err) |
| + } |
| + if set, _ = armedPlacements(ctx, fx.rel); !set["uk"] { |
| + t.Error("uk still unarmed after publishing a data bucket") |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 5d3ee5b39..5277ce1cf 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -592,6 +592,34 @@ 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. |
| + // |
| + // 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. |
| + // |
| + // 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. |
| + 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.", |
| + t.Host, t.Socket)) |
| + } |
| m[t.Host] = upstream{proxy: proxy, route: t, hibernating: hib} |
| } |
| // Path routes, folded BEFORE aliases so an alias of a prefix-routed |
diff --git a/internal/edge/noactivator_test.go b/internal/edge/noactivator_test.go| new file mode 100644 |
| index 000000000..aba0af722 |
| --- /dev/null |
| +++ b/internal/edge/noactivator_test.go |
| @@ -0,0 +1,95 @@ |
| +package edge |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "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. |
| +func TestReloadNamesAHibernatingRouteWithNoActivator(t *testing.T) { |
| + buf := captureLog(t) |
| + |
| + reg := openReg(t) |
| + // Socket-backed and hibernate-flagged: exactly what provision.Reconcile |
| + // mints for a plain instance record, which is the shape that stranded. |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "region-probe.example.com", Kind: "instance", |
| + Socket: "/run/carlos/region-probe.example.com.sock", |
| + Status: "ready", Hibernate: true, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + // A nil activator is the whole point: this is a box whose host.env never |
| + // named a CARLOS_HIBERNATE_BUCKET, so internal/host armed nothing. |
| + if _, err := New(reg, nil); err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + |
| + got := buf.String() |
| + for _, want := range []string{ |
| + "region-probe.example.com", |
| + "/run/carlos/region-probe.example.com.sock", |
| + "CARLOS_HIBERNATE_BUCKET", |
| + "502", |
| + } { |
| + 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) |
| + } |
| + } |
| +} |
| + |
| +// The complaint is about the ACTIVATOR, not about instances in general: a box |
| +// with an activator armed is working exactly as designed and must say |
| +// nothing, or the line becomes noise on every healthy box in the fleet. |
| +func TestReloadIsSilentWhenTheActivatorIsArmed(t *testing.T) { |
| + buf := captureLog(t) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "healthy.example.com", Kind: "instance", |
| + Socket: "/run/carlos/healthy.example.com.sock", |
| + Status: "ready", Hibernate: true, |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if _, err := New(reg, &fakeActivator{}); err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + |
| + if got := buf.String(); strings.Contains(got, "no activator") { |
| + t.Errorf("complained about an armed box: %s", got) |
| + } |
| +} |
| + |
| +// An UNFLAGGED instance is proxied straight by design — that is the |
| +// deploy-safety property that lets the hibernate env ship fleet-wide as a |
| +// no-op before any host is flagged — so a box with no activator must not |
| +// complain about one either. |
| +func TestReloadIsSilentForAnUnflaggedInstance(t *testing.T) { |
| + buf := captureLog(t) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "unflagged.example.com", Kind: "instance", |
| + Socket: "/run/carlos/unflagged.example.com.sock", |
| + Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + if _, err := New(reg, nil); err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + |
| + if got := buf.String(); strings.Contains(got, "no activator") { |
| + t.Errorf("complained about an unflagged instance: %s", got) |
| + } |
| +} |