edge: cert mirror and home map re-read only what moved; mesh keys keep their own poll (#336)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+826
−42
internal/edge/certsync.go+228 −39internal/edge/certsync_test.go+350 −0internal/edge/edge.go+17 −0internal/edge/homemap.go+88 −3internal/edge/homemap_test.go+137 −0internal/host/agent.go+6 −0
diff --git a/internal/edge/certsync.go b/internal/edge/certsync.go| index da88d527f..cbea09f81 100644 |
| --- a/internal/edge/certsync.go |
| +++ b/internal/edge/certsync.go |
| @@ -36,6 +36,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "math/rand/v2" |
| "net" |
| "net/http" |
| "os" |
| @@ -521,6 +522,27 @@ type certMirror struct { |
| // latches (region HA spec 2 §6); nil without region HA. |
| health *lbhealth.State |
| + // watch resolves the process's change-index watch (#336), handed down by |
| + // the agent through WithChangeWatch and read on every poll: the agent |
| + // arms its watch on its own schedule, possibly after the edge starts. |
| + // nil, or a nil watch, is today's mirror: every poll a full Refresh. |
| + // now is the poll's clock (nil is time.Now; tests inject one). |
| + watch func() *release.ChangeWatch |
| + now func() time.Time |
| + |
| + // Guarded by refreshMu: what the published snapshot was read at. |
| + // etags is each entry's ETag as its Get returned it ("" = unknown, read |
| + // it again); lastFull is the last full rebuild that landed and fullAge |
| + // the jittered backstop after which the next poll rebuilds in full; |
| + // actedGen is the control/certs generation, captured BEFORE the read, |
| + // that the snapshot is known to reflect (actedOK false = no such |
| + // generation: the watch could not vouch, or some object went unread). |
| + etags map[string]string |
| + lastFull time.Time |
| + fullAge time.Duration |
| + actedGen uint64 |
| + actedOK bool |
| + |
| // 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 |
| @@ -552,6 +574,7 @@ func (e *Edge) newCertMirror(local autocert.Cache) *certMirror { |
| } |
| m := newCertMirror(e.staticStore, local) |
| m.health = e.lbHealth |
| + m.watch = e.changeWatch |
| m.gated = func() bool { |
| _, held := e.issuerGate() |
| return !held |
| @@ -615,10 +638,12 @@ func newCertMirror(s release.Store, local autocert.Cache) *certMirror { |
| } |
| } |
| -// Refresh rebuilds the whole map from one list plus a get per key. No delta |
| -// cleverness: the prefix holds one small object per host and renewals are |
| -// rare, and a full rebuild is what makes a withdrawn entry (a spent challenge |
| -// token) actually leave. |
| +// Refresh rebuilds the whole map from one list plus a get per key. It is the |
| +// boot read before the listeners bind, every poll while the change index is |
| +// disarmed, and the armed loop's backstop and "unknown" read (poll, #336). |
| +// A rebuild from the listing is what makes a withdrawn entry (a spent |
| +// challenge token) actually leave; poll's changed-only read builds from a |
| +// listing too, for the same reason. |
| // |
| // An error leaves the last-good snapshot in place and is returned for the |
| // caller to log. That is the edge's standing degrade-not-die posture, and it |
| @@ -627,14 +652,23 @@ func newCertMirror(s release.Store, local autocert.Cache) *certMirror { |
| func (m *certMirror) Refresh(ctx context.Context) error { |
| m.refreshMu.Lock() |
| defer m.refreshMu.Unlock() |
| + return m.refreshFull(ctx) |
| +} |
| + |
| +// refreshFull is Refresh's body, under refreshMu. |
| +func (m *certMirror) refreshFull(ctx context.Context) error { |
| + // The generation this read will reflect, captured BEFORE the list: a |
| + // write that lands during the read leaves control/certs moved past it, |
| + // so the next poll looks again (#336). Without a usable watch there is |
| + // none, and the next poll cannot skip. |
| + gen, genOK := m.certsGen() |
| keys, err := m.s.List(ctx, certPrefix) |
| if err != nil { |
| return fmt.Errorf("list %s: %w", certPrefix, err) |
| } |
| prev := m.snapshot() |
| next := make(map[string][]byte, len(keys)) |
| - var tried, failed int |
| - var lastErr error |
| + etags := make(map[string]string, len(keys)) |
| // The eligible names first, then their bodies CONCURRENTLY. The fetch is |
| // one round trip per object and the objects are independent, so doing |
| @@ -655,10 +689,7 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| // box pays count x latency, so each new published cert moves every box |
| // closer to the same cliff. us-east-1 at ~75ms is about a third of the |
| // way there at today's count. |
| - type fetch struct { |
| - key, name string |
| - } |
| - var items []fetch |
| + var items []certFetch |
| for _, key := range keys { |
| name, ok := strings.CutPrefix(key, certPrefix) |
| // publishableCertName is the same filter the publish side applies: |
| @@ -667,15 +698,135 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| if !ok || !publishableCertName(name) { |
| continue |
| } |
| - items = append(items, fetch{key: key, name: name}) |
| + items = append(items, certFetch{key: key, name: name}) |
| + } |
| + tried := len(items) |
| + failed, lastErr := applyCertFetches(items, m.fetchCerts(ctx, items), prev, next, etags) |
| + if tried > 0 && failed == tried { |
| + // Nothing at all could be read: that is a bucket fault, not an object |
| + // fault, and it is reported like a failed List — last-good stands. |
| + return fmt.Errorf("get %s*: every one of %d object(s) failed, last: %w", certPrefix, tried, lastErr) |
| } |
| - tried = len(items) |
| + if failed > 0 { |
| + m.logPartialPoll(failed, tried, lastErr) |
| + } |
| + m.publish(ctx, prev, next, etags) |
| + // A partial rebuild still counts as the full rebuild: the objects it |
| + // could not read carry an unknown ETag, so the next poll's changed-only |
| + // read fetches exactly those again. It reflects no generation, though, |
| + // so that next poll cannot skip. |
| + m.lastFull, m.fullAge = m.clock(), jitteredBackstop() |
| + m.actedGen, m.actedOK = gen, genOK && failed == 0 |
| + return nil |
| +} |
| - type result struct { |
| - body []byte |
| - err error |
| +// poll is the 30s loop's refresh (#336). Disarmed, it is Refresh. Armed, it |
| +// polls the change index (a 304 when nothing moved, one GET either way) and |
| +// then does the least that keeps the snapshot current: |
| +// |
| +// - nothing, when control/certs has not moved since the generation the |
| +// snapshot reflects and the last full rebuild is younger than the |
| +// jittered backstop; |
| +// - a changed-only read (refreshChanged) when it has moved; |
| +// - today's full Refresh when the watch cannot vouch for the scope (the |
| +// index unreadable: "unknown", never "unchanged"), when no full rebuild |
| +// has landed yet, or at the backstop. |
| +// |
| +// Poll's moved list is not consulted: the watch is shared with the agent's |
| +// tick and record view, so whichever polls first consumes it. Generations |
| +// are compared with the one this mirror last acted on instead. |
| +func (m *certMirror) poll(ctx context.Context) error { |
| + w := m.changeWatch() |
| + if w == nil { |
| + return m.Refresh(ctx) |
| + } |
| + // An error leaves the watch unusable, which Gen reports below; the poll |
| + // itself has nothing to add. |
| + _, _ = w.Poll(ctx) |
| + m.refreshMu.Lock() |
| + defer m.refreshMu.Unlock() |
| + gen, ok := w.Gen(certScope) |
| + switch { |
| + case !ok, m.lastFull.IsZero(), !m.clock().Before(m.lastFull.Add(m.fullAge)): |
| + return m.refreshFull(ctx) |
| + case m.actedOK && gen == m.actedGen: |
| + return nil |
| + } |
| + return m.refreshChanged(ctx, gen) |
| +} |
| + |
| +// refreshChanged rebuilds the snapshot from one ETag listing plus a get per |
| +// new or rewritten object, under refreshMu. gen is the control/certs |
| +// generation captured before the listing. It keeps every guarantee of the |
| +// full rebuild: |
| +// |
| +// - a key absent from the listing (a spent challenge token) is absent from |
| +// the new snapshot, because the snapshot is built from the listing; |
| +// - a failed get of a changed object carries the previous copy forward |
| +// with an unknown ETag, so the next poll fetches it again; |
| +// - the new snapshot is built whole and swapped in by publish, which also |
| +// copies changed material into the local cache and wakes the wildcard |
| +// manager, exactly as Refresh does. |
| +// |
| +// A store without the ETag listing, or a listing that fails, is "unknown": |
| +// today's full Refresh runs instead. |
| +func (m *certMirror) refreshChanged(ctx context.Context, gen uint64) error { |
| + lister, ok := m.s.(release.ETagLister) |
| + if !ok { |
| + return m.refreshFull(ctx) |
| + } |
| + listed, err := lister.ListETags(ctx, certPrefix) |
| + if err != nil { |
| + return m.refreshFull(ctx) |
| + } |
| + prev := m.snapshot() |
| + next := make(map[string][]byte, len(listed)) |
| + etags := make(map[string]string, len(listed)) |
| + var items []certFetch |
| + for _, le := range listed { |
| + name, ok := strings.CutPrefix(le.Key, certPrefix) |
| + if !ok || !publishableCertName(name) { |
| + continue |
| + } |
| + if was := m.etags[name]; was != "" && was == le.ETag { |
| + if body, ok := prev.entries[name]; ok { |
| + next[name], etags[name] = body, was |
| + continue |
| + } |
| + } |
| + items = append(items, certFetch{key: le.Key, name: name}) |
| + } |
| + tried := len(items) |
| + failed, lastErr := applyCertFetches(items, m.fetchCerts(ctx, items), prev, next, etags) |
| + if tried > 0 && failed == tried { |
| + // Refresh's rule: nothing that changed could be read, so the bucket |
| + // is at fault and last-good stands whole. The generation stays |
| + // unacted, so the next poll tries again. |
| + return fmt.Errorf("get %s*: every one of %d changed object(s) failed, last: %w", certPrefix, tried, lastErr) |
| } |
| - results := make([]result, len(items)) |
| + if failed > 0 { |
| + m.logPartialPoll(failed, tried, lastErr) |
| + } |
| + m.publish(ctx, prev, next, etags) |
| + m.actedGen, m.actedOK = gen, failed == 0 |
| + return nil |
| +} |
| + |
| +// certFetch is one object a refresh reads; certFetchResult its outcome. |
| +type certFetch struct { |
| + key, name string |
| +} |
| + |
| +type certFetchResult struct { |
| + body []byte |
| + etag string |
| + err error |
| +} |
| + |
| +// fetchCerts gets every item's body, at most certRefreshConcurrency at once, |
| +// and returns the results in item order. |
| +func (m *certMirror) fetchCerts(ctx context.Context, items []certFetch) []certFetchResult { |
| + results := make([]certFetchResult, len(items)) |
| // A fixed pool pulling indices off a channel, NOT a goroutine per object |
| // parked on a semaphore. The distinction only shows up at scale, and it is |
| // the whole point of having a bound: goroutine-per-object costs one stack |
| @@ -696,8 +847,8 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| go func() { |
| defer wg.Done() |
| for i := range idx { |
| - body, _, err := m.s.Get(ctx, items[i].key) |
| - results[i] = result{body: body, err: err} |
| + body, etag, err := m.s.Get(ctx, items[i].key) |
| + results[i] = certFetchResult{body: body, etag: etag, err: err} |
| } |
| }() |
| } |
| @@ -706,57 +857,63 @@ func (m *certMirror) Refresh(ctx context.Context) error { |
| case idx <- i: |
| case <-ctx.Done(): |
| // Leaves results[i:] zero — a nil body with a nil error, which |
| - // the apply loop below reads as a successful empty get. Set the |
| + // the apply loop reads as a successful empty get. Set the |
| // context's error on the rest so they take the failed-get branch |
| // and carry the previous copy forward instead. |
| for j := i; j < len(items); j++ { |
| - results[j] = result{err: ctx.Err()} |
| + results[j] = certFetchResult{err: ctx.Err()} |
| } |
| close(idx) |
| wg.Wait() |
| - goto apply |
| + return results |
| } |
| } |
| close(idx) |
| wg.Wait() |
| -apply: |
| + return results |
| +} |
| +// applyCertFetches folds fetched bodies into next (and their ETags into |
| +// etags), carrying prev's copy of any object that failed, and returns the |
| +// failure count and the last failure. |
| +func applyCertFetches(items []certFetch, results []certFetchResult, prev *certSnapshot, next map[string][]byte, etags map[string]string) (failed int, lastErr error) { |
| // Applied in key order, not completion order, so a partial poll reports |
| // the same lastErr whichever way the fetches happened to interleave. |
| for i, it := range items { |
| - body, err := results[i].body, results[i].err |
| + r := results[i] |
| switch { |
| - case errors.Is(err, os.ErrNotExist): |
| + case errors.Is(r.err, os.ErrNotExist): |
| // Raced a delete between the list and the get — a spent token. Not |
| // a fault; the entry is simply gone. |
| continue |
| - case err != nil: |
| + case r.err != nil: |
| // ONE bad object is not a bad poll. Aborting here would discard the |
| // whole rebuild — every unrelated cert that landed this tick with |
| // it — and skip syncLocal and the wildcard wake; a single |
| // permanently unreadable key would freeze the mirror forever. Carry |
| - // the previous copy of this name forward and keep going. |
| + // the previous copy of this name forward and keep going, with an |
| + // unknown ETag so the next changed-only read fetches it again. |
| failed++ |
| - lastErr = err |
| + lastErr = r.err |
| if body, ok := prev.entries[it.name]; ok { |
| - next[it.name] = body |
| + next[it.name], etags[it.name] = body, "" |
| } |
| continue |
| } |
| - next[it.name] = body |
| - } |
| - if tried > 0 && failed == tried { |
| - // Nothing at all could be read: that is a bucket fault, not an object |
| - // fault, and it is reported like a failed List — last-good stands. |
| - return fmt.Errorf("get %s*: every one of %d object(s) failed, last: %w", certPrefix, tried, lastErr) |
| - } |
| - if failed > 0 { |
| - m.logPartialPoll(failed, tried, lastErr) |
| + next[it.name], etags[it.name] = r.body, r.etag |
| } |
| + return failed, lastErr |
| +} |
| + |
| +// publish swaps in the snapshot built from next, whole, and does what every |
| +// landed refresh does after it: the local cache copy-in, the health latch and |
| +// the wildcard wake. Under refreshMu. |
| +func (m *certMirror) publish(ctx context.Context, prev *certSnapshot, next map[string][]byte, etags map[string]string) { |
| snap := buildCertSnapshot(prev, next) |
| m.mu.Lock() |
| m.snap = snap |
| m.mu.Unlock() |
| + m.etags = etags |
| 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 |
| @@ -767,7 +924,39 @@ apply: |
| // serving path in one poll instead of up to twelve hours. |
| m.wake() |
| } |
| - return nil |
| +} |
| + |
| +// certScope is the change-index scope every write under certPrefix bumps. |
| +const certScope = "control/certs" |
| + |
| +func (m *certMirror) changeWatch() *release.ChangeWatch { |
| + if m.watch == nil { |
| + return nil |
| + } |
| + return m.watch() |
| +} |
| + |
| +// certsGen is control/certs' generation as the watch last saw it, ok false |
| +// when there is no usable watch. |
| +func (m *certMirror) certsGen() (uint64, bool) { |
| + if w := m.changeWatch(); w != nil { |
| + return w.Gen(certScope) |
| + } |
| + return 0, false |
| +} |
| + |
| +func (m *certMirror) clock() time.Time { |
| + if m.now != nil { |
| + return m.now() |
| + } |
| + return time.Now() |
| +} |
| + |
| +// jitteredBackstop is release.ChangeIndexBackstop jittered by up to 10% either |
| +// way (R3), drawn per full read so a fleet's rebuilds do not line up. |
| +func jitteredBackstop() time.Duration { |
| + b := release.ChangeIndexBackstop |
| + return b - b/10 + time.Duration(rand.Int64N(int64(b/5)+1)) |
| } |
| // logPartialPoll reports unreadable objects on the loop's own first-and-every- |
| @@ -908,7 +1097,7 @@ func (m *certMirror) run(stop <-chan struct{}) { |
| case <-tick.C: |
| } |
| ctx, cancel := context.WithTimeout(context.Background(), certMirrorTimeout) |
| - err := m.Refresh(ctx) |
| + err := m.poll(ctx) |
| cancel() |
| if err != nil { |
| errs++ |
diff --git a/internal/edge/certsync_test.go b/internal/edge/certsync_test.go| index 0dd51fc51..681bed511 100644 |
| --- a/internal/edge/certsync_test.go |
| +++ b/internal/edge/certsync_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "runtime" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -1951,3 +1952,352 @@ func TestCertMirrorRefreshFetchesConcurrently(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- #336: the change index gates the mirror's reads ------------------------- |
| + |
| +// changeIndexStore is a DirStore with the change index on that records every |
| +// read by method and key. Embedding the concrete store forwards |
| +// ConditionalGetter and ETagLister, so the mirror and the watch see the store |
| +// production hands them, and every write bumps its scope as in production. |
| +type changeIndexStore struct { |
| + *release.DirStore |
| + mu sync.Mutex |
| + calls []string |
| + failGet string // Get of exactly this key fails |
| +} |
| + |
| +func (s *changeIndexStore) setFailGet(key string) { s.mu.Lock(); s.failGet = key; s.mu.Unlock() } |
| + |
| +func newChangeIndexStore(t *testing.T) *changeIndexStore { |
| + t.Helper() |
| + d := release.NewDirStore(t.TempDir()) |
| + d.EnableChangeIndex() |
| + return &changeIndexStore{DirStore: d} |
| +} |
| + |
| +func (s *changeIndexStore) note(method, key string) { |
| + s.mu.Lock() |
| + s.calls = append(s.calls, method+" "+key) |
| + s.mu.Unlock() |
| +} |
| + |
| +func (s *changeIndexStore) reset() { s.mu.Lock(); s.calls = nil; s.mu.Unlock() } |
| + |
| +// count is how many recorded calls of method name a key under prefix. |
| +func (s *changeIndexStore) count(method, prefix string) int { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + n := 0 |
| + for _, c := range s.calls { |
| + if strings.HasPrefix(c, method+" "+prefix) { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +func (s *changeIndexStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.note("Get", key) |
| + s.mu.Lock() |
| + fail := s.failGet != "" && s.failGet == key |
| + s.mu.Unlock() |
| + if fail { |
| + return nil, "", errors.New("get refused") |
| + } |
| + return s.DirStore.Get(ctx, key) |
| +} |
| + |
| +func (s *changeIndexStore) GetIfChanged(ctx context.Context, key, etag string) ([]byte, string, bool, error) { |
| + s.note("GetIfChanged", key) |
| + return s.DirStore.GetIfChanged(ctx, key, etag) |
| +} |
| + |
| +func (s *changeIndexStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + s.note("List", prefix) |
| + return s.DirStore.List(ctx, prefix) |
| +} |
| + |
| +func (s *changeIndexStore) ListETags(ctx context.Context, prefix string) ([]release.KeyETag, error) { |
| + s.note("ListETags", prefix) |
| + return s.DirStore.ListETags(ctx, prefix) |
| +} |
| + |
| +// certReads is every bucket read the mirror made under control/certs/. |
| +func certReads(s *changeIndexStore) (list, listETags, get int) { |
| + return s.count("List", certPrefix), s.count("ListETags", certPrefix), s.count("Get", certPrefix) |
| +} |
| + |
| +// armedMirror is a mirror over s with the change index armed: one watch over |
| +// the same store (the agent's, in production) and an injected clock. |
| +func armedMirror(t *testing.T, s *changeIndexStore) (*certMirror, *testClock) { |
| + t.Helper() |
| + w := release.NewChangeWatch(s) |
| + clk := &testClock{t: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)} |
| + m := newCertMirror(s, nil) |
| + m.watch = func() *release.ChangeWatch { return w } |
| + m.now = clk.now |
| + return m, clk |
| +} |
| + |
| +func mirrorPoll(t *testing.T, m *certMirror) { |
| + t.Helper() |
| + if err := m.poll(context.Background()); err != nil { |
| + t.Fatalf("poll: %v", err) |
| + } |
| +} |
| + |
| +func TestCertMirrorSkipsWhenUnmoved(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + putBucket(t, s, "shop.example.com", []byte("PEM2")) |
| + m, clk := armedMirror(t, s) |
| + mirrorPoll(t, m) |
| + if b, ok := m.Cert("example.com"); !ok || string(b) != "PEM" { |
| + t.Fatalf("first poll: mirror cert = %q, %v", b, ok) |
| + } |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l+le+g != 0 { |
| + t.Fatalf("unmoved poll read the bucket: List %d, ListETags %d, Get %d; want none", l, le, g) |
| + } |
| + if n := s.count("GetIfChanged", release.ChangeIndexKey); n != 1 { |
| + t.Fatalf("unmoved poll: %d index reads, want 1", n) |
| + } |
| + if b, ok := m.Cert("shop.example.com"); !ok || string(b) != "PEM2" { |
| + t.Fatalf("after a skipped poll the mirror = %q, %v — want the same copy", b, ok) |
| + } |
| +} |
| + |
| +func TestCertMirrorFetchesOnlyChanged(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + local := newCountingCache(t) |
| + pemA := hostCertPEM(t, "a.example.com", time.Now().Add(60*24*time.Hour)) |
| + pemB := hostCertPEM(t, "b.example.com", time.Now().Add(60*24*time.Hour)) |
| + putBucket(t, s, "a.example.com", pemA) |
| + putBucket(t, s, "b.example.com", pemB) |
| + putBucket(t, s, "c.example.com", []byte("PEM-C")) |
| + m, clk := armedMirror(t, s) |
| + m.local = local |
| + mirrorPoll(t, m) |
| + |
| + renewed := hostCertPEM(t, "b.example.com", time.Now().Add(90*24*time.Hour)) |
| + putBucket(t, s, "b.example.com", renewed) |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + l, le, g := certReads(s) |
| + if l != 0 || le != 1 || g != 1 { |
| + t.Fatalf("one rewritten cert: List %d, ListETags %d, Get %d; want 0, 1, 1", l, le, g) |
| + } |
| + if n := s.count("Get", certKey("b.example.com")); n != 1 { |
| + t.Fatalf("the one Get must be the rewritten cert's, got %d of it", n) |
| + } |
| + if b, ok := m.Cert("b.example.com"); !ok || !bytes.Equal(b, renewed) { |
| + t.Fatal("the rewritten cert must be served from the new snapshot") |
| + } |
| + for _, name := range []string{"a.example.com", "c.example.com"} { |
| + if _, ok := m.Cert(name); !ok { |
| + t.Fatalf("%s did not move and must stay in the snapshot", name) |
| + } |
| + } |
| + if got, err := localCert(t, local, "b.example.com"); err != nil || !bytes.Equal(got, renewed) { |
| + t.Fatalf("a changed cert must still be copied into the local cache: %v", err) |
| + } |
| + if m.snapshot().certs["b.example.com"] == nil { |
| + t.Fatal("the changed cert must be parsed into the new snapshot") |
| + } |
| +} |
| + |
| +func TestCertMirrorDropsRemovedKey(t *testing.T) { |
| + t.Parallel() |
| + const token = "0123456789abcdefghij0123456789abcdefghijkl" |
| + s := newChangeIndexStore(t) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + putBucket(t, s, token+httpTokenSuffix, []byte("resp")) |
| + m, clk := armedMirror(t, s) |
| + mirrorPoll(t, m) |
| + if _, ok := m.Challenge(token); !ok { |
| + t.Fatal("precondition: the live token is mirrored") |
| + } |
| + if err := s.Delete(context.Background(), certKey(token+httpTokenSuffix)); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if _, ok := m.Challenge(token); ok { |
| + t.Fatal("a spent challenge token must leave the snapshot when its key leaves the listing") |
| + } |
| + if b, ok := m.Cert("example.com"); !ok || string(b) != "PEM" { |
| + t.Fatalf("the unmoved cert = %q, %v — want it kept", b, ok) |
| + } |
| + if l, le, g := certReads(s); l != 0 || le != 1 || g != 0 { |
| + t.Fatalf("a deletion: List %d, ListETags %d, Get %d; want 0, 1, 0", l, le, g) |
| + } |
| +} |
| + |
| +func TestCertMirrorFullRebuildAtBackstop(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + putBucket(t, s, "shop.example.com", []byte("PEM2")) |
| + m, clk := armedMirror(t, s) |
| + mirrorPoll(t, m) |
| + |
| + // Inside the jitter's lower bound (-10%): still trusted. |
| + s.reset() |
| + clk.advance(release.ChangeIndexBackstop*9/10 - time.Minute) |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l+le+g != 0 { |
| + t.Fatalf("a young snapshot with the index unmoved: List %d, ListETags %d, Get %d; want none", l, le, g) |
| + } |
| + |
| + // Past the jitter's upper bound (+10%): a full rebuild, index unmoved. |
| + s.reset() |
| + clk.advance(release.ChangeIndexBackstop*2/10 + 2*time.Minute) |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l != 1 || le != 0 || g != 2 { |
| + t.Fatalf("at the backstop: List %d, ListETags %d, Get %d; want the full rebuild 1, 0, 2", l, le, g) |
| + } |
| + |
| + // The rebuild resets the age: the next unmoved poll reads nothing. |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l+le+g != 0 { |
| + t.Fatalf("after the backstop rebuild: List %d, ListETags %d, Get %d; want none", l, le, g) |
| + } |
| +} |
| + |
| +func TestCertMirrorBootRefreshUnchanged(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + putBucket(t, s, "shop.example.com", []byte("PEM2")) |
| + m, clk := armedMirror(t, s) |
| + // The agent's tick has already polled the shared watch. |
| + if _, err := m.watch().Poll(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.reset() |
| + // The boot path: a full read, whatever the index says, and twice over. |
| + for i := 0; i < 2; i++ { |
| + if err := m.Refresh(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if l, le, g := certReads(s); l != 2 || le != 0 || g != 4 { |
| + t.Fatalf("two boot refreshes: List %d, ListETags %d, Get %d; want 2, 0, 4", l, le, g) |
| + } |
| + if n := s.count("GetIfChanged", ""); n != 0 { |
| + t.Fatalf("the boot refresh must not poll the index (%d reads): it runs before the listeners bind", n) |
| + } |
| + // The loop's first poll trusts the boot read. |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l+le+g != 0 { |
| + t.Fatalf("first poll after boot, index unmoved: List %d, ListETags %d, Get %d; want none", l, le, g) |
| + } |
| +} |
| + |
| +// Disarmed (no watch handed down, or the agent never armed one), every poll |
| +// is today's full Refresh. |
| +func TestCertMirrorDisarmedPollIsAFullRefresh(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + for _, watch := range []func() *release.ChangeWatch{nil, func() *release.ChangeWatch { return nil }} { |
| + m := newCertMirror(s, nil) |
| + m.watch = watch |
| + for i := 0; i < 2; i++ { |
| + s.reset() |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l != 1 || le != 0 || g != 1 { |
| + t.Fatalf("disarmed poll %d: List %d, ListETags %d, Get %d; want 1, 0, 1", i, l, le, g) |
| + } |
| + if n := s.count("GetIfChanged", ""); n != 0 { |
| + t.Fatalf("disarmed poll read the index %d time(s)", n) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// A changed cert whose Get fails keeps its previous copy (degrade, don't |
| +// die), and the next poll reads it again with the index unmoved. |
| +func TestCertMirrorFailedGetOfAChangedCertKeepsThePreviousCopy(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putBucket(t, s, "example.com", []byte("PEM")) |
| + putBucket(t, s, "shop.example.com", []byte("PEM2")) |
| + m, clk := armedMirror(t, s) |
| + mirrorPoll(t, m) |
| + |
| + putBucket(t, s, "example.com", []byte("PEM-RENEWED")) |
| + putBucket(t, s, "new.example.com", []byte("PEM3")) |
| + s.setFailGet(certKey("example.com")) |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if b, ok := m.Cert("example.com"); !ok || string(b) != "PEM" { |
| + t.Fatalf("a failed Get of a changed cert: mirror = %q, %v — want the previous copy", b, ok) |
| + } |
| + if b, ok := m.Cert("new.example.com"); !ok || string(b) != "PEM3" { |
| + t.Fatalf("the other changed cert = %q, %v — want it landed", b, ok) |
| + } |
| + |
| + s.setFailGet("") |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if b, ok := m.Cert("example.com"); !ok || string(b) != "PEM-RENEWED" { |
| + t.Fatalf("the retry: mirror = %q, %v — want the renewed copy", b, ok) |
| + } |
| + if l, le, g := certReads(s); l != 0 || le != 1 || g != 1 { |
| + t.Fatalf("the retry: List %d, ListETags %d, Get %d; want 0, 1, 1", l, le, g) |
| + } |
| + s.reset() |
| + clk.advance(certMirrorInterval) |
| + mirrorPoll(t, m) |
| + if l, le, g := certReads(s); l+le+g != 0 { |
| + t.Fatalf("settled: List %d, ListETags %d, Get %d; want none", l, le, g) |
| + } |
| +} |
| + |
| +// The scopes the mirror and the home map gate on are the ones their prefixes' |
| +// writes bump. |
| +func TestCertMirrorAndHomeMapScopesMatchTheirPrefixes(t *testing.T) { |
| + t.Parallel() |
| + if got, ok := release.ChangeScope(certKey("example.com")); !ok || got != certScope { |
| + t.Fatalf("a cert write bumps %q (%v), the mirror gates on %q", got, ok, certScope) |
| + } |
| + if got, ok := release.ChangeScope(edgesPrefix + "box.json"); !ok || got != edgesScope { |
| + t.Fatalf("an edges-file write bumps %q (%v), the home map gates on %q", got, ok, edgesScope) |
| + } |
| +} |
| + |
| +// WithChangeWatch reaches both loops, resolved per poll: a watch the agent |
| +// arms after the edge started is still seen. |
| +func TestCertMirrorWithChangeWatchReachesTheMirrorAndHomeMap(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + var armed atomic.Pointer[release.ChangeWatch] |
| + e, err := New(openReg(t), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(e.probers.Stop) |
| + e.staticStore = s |
| + WithChangeWatch(armed.Load)(e) |
| + m, hm := e.newCertMirror(nil), e.newHomeMap() |
| + if m.changeWatch() != nil || hm.changeWatch() != nil { |
| + t.Fatal("nothing armed yet: both loops must be disarmed") |
| + } |
| + w := release.NewChangeWatch(s) |
| + armed.Store(w) |
| + if m.changeWatch() != w || hm.changeWatch() != w { |
| + t.Fatal("a watch armed after the edge started must reach both loops") |
| + } |
| +} |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 31ea8b8de..a23121947 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -258,6 +258,10 @@ type Edge struct { |
| // this one and never the reverse. It is what the home map excludes its own |
| // hosts by; "" (bare `carlos edge`) leaves the map offering nothing. |
| box string |
| + // changeWatch resolves the process's change-index watch (#336), handed |
| + // down through WithChangeWatch. The cert mirror and the home map call it |
| + // on every poll. nil, or a nil watch, keeps both on today's full reads. |
| + changeWatch func() *release.ChangeWatch |
| // homes, when non-nil, is the mirror of control/edges/ plus the mesh keys |
| // (homemap.go): who homes a host this box does not serve, and what proves |
| // an inbound hop. Atomic because it is armed in RunWithActivatorReady after |
| @@ -326,6 +330,19 @@ func WithIssuerContender(c func() bool) RunOption { return func(e *Edge) { e.con |
| // See homemap.go. |
| func WithBoxLabel(label string) RunOption { return func(e *Edge) { e.box = label } } |
| +// WithChangeWatch hands the edge the agent's change-index watch (#336) as a |
| +// function, called on every cert-mirror and home-map poll. A function and |
| +// not the watch itself, because the agent may arm its watch after the edge |
| +// has started (armChangeIndex runs on a goroutine on some boxes), and an |
| +// edge holding a nil captured at start would stay disarmed for the life of |
| +// the process. One watch per process, the agent's: the edge never arms one |
| +// of its own, so CARLOS_CHANGE_INDEX and the fleet-box exclusion stay |
| +// decided in one place. The function, or the watch it returns, nil keeps |
| +// both loops on today's full reads. |
| +func WithChangeWatch(w func() *release.ChangeWatch) RunOption { |
| + return func(e *Edge) { e.changeWatch = w } |
| +} |
| + |
| // WithEdgeDoor asks for the edge-local door (door.go, #175 P7) on the unix |
| // socket at path, presenting publicAddr, the box's public address, as the |
| // address of every door request. The door opens once the home map and the |
diff --git a/internal/edge/homemap.go b/internal/edge/homemap.go| index f91901da4..e266bc40e 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -287,6 +287,24 @@ type homeMap struct { |
| // nil without region HA: Refresh latches the first loaded map and key |
| // and stamps every attempt it completes. |
| health *lbhealth.State |
| + |
| + // watch and now are certMirror's (#336): the process's change-index |
| + // watch, resolved on every poll (nil, or a nil watch, is today's map), |
| + // and the poll's clock (nil is time.Now). Only the hosts half is gated |
| + // on them; the mesh keys keep their own read on every poll. |
| + watch func() *release.ChangeWatch |
| + now func() time.Time |
| + |
| + // Guarded by refreshMu: the last hosts rebuild that read every edges |
| + // file (lastHosts), the jittered backstop after which the next poll |
| + // reads them all again (hostsAge), and the control/edges generation, |
| + // captured BEFORE that read, which the fold is known to reflect |
| + // (actedOK false = none: the watch could not vouch, or a file went |
| + // unread or unparsed and must be retried). |
| + lastHosts time.Time |
| + hostsAge time.Duration |
| + actedGen uint64 |
| + actedOK bool |
| } |
| func newHomeMap(s release.Store, self string) *homeMap { |
| @@ -305,6 +323,7 @@ func (e *Edge) newHomeMap() *homeMap { |
| m := newHomeMap(e.staticStore, e.box) |
| m.privateDial = regionHAFromEnv(os.Getenv) |
| m.health = e.lbHealth |
| + m.watch = e.changeWatch |
| return m |
| } |
| @@ -320,17 +339,72 @@ func (e *Edge) newHomeMap() *homeMap { |
| func (m *homeMap) Refresh(ctx context.Context) error { |
| m.refreshMu.Lock() |
| defer m.refreshMu.Unlock() |
| - hostsErr, keysErr := m.refreshHosts(ctx), m.refreshKeys(ctx) |
| + return m.refresh(ctx, true) |
| +} |
| + |
| +// poll is the 30s loop's refresh (#336). Disarmed, it is Refresh. Armed, it |
| +// polls the change index and skips the hosts half when control/edges has not |
| +// moved since the generation the fold reflects and the last full hosts read |
| +// is younger than the jittered backstop; otherwise (moved, the watch unable |
| +// to vouch, no read yet, or at the backstop) the hosts half runs as today. |
| +// The mesh keys are read on every poll whatever the index says: their |
| +// objects (control/edge-mesh-key and .next) bump no scope, so a rotation |
| +// must not wait on control/edges (codex 8). Poll's moved list is advisory |
| +// (the watch is shared), so the decision compares generations instead. |
| +func (m *homeMap) poll(ctx context.Context) error { |
| + w := m.changeWatch() |
| + if w == nil { |
| + return m.Refresh(ctx) |
| + } |
| + // An error leaves the watch unusable, which Gen reports below. |
| + _, _ = w.Poll(ctx) |
| + m.refreshMu.Lock() |
| + defer m.refreshMu.Unlock() |
| + gen, ok := w.Gen(edgesScope) |
| + current := ok && m.actedOK && gen == m.actedGen && |
| + !m.lastHosts.IsZero() && m.clock().Before(m.lastHosts.Add(m.hostsAge)) |
| + return m.refresh(ctx, !current) |
| +} |
| + |
| +// refresh is Refresh's body, under refreshMu. readHosts false skips the |
| +// hosts half, which then counts as a success: the fold it keeps is one a |
| +// clean read landed (poll skips only after one, and that read latched |
| +// MapLoaded), so the latch below is a no-op and the health answer is what a |
| +// full read would have given. The attempt is stamped either way, so the |
| +// five-minute refresh heartbeat holds across skipped polls. |
| +func (m *homeMap) refresh(ctx context.Context, readHosts bool) error { |
| + var hostsErr error |
| + if readHosts { |
| + hostsErr = m.refreshHosts(ctx) |
| + } |
| + keysErr := 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()) |
| + m.health.RefreshAttempted(m.clock()) |
| return errors.Join(hostsErr, keysErr) |
| } |
| +// edgesScope is the change-index scope every write under edgesPrefix bumps. |
| +const edgesScope = "control/edges" |
| + |
| +func (m *homeMap) changeWatch() *release.ChangeWatch { |
| + if m.watch == nil { |
| + return nil |
| + } |
| + return m.watch() |
| +} |
| + |
| +func (m *homeMap) clock() time.Time { |
| + if m.now != nil { |
| + return m.now() |
| + } |
| + return time.Now() |
| +} |
| + |
| // refreshHosts rebuilds the whole map from one list plus a get per key — no |
| // delta cleverness, for certMirror.Refresh's reason: a full rebuild is what |
| // makes a RETIRED box's hosts actually leave the map. |
| @@ -340,6 +414,13 @@ func (m *homeMap) Refresh(ctx context.Context) error { |
| // backhaul in the deployment at once, turning a blip into a regional outage |
| // for every host homed elsewhere. |
| func (m *homeMap) refreshHosts(ctx context.Context) error { |
| + // The generation this read will reflect, captured BEFORE the list, so a |
| + // write landing during the read leaves control/edges moved past it (#336). |
| + var gen uint64 |
| + var genOK bool |
| + if w := m.changeWatch(); w != nil { |
| + gen, genOK = w.Gen(edgesScope) |
| + } |
| keys, err := m.s.List(ctx, edgesPrefix) |
| if err != nil { |
| return fmt.Errorf("list %s: %w", edgesPrefix, err) |
| @@ -407,6 +488,10 @@ func (m *homeMap) refreshHosts(ctx context.Context) error { |
| m.snap = snap |
| wake := m.hostsWake |
| m.mu.Unlock() |
| + // A partial read reflects no generation: the file it could not read or |
| + // parse is retried on the next poll, as it always was. |
| + m.lastHosts, m.hostsAge = m.clock(), jitteredBackstop() |
| + m.actedGen, m.actedOK = gen, genOK && failed == 0 |
| if wake != nil && !samePlatformSet(prev.hosts, snap.hosts) { |
| // The deployment's platform name set moved: a wildcard parent may |
| // have appeared for an account homed elsewhere (#300). Off this |
| @@ -922,7 +1007,7 @@ func (m *homeMap) run(stop <-chan struct{}) { |
| case <-tick.C: |
| } |
| ctx, cancel := context.WithTimeout(context.Background(), homeMapTimeout) |
| - err := m.Refresh(ctx) |
| + err := m.poll(ctx) |
| cancel() |
| if err != nil { |
| errs++ |
diff --git a/internal/edge/homemap_test.go b/internal/edge/homemap_test.go| index cdf518660..9c3e786ea 100644 |
| --- a/internal/edge/homemap_test.go |
| +++ b/internal/edge/homemap_test.go |
| @@ -18,6 +18,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/edgeproof" |
| + "github.com/carlosframework/platform/internal/lbhealth" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -1784,3 +1785,139 @@ func TestLocalWildcardYieldsToARemoteExactHost(t *testing.T) { |
| t.Fatalf("home edge saw %d request(s), want still 1", n) |
| } |
| } |
| + |
| +// ---- #336: only the hosts half is gated on the change index ------------------ |
| + |
| +// armedHomeMap is a home map over s with the change index armed (one watch over |
| +// the same store, the agent's in production) and a clock that starts now, so |
| +// the health state's real-time reads agree with it. |
| +func armedHomeMap(t *testing.T, s *changeIndexStore, self string) (*homeMap, *testClock) { |
| + t.Helper() |
| + w := release.NewChangeWatch(s) |
| + clk := &testClock{t: time.Now()} |
| + m := newHomeMap(s, self) |
| + m.watch = func() *release.ChangeWatch { return w } |
| + m.now = clk.now |
| + return m, clk |
| +} |
| + |
| +func homePoll(t *testing.T, m *homeMap) { |
| + t.Helper() |
| + if err := m.poll(context.Background()); err != nil { |
| + t.Fatalf("poll: %v", err) |
| + } |
| +} |
| + |
| +func edgesReads(s *changeIndexStore) int { |
| + return s.count("List", edgesPrefix) + s.count("ListETags", edgesPrefix) + s.count("Get", edgesPrefix) |
| +} |
| + |
| +// codex 8: a mesh-key rotation writes control/edge-mesh-key, which bumps no |
| +// scope, so the keys must not wait on control/edges. |
| +func TestHomeMapKeysPolledIndependently(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`) |
| + putJSON(t, s, meshKeyKey, strings.Repeat("aa", 32)) |
| + m, clk := armedHomeMap(t, s, "annie") |
| + homePoll(t, m) |
| + if _, _, ok := m.Home("b.test"); !ok { |
| + t.Fatal("precondition: the first poll folds the edges files") |
| + } |
| + |
| + putJSON(t, s, meshKeyKey, strings.Repeat("bb", 32)) |
| + s.reset() |
| + clk.advance(homeMapInterval) |
| + homePoll(t, m) |
| + if cur, _ := m.meshKeys(); string(cur) != strings.Repeat("\xbb", 32) { |
| + t.Fatalf("the rotated mesh key must load within one poll, got %x", cur) |
| + } |
| + if n := edgesReads(s); n != 0 { |
| + t.Fatalf("control/edges did not move, yet the poll made %d read(s) under it", n) |
| + } |
| + if n := s.count("Get", meshKeyKey); n < 1 { |
| + t.Fatal("the mesh key must be read on every poll") |
| + } |
| + if _, _, ok := m.Home("b.test"); !ok { |
| + t.Fatal("a skipped hosts read must keep the fold") |
| + } |
| +} |
| + |
| +// codex 8: the five-minute refresh heartbeat holds across polls whose hosts |
| +// read is skipped, and the forwarding latch stays set. |
| +func TestHomeMapHeartbeatOnUnchangedPoll(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`) |
| + putJSON(t, s, meshKeyKey, strings.Repeat("aa", 32)) |
| + m, clk := armedHomeMap(t, s, "annie") |
| + health := &lbhealth.State{} |
| + m.health = health |
| + homePoll(t, m) |
| + for i := 0; i < 4; i++ { |
| + s.reset() |
| + clk.advance(lbhealth.RefreshWindow - time.Minute) |
| + homePoll(t, m) |
| + if n := edgesReads(s); n != 0 { |
| + t.Fatalf("unchanged poll %d made %d read(s) under control/edges", i, n) |
| + } |
| + for _, c := range health.Failing(clk.now()) { |
| + if c == lbhealth.CondRefresh || c == lbhealth.CondForwarding { |
| + t.Fatalf("unchanged poll %d: health fails %q — a skipped hosts read must still beat", i, c) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The hosts half re-reads when control/edges moves, and at the backstop with |
| +// the index unmoved. |
| +func TestHomeMapHostsReadOnMoveAndAtBackstop(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`) |
| + m, clk := armedHomeMap(t, s, "annie") |
| + homePoll(t, m) |
| + |
| + putJSON(t, s, "control/edges/carol.json", `{"v":1,"box":"carol","eip":"3.3.3.3","hosts":{"c.test":{"role":"home"}}}`) |
| + s.reset() |
| + clk.advance(homeMapInterval) |
| + homePoll(t, m) |
| + if _, _, ok := m.Home("c.test"); !ok { |
| + t.Fatal("a moved control/edges must be read on the next poll") |
| + } |
| + if n := s.count("List", edgesPrefix); n != 1 { |
| + t.Fatalf("moved: %d list(s) of control/edges, want 1", n) |
| + } |
| + |
| + s.reset() |
| + clk.advance(release.ChangeIndexBackstop*9/10 - time.Minute) |
| + homePoll(t, m) |
| + if n := edgesReads(s); n != 0 { |
| + t.Fatalf("young and unmoved: %d read(s), want none", n) |
| + } |
| + s.reset() |
| + clk.advance(release.ChangeIndexBackstop*2/10 + 2*time.Minute) |
| + homePoll(t, m) |
| + if n := s.count("List", edgesPrefix); n != 1 { |
| + t.Fatalf("at the backstop: %d list(s) of control/edges, want 1", n) |
| + } |
| +} |
| + |
| +// Disarmed, every poll reads the hosts as today. |
| +func TestHomeMapDisarmedPollReadsHosts(t *testing.T) { |
| + t.Parallel() |
| + s := newChangeIndexStore(t) |
| + putJSON(t, s, "control/edges/betty.json", `{"v":1,"box":"betty","eip":"2.2.2.2","hosts":{"b.test":{"role":"home"}}}`) |
| + m := newHomeMap(s, "annie") |
| + m.watch = func() *release.ChangeWatch { return nil } |
| + for i := 0; i < 2; i++ { |
| + s.reset() |
| + homePoll(t, m) |
| + if n := s.count("List", edgesPrefix); n != 1 { |
| + t.Fatalf("disarmed poll %d: %d list(s), want 1", i, n) |
| + } |
| + if n := s.count("GetIfChanged", ""); n != 0 { |
| + t.Fatalf("disarmed poll %d read the index", i) |
| + } |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 2385e2cc8..1bee56397 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -899,6 +899,12 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // the two derivations must be one derivation. Without it the edge |
| // would offer nothing (fail-closed), never dial itself. |
| edge.WithBoxLabel(metricsBoxLabel(os.Getenv, readMachineID())), |
| + // The change index (#336): the cert mirror and the home map read |
| + // through the process's one watch. A loader, not the watch: on a box |
| + // without claim writes the index is armed on runDuties' goroutine, |
| + // which may not have run yet. Disarmed, it returns nil for good and |
| + // both keep today's full reads. |
| + edge.WithChangeWatch(armedChangeWatch.Load), |
| }, |
| // The edge-local door (#175 P7), only when CARLOS_EDGE_DOOR names a |
| // socket. It presents this box's public address, the one the edges |