Merge branch 'region-hosting-armed' into main
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 4d86d5886..a58f17548 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) |
| + } |
| +} |