region HA 2a: the load balancer health answer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+413
−4
internal/activator/activator.go+20 −0internal/activator/lastsweep_test.go+28 −0internal/edge/certsync.go+10 −0internal/edge/edge.go+16 −1internal/edge/homemap.go+21 −1internal/edge/lbhealth.go+39 −0internal/edge/lbhealth_test.go+231 −0internal/host/claimwiring.go+7 −0internal/host/claimwiring_hosting_test.go+35 −0internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+5 −2region_ha_boundary_test.go+1 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 524cbe537..b4b81126b 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -1239,6 +1239,10 @@ type Service struct { |
| sweepStop chan struct{} |
| sweepDone chan struct{} |
| + // lastSweep is when the sweeper last finished a sweep, in unix |
| + // nanoseconds (0: never): the hosting loop's own beat, which the load |
| + // balancer's health answer reads (region HA spec 2 §6, LastSweep). |
| + lastSweep atomic.Int64 |
| // gens is the generations switch (SetGenerations) as read at New. Off: |
| // every spawn binds the canonical socket and no retire hook fires. |
| @@ -4441,11 +4445,27 @@ func (a *Service) Start() { |
| return |
| case <-t.C: |
| a.sweepOnce() |
| + a.lastSweep.Store(time.Now().UnixNano()) |
| } |
| } |
| }() |
| } |
| +// LastSweep is when the sweeper last finished a sweep, or the zero time |
| +// before its first. The sweep runs at least every 30 seconds, so a box |
| +// whose last sweep is older than a minute has a hosting loop that is stuck |
| +// (region HA spec 2 §6). A nil service has never swept. |
| +func (a *Service) LastSweep() time.Time { |
| + if a == nil { |
| + return time.Time{} |
| + } |
| + n := a.lastSweep.Load() |
| + if n == 0 { |
| + return time.Time{} |
| + } |
| + return time.Unix(0, n) |
| +} |
| + |
| // markCleanParkLocked records that a clean-exit park is stopping e's current |
| // worker incarnation (round 2 Task 6). ReplaceWorker's observation forgives |
| // an exited one-shot that is no longer awake (spec §6.5) only when this mark |
diff --git a/internal/activator/lastsweep_test.go b/internal/activator/lastsweep_test.go| new file mode 100644 |
| index 000000000..62f57a5b7 |
| --- /dev/null |
| +++ b/internal/activator/lastsweep_test.go |
| @@ -0,0 +1,28 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Region HA spec 2 §6: the sweeper's finished sweeps are the hosting |
| +// loop's beat. Nothing has swept before Start. |
| +func TestLastSweepBeatsAfterEachSweep(t *testing.T) { |
| + a, _, _, _ := testActivator(t, 400*time.Millisecond) // sweeps every 100ms |
| + if !a.LastSweep().IsZero() { |
| + t.Fatal("swept before Start") |
| + } |
| + var none *Service |
| + if !none.LastSweep().IsZero() { |
| + t.Fatal("a nil service swept") |
| + } |
| + a.Start() |
| + t.Cleanup(func() { a.Shutdown(context.Background()) }) |
| + waitFor(t, "a sweep", func() bool { return !a.LastSweep().IsZero() }) |
| + first := a.LastSweep() |
| + if time.Since(first) > 5*time.Second { |
| + t.Fatalf("beat %v is not wall-clock time", first) |
| + } |
| + waitFor(t, "a later sweep", func() bool { return a.LastSweep().After(first) }) |
| +} |
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go| index 021f7bab4..da88d527f 100644 |
| --- a/internal/edge/certsync.go |
| +++ b/internal/edge/certsync.go |
| @@ -50,6 +50,7 @@ import ( |
| "golang.org/x/crypto/acme/autocert" |
| "golang.org/x/net/idna" |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -516,6 +517,10 @@ type certMirror struct { |
| // first-and-every-tenth log cadence. |
| partialPolls int |
| + // health is the load balancer health state the first landed refresh |
| + // latches (region HA spec 2 §6); nil without region HA. |
| + health *lbhealth.State |
| + |
| // synced remembers, per name, the mirrored bytes last reconciled into the |
| // local cache, so a steady state costs no local I/O at all. |
| syncMu sync.Mutex |
| @@ -546,6 +551,7 @@ func (e *Edge) newCertMirror(local autocert.Cache) *certMirror { |
| return nil |
| } |
| m := newCertMirror(e.staticStore, local) |
| + m.health = e.lbHealth |
| m.gated = func() bool { |
| _, held := e.issuerGate() |
| return !held |
| @@ -752,6 +758,10 @@ apply: |
| m.snap = snap |
| m.mu.Unlock() |
| m.syncLocal(ctx, snap) |
| + // The local cache the handshake reads now holds what the bucket does: |
| + // the certificate condition is met for good (§6). A later failed |
| + // refresh keeps serving this copy and leaves the latch set. |
| + m.health.CertsLoaded() |
| if m.wake != nil && wildcardMaterialChanged(prev, snap) { |
| // A takeover (or a first wildcard for a new account) reaches the |
| // serving path in one poll instead of up to twelve hours. |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 1e752d1d5..3b3a2758e 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -56,6 +56,7 @@ import ( |
| "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/geo" |
| "github.com/carlosframework/platform/internal/geoip" |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -131,6 +132,10 @@ type Edge struct { |
| // environment; see steeringprobe.go for why it is a serving concern at |
| // all. |
| steeringHost string |
| + // lbHealth is the load balancer health state (region HA spec 2 §6): |
| + // lbhealth.Default with CARLOS_REGION_HA on, nil and inert with it |
| + // off. Set once in New. |
| + lbHealth *lbhealth.State |
| // appsDomain is the platform apps domain (PLATFORM_APPS_DOMAIN, |
| // normalized), read once in New; cookieLogged is the set of route hosts |
| // whose cookie rewrite has been logged. Both serve cookiescope.go. "" |
| @@ -368,7 +373,8 @@ type upstream struct { |
| // New builds an Edge over reg and performs the startup reload. act may be nil |
| // (no hibernation). |
| func New(reg *registry.Registry, act Activator) (*Edge, error) { |
| - e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv(), appsDomain: appsDomainFromEnv()} |
| + e := &Edge{reg: reg, act: act, gate: gate.New(), probers: newProberSet(), gzipOn: gzipEnabled(), steeringHost: steeringProbeHostFromEnv(), appsDomain: appsDomainFromEnv(), |
| + lbHealth: lbHealthFromEnv(os.Getenv, act)} |
| e.geo = geo.NewSwappable() |
| e.geoLookup = e.geo.Lookup |
| // Generations (zero-downtime spec §7.2), before the startup Reload so |
| @@ -2226,6 +2232,12 @@ func (e *Edge) httpFallbackHandler() http.Handler { |
| if h, _, err := net.SplitHostPort(host); err == nil { |
| host = h |
| } |
| + // The load balancer's health check (region HA spec 2 §6, lbhealth.go): |
| + // any host, so it comes before every host-based branch. Inert with |
| + // region HA off. |
| + if e.lbHealthAnswer(w, r) { |
| + return |
| + } |
| // The steering hostname answers here too (ServeHTTP's own first |
| // branch, and the same reasoning): the health check is HTTPS today, |
| // but a :80 probe — or an operator's curl — must not be handed this |
| @@ -2305,6 +2317,9 @@ func (e *Edge) Watch(stop <-chan struct{}) { |
| continue |
| } |
| errs = 0 |
| + // The edge's own loop is alive and its routing table readable |
| + // (region HA spec 2 §6). A wedged poll stops beating. |
| + e.lbHealth.EdgeBeat(time.Now()) |
| if fp == last { |
| continue |
| } |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index 504e6447a..acee87bc0 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -86,6 +86,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -279,6 +280,11 @@ type homeMap struct { |
| // privateDial is this edge's region HA switch: only with it on does |
| // the fold dial a same-region box's private address (dialAddress). |
| privateDial bool |
| + |
| + // health is the load balancer health state (region HA spec 2 §6), |
| + // nil without region HA: Refresh latches the first loaded map and key |
| + // and stamps every attempt it completes. |
| + health *lbhealth.State |
| } |
| func newHomeMap(s release.Store, self string) *homeMap { |
| @@ -296,6 +302,7 @@ func (e *Edge) newHomeMap() *homeMap { |
| } |
| m := newHomeMap(e.staticStore, e.box) |
| m.privateDial = regionHAFromEnv(os.Getenv) |
| + m.health = e.lbHealth |
| return m |
| } |
| @@ -303,10 +310,23 @@ func (e *Edge) newHomeMap() *homeMap { |
| // fail INDEPENDENTLY — a bucket that answered the edges list but not the key |
| // object should still land the fold — so both run and their errors join. |
| // Whatever fails leaves its own last-good in place; the caller logs. |
| +// |
| +// For the load balancer's health answer (region HA spec 2 §6) each half |
| +// latches its first success, and every call that returns stamps an |
| +// attempt, failed or not: a bucket outage after arming leaves the box |
| +// healthy, and only a refresh stuck inside one attempt goes stale. |
| func (m *homeMap) Refresh(ctx context.Context) error { |
| m.refreshMu.Lock() |
| defer m.refreshMu.Unlock() |
| - return errors.Join(m.refreshHosts(ctx), m.refreshKeys(ctx)) |
| + hostsErr, keysErr := m.refreshHosts(ctx), m.refreshKeys(ctx) |
| + if hostsErr == nil { |
| + m.health.MapLoaded() |
| + } |
| + if current, _ := m.meshKeys(); keysErr == nil && len(current) > 0 { |
| + m.health.KeyLoaded() |
| + } |
| + m.health.RefreshAttempted(time.Now()) |
| + return errors.Join(hostsErr, keysErr) |
| } |
| // refreshHosts rebuilds the whole map from one list plus a get per key — no |
diff --git a/internal/edge/lbhealth.go b/internal/edge/lbhealth.go| new file mode 100644 |
| index 000000000..bbb6a6565 |
| --- /dev/null |
| +++ b/internal/edge/lbhealth.go |
| @@ -0,0 +1,39 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| +) |
| + |
| +// The load balancer health answer (region HA spec 2 §6): GET or HEAD |
| +// /.carlos/lb-health on port 80, from any host. The network load |
| +// balancer's HTTP check sends the target's address as the Host header, so |
| +// the steering probe, which answers only for its own hostname, cannot |
| +// serve it. Every condition is this box's own; lbhealth has the list. |
| + |
| +// lbHealthFromEnv is the state the edge answers from: the process's |
| +// (lbhealth.Default) with CARLOS_REGION_HA on, and nil with it off, which |
| +// leaves the path to the ordinary :80 handling exactly as before. The |
| +// activator's sweep is the hosting loop's beat; an edge with no activator |
| +// (carlos edge) has none, and its answer is never 200. |
| +func lbHealthFromEnv(getenv func(string) string, act Activator) *lbhealth.State { |
| + if !regionHAFromEnv(getenv) { |
| + return nil |
| + } |
| + s := lbhealth.Default |
| + if a, ok := act.(interface{ LastSweep() time.Time }); ok { |
| + s.SetActivator(a.LastSweep) |
| + } |
| + return s |
| +} |
| + |
| +// lbHealthAnswer answers the health check when r is one and region HA is |
| +// on, reporting whether it did. |
| +func (e *Edge) lbHealthAnswer(w http.ResponseWriter, r *http.Request) bool { |
| + if e.lbHealth == nil { |
| + return false |
| + } |
| + return lbhealth.Serve(w, r, e.lbHealth, time.Now()) |
| +} |
diff --git a/internal/edge/lbhealth_test.go b/internal/edge/lbhealth_test.go| new file mode 100644 |
| index 000000000..fdaee8be5 |
| --- /dev/null |
| +++ b/internal/edge/lbhealth_test.go |
| @@ -0,0 +1,231 @@ |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/crypto/acme/autocert" |
| + |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// sweeper is an Activator whose only working method is the sweep beat. |
| +type sweeper struct { |
| + Activator |
| + at time.Time |
| +} |
| + |
| +func (s sweeper) LastSweep() time.Time { return s.at } |
| + |
| +func lbEnv(v string) func(string) string { |
| + return func(k string) string { |
| + if k == "CARLOS_REGION_HA" { |
| + return v |
| + } |
| + return "" |
| + } |
| +} |
| + |
| +func TestLBHealthStateFollowsRegionHA(t *testing.T) { |
| + if s := lbHealthFromEnv(lbEnv(""), nil); s != nil { |
| + t.Fatal("region HA unset armed the health answer") |
| + } |
| + if s := lbHealthFromEnv(lbEnv("0"), nil); s != nil { |
| + t.Fatal("region HA off armed the health answer") |
| + } |
| + at := time.Now() |
| + s := lbHealthFromEnv(lbEnv("1"), nil) |
| + if s != lbhealth.Default { |
| + t.Fatal("region HA on: not the process's state") |
| + } |
| + t.Cleanup(func() { lbhealth.Default.SetActivator(nil) }) |
| + lbHealthFromEnv(lbEnv("true"), sweeper{at: at}) |
| + if got := lbhealth.Default.Failing(at); strings.Contains(strings.Join(got, ","), lbhealth.CondActivator) { |
| + t.Fatalf("the activator's sweep was not wired: %v", got) |
| + } |
| +} |
| + |
| +// fallbackGet drives the :80 handler with one request. |
| +func fallbackGet(e *Edge, method, path, host string) *httptest.ResponseRecorder { |
| + w := httptest.NewRecorder() |
| + r := httptest.NewRequest(method, path, nil) |
| + r.Host = host |
| + e.httpFallbackHandler().ServeHTTP(w, r) |
| + return w |
| +} |
| + |
| +// healthyState meets every condition now. |
| +func healthyState() *lbhealth.State { |
| + s := &lbhealth.State{} |
| + now := time.Now() |
| + s.SetHosting(true) |
| + s.MapLoaded() |
| + s.KeyLoaded() |
| + s.CertsLoaded() |
| + s.EdgeBeat(now) |
| + s.RefreshAttempted(now) |
| + s.SetActivator(func() time.Time { return now }) |
| + return s |
| +} |
| + |
| +// The answer is on :80, for any host (the load balancer sends the |
| +// target's address), GET or HEAD, ahead of the redirect and unknown-host |
| +// branches. With region HA off the path is ordinary :80 traffic. |
| +func TestLBHealthAnswersOnPort80ForAnyHost(t *testing.T) { |
| + e, err := New(openReg(t), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(e.probers.Stop) |
| + e.lbHealth = healthyState() |
| + for _, host := range []string{"10.0.1.7", "10.0.1.7:80", "unknown.example.com"} { |
| + w := fallbackGet(e, http.MethodGet, lbhealth.Path, host) |
| + if w.Code != http.StatusOK || w.Body.String() != "ok\n" { |
| + t.Fatalf("host %s: %d %q", host, w.Code, w.Body.String()) |
| + } |
| + } |
| + if w := fallbackGet(e, http.MethodHead, lbhealth.Path, "10.0.1.7"); w.Code != http.StatusOK { |
| + t.Fatalf("HEAD: %d", w.Code) |
| + } |
| + joining := healthyState() |
| + joining.SetHosting(false) |
| + e.lbHealth = joining |
| + if w := fallbackGet(e, http.MethodGet, lbhealth.Path, "10.0.1.7"); w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), lbhealth.CondHosting) { |
| + t.Fatalf("joining: %d %q", w.Code, w.Body.String()) |
| + } |
| + // A POST, or another path, is ordinary :80 traffic. |
| + if w := fallbackGet(e, http.MethodPost, lbhealth.Path, "10.0.1.7"); w.Code == http.StatusOK || w.Code == http.StatusServiceUnavailable { |
| + t.Fatalf("POST answered by the health check: %d", w.Code) |
| + } |
| + e.lbHealth = nil |
| + if w := fallbackGet(e, http.MethodGet, lbhealth.Path, "10.0.1.7"); w.Code == http.StatusOK || strings.Contains(w.Body.String(), "unavailable:") { |
| + t.Fatalf("region HA off answered the health check: %d %q", w.Code, w.Body.String()) |
| + } |
| +} |
| + |
| +// blockingStore makes every List wait until release is closed, and fail |
| +// while down is set: a refresh stuck inside one attempt, and a bucket |
| +// outage. |
| +type blockingStore struct { |
| + release.Store |
| + block chan struct{} |
| + down bool |
| + entered chan struct{} |
| +} |
| + |
| +func (s *blockingStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if s.block != nil { |
| + s.entered <- struct{}{} |
| + <-s.block |
| + } |
| + if s.down { |
| + return nil, errors.New("bucket unreachable") |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +func (s *blockingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if s.down { |
| + return nil, "", errors.New("bucket unreachable") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// The home map latches its first loaded map and key, stamps every attempt |
| +// it finishes, failed or not, and stamps nothing while one is stuck. |
| +func TestHomeMapFeedsTheHealthState(t *testing.T) { |
| + inner := meshTestStore(t) |
| + if err := mintMeshKey(context.Background(), inner); err != nil { |
| + t.Fatal(err) |
| + } |
| + putJSON(t, inner, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`) |
| + s := &blockingStore{Store: inner} |
| + h := &lbhealth.State{} |
| + m := newHomeMap(s, "annie") |
| + m.health = h |
| + refreshHomeMap(t, m) |
| + now := time.Now() |
| + failing := strings.Join(h.Failing(now), ",") |
| + if strings.Contains(failing, lbhealth.CondForwarding) || strings.Contains(failing, lbhealth.CondRefresh) { |
| + t.Fatalf("after a good refresh: %s", failing) |
| + } |
| + |
| + // A bucket outage: the attempt fails but finishes, and the latches hold. |
| + s.down = true |
| + before := time.Now() |
| + if err := m.Refresh(context.Background()); err == nil { |
| + t.Fatal("refresh against a down bucket succeeded") |
| + } |
| + if got := strings.Join(h.Failing(before.Add(lbhealth.RefreshWindow)), ","); strings.Contains(got, lbhealth.CondForwarding) || strings.Contains(got, lbhealth.CondRefresh) { |
| + t.Fatalf("a failed attempt dropped forwarding or went unstamped: %s", got) |
| + } |
| + |
| + // A stuck attempt stamps nothing. |
| + s.down = false |
| + s.block, s.entered = make(chan struct{}), make(chan struct{}, 1) |
| + done := make(chan struct{}) |
| + go func() { _ = m.Refresh(context.Background()); close(done) }() |
| + <-s.entered |
| + stuckAt := time.Now() |
| + if got := strings.Join(h.Failing(stuckAt.Add(lbhealth.RefreshWindow+time.Second)), ","); !strings.Contains(got, lbhealth.CondRefresh) { |
| + t.Fatalf("a refresh stuck for longer than the window still counts: %s", got) |
| + } |
| + close(s.block) |
| + <-done |
| +} |
| + |
| +// A key that never loads leaves forwarding unarmed, whatever the map did. |
| +func TestHomeMapWithNoMeshKeyIsNotArmed(t *testing.T) { |
| + s := meshTestStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{}}`) |
| + h := &lbhealth.State{} |
| + m := newHomeMap(s, "annie") |
| + m.health = h |
| + refreshHomeMap(t, m) |
| + if got := strings.Join(h.Failing(time.Now()), ","); !strings.Contains(got, lbhealth.CondForwarding) { |
| + t.Fatalf("no mesh key, yet forwarding armed: %s", got) |
| + } |
| +} |
| + |
| +func TestCertMirrorLatchesItsFirstLoad(t *testing.T) { |
| + s := meshTestStore(t) |
| + h := &lbhealth.State{} |
| + m := newCertMirror(s, autocert.DirCache(t.TempDir())) |
| + m.health = h |
| + if !strings.Contains(strings.Join(h.Failing(time.Now()), ","), lbhealth.CondCerts) { |
| + t.Fatal("certificates counted before any load") |
| + } |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.Contains(strings.Join(h.Failing(time.Now()), ","), lbhealth.CondCerts) { |
| + t.Fatal("a loaded mirror did not latch the certificate condition") |
| + } |
| +} |
| + |
| +// The edge's registry watch beats on every poll it completes. |
| +func TestRegistryWatchBeats(t *testing.T) { |
| + e, err := New(openReg(t), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(e.probers.Stop) |
| + h := &lbhealth.State{} |
| + e.lbHealth = h |
| + stop := make(chan struct{}) |
| + t.Cleanup(func() { close(stop) }) |
| + go e.Watch(stop) |
| + deadline := time.Now().Add(5 * time.Second) |
| + for strings.Contains(strings.Join(h.Failing(time.Now()), ","), lbhealth.CondEdge) { |
| + if time.Now().After(deadline) { |
| + t.Fatal("the registry watch never beat") |
| + } |
| + time.Sleep(50 * time.Millisecond) |
| + } |
| +} |
diff --git a/internal/host/claimwiring.go b/internal/host/claimwiring.go| index 8d0ef054c..94388fd7f 100644 |
| --- a/internal/host/claimwiring.go |
| +++ b/internal/host/claimwiring.go |
| @@ -10,6 +10,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/schedule" |
| ) |
| @@ -94,6 +95,12 @@ func (w *ClaimWiring) startRegional(ctx context.Context, serial <-chan struct{}, |
| w.cancel = cancel |
| w.joined = make(chan struct{}) |
| go func() { defer close(w.joined); w.Providers.Regional.Loop(workerCtx, serial, report) }() |
| + // The claim agent is running: it refused to start without its |
| + // host-actor enrollment and a matching roster row, so this box is |
| + // hosting (region HA spec 2 §5, §6). Plan 2b's mode selection owns |
| + // this flag from then on: joining mode leaves it false, and only an |
| + // active roster entry with an armed guard sets it. |
| + lbhealth.Default.SetHosting(true) |
| return nil |
| } |
| func (w *ClaimWiring) close(ctx context.Context) error { |
diff --git a/internal/host/claimwiring_hosting_test.go b/internal/host/claimwiring_hosting_test.go| new file mode 100644 |
| index 000000000..0c296aaeb |
| --- /dev/null |
| +++ b/internal/host/claimwiring_hosting_test.go |
| @@ -0,0 +1,35 @@ |
| +package host |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| +) |
| + |
| +// Region HA spec 2 §5, §6: a claim agent that is running is enrolled, so |
| +// it is hosting; an agent with claim mode off never says so. Plan 2b's |
| +// joining mode is what leaves this false. |
| +func TestClaimWiringStartMarksTheBoxHosting(t *testing.T) { |
| + lbhealth.Default.SetHosting(false) |
| + t.Cleanup(func() { lbhealth.Default.SetHosting(false) }) |
| + off := &ClaimWiring{} |
| + if err := off.startRegional(t.Context(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if lbhealth.Default.Hosting() { |
| + t.Fatal("claim mode off marked the box hosting") |
| + } |
| + loop := testRegionalLoop{started: make(chan struct{})} |
| + w := &ClaimWiring{Writes: hostwrite.Config{Enabled: true}, Providers: ClaimProviders{Regional: loop}} |
| + if err := w.startRegional(t.Context(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + <-loop.started |
| + if !lbhealth.Default.Hosting() { |
| + t.Fatal("a running claim agent is not hosting") |
| + } |
| + if err := w.close(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv| index 3bfa644f1..b4f264e64 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -6,11 +6,14 @@ |
| # Task 6: hostops.ExecutorChooser applies spec 2's policy in Choose and Permits (creates and relocation targets) and names the placeable set in PlaceableBoxes and Capacity. ExecutorChooser.eligible, the accepted-legacy unit (merge ruling 2), is unchanged: the policy runs over its result. Envelope-only, and the legacy seal stands. |
| # Task 7: the box's own addresses (internal/host boxaddress.go, the provider-neutral source over CARLOS_PRIVATE_IP with CARLOS_PRIVATE_CIDR, and CARLOS_PUBLIC_IP, all from root-owned host.env; awsaddr.go, the IMDS adapter, which reads the VPC's CIDR blocks too) and the edges file's private_ip and private_cidr, validated before they are written by edgesfile.PrivateAddress (census ruling region-ha-2a, condition 1). edgesFileWriteClaims (authority, F,J1) is re-digested with its class, task, basis and sites unchanged: with region HA on it reads the address source before every publication and no longer holds a blank EIP. It adds no registry, /data, release or claim read, and nothing runs as root that did not before; the legacy seal stands. |
| # Task 8: with the reader's own region HA on, forwarding dials a same-region box's private address when it passes edgesfile.PrivateAddress against the reader's own published network, and every other box at its public address (internal/edge homemap.go: dialAddress, regionHAFromEnv, edgesFileView's private_ip, private_cidr, region and placement). The home map is not a root-read unit. Envelope-only, and the legacy seal stands. |
| +# Task 9: the load balancer health answer (internal/edge lbhealth.go, answered in httpFallbackHandler; the home map and the certificate mirror latch their first loads, Edge.Watch beats, the activator's sweeper beats through Service.LastSweep, and ClaimWiring.startRegional marks the box hosting). The only root-read unit that moved is the activator's authority row type Service, which gains one atomic.Int64 field (lastSweep) written after each finished sweep and read by LastSweep: no read, write or decision of the seed rows' authority changes, so its row is re-pinned to the new digest with its class, owner and basis unchanged. The answer reads only in-memory state. Envelope-only, and the legacy seal stands. |
| +internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative be4ca3a3ea2bd768e7e52c1056513d4fef1c1702d2e04656c540cdcbb858865c |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| @coverage internal/edgesfile f62c3973bb48fa36f561d26f496c0ba9a14fe882fe5843867e9f7a9eaf0abbdf |
| -@coverage internal/host 75e0a977c0f286966f2fb1ebbca10aa40f3795117958bf82e80cf545d6a776fa |
| +@coverage internal/host 4ad9bdcd8b94516a6ccdad3bc900b166833faf1f0334ac83478be5b04de47069 |
| @coverage internal/hostops 3dc23aaff7bdae0341a8c9a6fbac076a2f57ca1e4676d83543433ff9ad7d1c79 |
| -@coverage internal/edge 2c474d685471295daf20f061e595becab78175c5754dbef0d2187f0f8519fbe3 |
| +@coverage internal/edge 8e47251c54e3c5ee9cb6e8b9b1ad3ae87da528436c6a870cadb07ca3a8ac3146 |
| internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 root-route;kept=F;scope=protected-decision-only registry:Account@10835,registry:Alias@5297,registry:Alias@5332,registry:Alias@5754,registry:Alias@5911,registry:Alias@5981,registry:Alias@6013,registry:Alias@6482,registry:Alias@8772,registry:App@10847,registry:DNSStatus@5429,registry:DNSStatus@5920,registry:Host@10480,registry:Host@2878,registry:Host@5389,registry:Host@6234,registry:Host@6567,registry:Host@8819,registry:Host@8866,registry:Host@8916,registry:Host@8951,registry:Host@9000,registry:Platform@2921,registry:Provisioned@2935,registry:Replica@2959,registry:Status@877,whole:Route@3212,whole:Route@323,whole:Route@3505,whole:Route@351,whole:RouteAlias@5282,whole:RouteAlias@8725,whole:RoutePath@3520,whole:WildcardDomain@8905 8c7cdffba66886b92f9a2ac663a86dbdd5f80699afd2e6afb4571da916c727ad |
| +@coverage internal/activator fd9cf14712f55895541bb8bfbf8137cfd446298bcaa8634b0aba7b8344c29ec3 |
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go| index 327ebcef6..4336d209e 100644 |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| @@ -24,6 +24,7 @@ var regionHACoreFiles = []string{ |
| "internal/boxchoice/policy.go", |
| "internal/host/boxaddress.go", |
| "internal/edgesfile/private.go", |
| + "internal/edge/lbhealth.go", |
| } |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |