carlos / platform Public

region HA 2a: the load balancer health answer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev cfb23740c6283d0605ae5fdcd7467620d6c900ee parent aee7b1e
11 files changed, +413 −4
  • internal/activator/activator.go +20 −0
  • internal/activator/lastsweep_test.go +28 −0
  • internal/edge/certsync.go +10 −0
  • internal/edge/edge.go +16 −1
  • internal/edge/homemap.go +21 −1
  • internal/edge/lbhealth.go +39 −0
  • internal/edge/lbhealth_test.go +231 −0
  • internal/host/claimwiring.go +7 −0
  • internal/host/claimwiring_hosting_test.go +35 −0
  • internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv +5 −2
  • region_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