host: config generation loop — a set converges like a canary adopts (issue #44)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013WVANvB83eHrRANRpEL8mn
2 files changed,
+439
−8
internal/host/agent.go+182 −8internal/host/agent_test.go+257 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 49d4d016d..b2a30e6d8 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -67,6 +67,7 @@ import ( |
| "crypto/ed25519" |
| "errors" |
| "flag" |
| + "fmt" |
| "io" |
| "log" |
| "math/rand/v2" |
| @@ -149,6 +150,7 @@ func RunAgent(args []string) { |
| go domainsLoop(reg, peekAcmeCache(args)) |
| go metricsLoop() |
| go configSyncLoop(reg, svc, dd) |
| + go configGenLoop(reg, svc, dd) |
| go socketAdvisoryLoop(reg, svc) |
| // The same serving loop as `carlos edge`, with the activator injected. |
| @@ -247,22 +249,194 @@ func configSyncLoop(reg *registry.Registry, svc *activator.Service, dd string) { |
| log.Printf("agent: config sync loop disabled: %v", err) |
| return |
| } |
| - bounce := func(host string) error { |
| + log.Printf("agent: config sync loop armed (hourly + jitter)") |
| + delay := time.Minute |
| + for { |
| + time.Sleep(delay) |
| + delay = time.Hour + rand.N(10*time.Minute) |
| + if err := appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc)); err != nil { |
| + log.Printf("agent: config sync: %v", err) |
| + } |
| + } |
| +} |
| + |
| +// bounce builds the restart callback SyncExec calls for a route whose |
| +// rendered config actually changed. Shared by BOTH config loops (hourly |
| +// configSyncLoop and the fast configGenLoop) rather than duplicated per |
| +// loop, unlike the adopt pair: those two differ from each other (filter, |
| +// nag) where these two must not — the delivery must be identical whichever |
| +// loop happened to notice a change first, and a divergence would mean an |
| +// app converges one way at 3s and another way on the hour. A nil svc (no |
| +// activator armed) still returns a working no-op: the files converge for |
| +// future spawns, there is simply nothing running to restart. |
| +func bounce(svc *activator.Service) func(string) error { |
| + return func(host string) error { |
| if svc == nil { |
| - return nil // no activator: files still converge for future spawns |
| + return nil |
| } |
| if svc.Bounce(host) { |
| log.Printf("agent: config changed, bounced %s", host) |
| } |
| return nil |
| } |
| - log.Printf("agent: config sync loop armed (hourly + jitter)") |
| - delay := time.Minute |
| +} |
| + |
| +// genAbsent marks an app whose generation object was READ and found |
| +// missing, distinguishing it from an app never seen at all — the two are |
| +// different in the only way this loop cares about: a never-seen app must |
| +// record silently (first sight), while a known-absent app that suddenly |
| +// has a generation object is an app whose config was just written for the |
| +// first time, which must sync. A real etag can never collide with it: no |
| +// store produces one containing a NUL byte (S3 etags are hex/quoted, |
| +// DirStore's is hex). |
| +const genAbsent = "\x00absent" |
| + |
| +// genPollPass is one tick of configGenLoop, split out for tests: read every |
| +// exec-config app's generation etag and report whether ANY moved since the |
| +// cache was built. |
| +// |
| +// Semantics, each of which is a deliberate choice: |
| +// |
| +// - First sight of an app (no cache entry) records silently and does NOT |
| +// sync. Boot must not re-bounce the world — every app would look |
| +// "changed" on the first tick after a restart. configSyncLoop's ~1min |
| +// first pass owns boot convergence. |
| +// - An unchanged etag does nothing. |
| +// - Absent that stays absent does nothing, forever: an app last written |
| +// by a pre-generation CLI has no generation object and never will |
| +// until someone writes its config again. That is legal, not a fault. |
| +// - Any movement syncs: the etag changed, or absent -> present. |
| +// present -> absent shouldn't happen (generation objects are only ever |
| +// created and overwritten, never deleted), but it's treated as a change |
| +// too — one extra SyncExec pass is cheap, and pretending we didn't see |
| +// it would be a lie about the only thing this function reports. |
| +// - ONE bool for the whole pass, however many apps moved: the caller runs |
| +// one SyncExec, which resolves every route itself. Per-app syncs would |
| +// multiply bucket reads to deliver exactly the same files. |
| +// - Apps that vanish from the route set lose their cache entries, so a |
| +// re-added app is a first sight again rather than a comparison against |
| +// an etag remembered from before it left (a route being re-added gets |
| +// fresh config from the spawn path anyway). |
| +// - A read error that isn't "not found" leaves that app's cache entry |
| +// exactly as it was and keeps evaluating the rest: the errors come back |
| +// for the caller to aggregate into ONE log line per pass, never one per |
| +// app per tick. Clearing the entry instead would turn a recovered blip |
| +// into a bogus first sight or a bogus change. |
| +// |
| +// The cache is mutated in place — it lives across ticks in the loop, which |
| +// is the whole point (there is nowhere else to remember what "changed" |
| +// means). |
| +func genPollPass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (bool, []error) { |
| + apps := appconfig.ExecConfigApps(routes) |
| + live := make(map[[2]string]bool, len(apps)) |
| + changed := false |
| + var readErrs []error |
| + for _, k := range apps { |
| + live[k] = true |
| + _, etag, err := store.Get(ctx, appconfig.GenKey(k[0], k[1])) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = genAbsent |
| + case err != nil: |
| + readErrs = append(readErrs, fmt.Errorf("%s/%s: %w", k[0], k[1], err)) |
| + continue // cache entry untouched: this app is unknown this tick, not changed |
| + } |
| + prev, seen := cache[k] |
| + cache[k] = etag |
| + if seen && prev != etag { |
| + changed = true |
| + } |
| + } |
| + for k := range cache { |
| + if !live[k] { |
| + delete(cache, k) |
| + } |
| + } |
| + return changed, readErrs |
| +} |
| + |
| +// configGenLoop is canaryAdoptLoop's shape applied to issue #44: a config |
| +// set should converge like a canary adopts — seconds, not the next hour. |
| +// Every ~3s it reads one small object per exec-config app (the generation |
| +// object every config write touches) and, on any movement, runs ONE |
| +// SyncExec pass for the whole tick. Spurious movement costs a pass that |
| +// finds nothing to write: SyncExec bounces only routes whose rendered file |
| +// actually changed, so a redundant touch can never cost a restart. |
| +// |
| +// Gated on CARLOS_SECRETS_KEY exactly as configSyncLoop is, for a reason |
| +// beyond convention: without the key SyncExec cannot render an app's |
| +// environment at all, so this loop would poll the bucket every 3 seconds |
| +// forever to signal no one. openDeployStore failing is the same story with |
| +// nothing to read. |
| +// |
| +// Cardinality: unlike canaryAdoptLoop, which is bounded to the deliberately |
| +// rare canary/* routes, this watches EVERY exec-config app on the box. That |
| +// is accepted knowingly — one GET of the smallest object in the bucket per |
| +// app per 3s, against the store canaryAdoptLoop already hits every 2s. What |
| +// keeps it bounded is that the pass reads generation objects only: no |
| +// manifests, no pointers, no per-environment resolution. All of that |
| +// happens inside the SyncExec a real change triggers. |
| +// |
| +// Deliberately NOT done here: |
| +// |
| +// - No fast first pass. The loop sleeps BEFORE its first tick and the |
| +// first sight of every app is silent (see genPollPass) — boot |
| +// convergence belongs to configSyncLoop's ~1min first pass, and a box |
| +// coming up must not bounce every instance it has because it has never |
| +// seen their etags before. |
| +// - No unit-backed routes. This process cannot restart units (see |
| +// RunAdopt); the root adopt path keeps that half, at its own cadence. |
| +// - No hourly floor replaced. configSyncLoop stays exactly as it was: it |
| +// catches apps written by older CLIs (no generation object at all), |
| +// ticks this loop missed, and the boot-retry gap of issue #27. |
| +// |
| +// Thin and untested by convention, like adoptLoop/statusLoop — the tick's |
| +// whole decision lives in genPollPass, which is tested. |
| +func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) { |
| + raw := os.Getenv("CARLOS_SECRETS_KEY") |
| + if raw == "" { |
| + log.Printf("agent: config generation loop disabled: CARLOS_SECRETS_KEY not set") |
| + return |
| + } |
| + priv, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| + if err != nil { |
| + log.Printf("agent: config generation loop disabled: %v", err) |
| + return |
| + } |
| + ctx := context.Background() |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + log.Printf("agent: config generation loop disabled: %v", err) |
| + return |
| + } |
| + // One cache for the life of the process: what "changed" means is |
| + // exactly "differs from the last tick that could read it". |
| + cache := map[[2]string]string{} |
| + log.Printf("agent: config generation loop armed (3s + jitter, exec-config apps)") |
| for { |
| - time.Sleep(delay) |
| - delay = time.Hour + rand.N(10*time.Minute) |
| - if err := appconfig.SyncExec(ctx, reg, store, priv, dd, bounce); err != nil { |
| - log.Printf("agent: config sync: %v", err) |
| + time.Sleep(3*time.Second + rand.N(time.Second)) |
| + routes, err := reg.List() |
| + if err != nil { |
| + // The registry is a local file this process already holds open; |
| + // a failing List is a broken box, not a passing blip, so it is |
| + // worth a line even at this cadence (the same call is logged per |
| + // tick by statusLoop). |
| + log.Printf("agent: config generation poll: list routes: %v", err) |
| + continue |
| + } |
| + changed, readErrs := genPollPass(ctx, routes, store, cache) |
| + if len(readErrs) > 0 { |
| + // ONE line per pass, never one per app: a bucket having a bad |
| + // minute would otherwise flood the journal at 20 lines a minute |
| + // per app. The count carries the scale, the first error carries |
| + // the cause; they are the same cause in practice. |
| + log.Printf("agent: config generation poll: %d apps unreadable: %v", len(readErrs), readErrs[0]) |
| + } |
| + if !changed { |
| + continue |
| + } |
| + if err := appconfig.SyncExec(ctx, reg, store, priv, dd, bounce(svc)); err != nil { |
| + log.Printf("agent: config generation sync: %v", err) |
| } |
| } |
| } |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| new file mode 100644 |
| index 000000000..d76f88694 |
| --- /dev/null |
| +++ b/internal/host/agent_test.go |
| @@ -0,0 +1,257 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// genKit is the shared setup for the generation-poll tests: a directory |
| +// store standing in for the deployment bucket, and a context. No registry |
| +// — genPollPass takes routes, not a *registry.Registry, precisely so these |
| +// tests need no bolt database (the loop does its own reg.List()). |
| +func genKit(t *testing.T) (context.Context, release.Store) { |
| + t.Helper() |
| + return context.Background(), release.NewDirStore(t.TempDir()) |
| +} |
| + |
| +// execRoute is the socket-exec instance shape appconfig.ExecConfigApps |
| +// accepts (execSyncable): a non-platform, ready instance with a socket |
| +// upstream. Field-for-field the fixture shape syncexec_test.go uses, so a |
| +// change to the predicates fails these tests for the same reason it fails |
| +// appconfig's. |
| +func execRoute(host, acct, app string) registry.Route { |
| + return registry.Route{ |
| + Host: host, Kind: "instance", App: app, Account: acct, |
| + Status: "ready", Socket: "/run/carlos/" + host + ".sock", |
| + } |
| +} |
| + |
| +// genEtag reads the app's generation etag straight from the store, so a |
| +// test can assert the cache holds what the bucket actually reports rather |
| +// than a value the test made up. |
| +func genEtag(t *testing.T, ctx context.Context, s release.Store, acct, app string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(ctx, appconfig.GenKey(acct, app)) |
| + if err != nil { |
| + t.Fatalf("read generation %s/%s: %v", acct, app, err) |
| + } |
| + return etag |
| +} |
| + |
| +func touch(t *testing.T, ctx context.Context, s release.Store, acct, app string) { |
| + t.Helper() |
| + if err := appconfig.TouchGeneration(ctx, s, acct, app); err != nil { |
| + t.Fatalf("TouchGeneration %s/%s: %v", acct, app, err) |
| + } |
| +} |
| + |
| +// failingKeyStore fails Get for exactly one key — a transient bucket blip |
| +// against a single app's generation object while every other key in the |
| +// same pass resolves normally (the shape an S3 503 against one object |
| +// takes). Same wrapper appconfig's syncexec_test.go uses, for the same |
| +// reason. |
| +type failingKeyStore struct { |
| + release.Store |
| + failKey string |
| + err error |
| +} |
| + |
| +func (f *failingKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == f.failKey { |
| + return nil, "", f.err |
| + } |
| + return f.Store.Get(ctx, key) |
| +} |
| + |
| +func TestGenPollPassFirstSightIsSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "eleven") |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if changed { |
| + t.Fatal("first sight of an app must not trigger a sync (boot would re-bounce the world)") |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + want := genEtag(t, ctx, s, "bab", "eleven") |
| + if got := cache[[2]string{"bab", "eleven"}]; got != want { |
| + t.Fatalf("cache etag = %q, want the store's %q", got, want) |
| + } |
| +} |
| + |
| +func TestGenPollPassUnchangedIsSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "eleven") |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + |
| + genPollPass(ctx, routes, s, cache) |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if changed { |
| + t.Fatal("an unmoved generation etag must not trigger a sync") |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| +} |
| + |
| +func TestGenPollPassTouchedGenerationTriggersSync(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "eleven") |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + genPollPass(ctx, routes, s, cache) |
| + |
| + touch(t, ctx, s, "bab", "eleven") |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if !changed { |
| + t.Fatal("a moved generation etag must trigger a sync") |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + want := genEtag(t, ctx, s, "bab", "eleven") |
| + if got := cache[[2]string{"bab", "eleven"}]; got != want { |
| + t.Fatalf("cache etag = %q, want the new %q (a stale cache would re-sync forever)", got, want) |
| + } |
| + if changed, _ := genPollPass(ctx, routes, s, cache); changed { |
| + t.Fatal("the pass after a change must be silent again") |
| + } |
| +} |
| + |
| +func TestGenPollPassAbsentGenerationStaysSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + // No touch at all: an app last written by a pre-generation CLI. Legal |
| + // forever — the hourly configSyncLoop is its floor. |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + |
| + for i := range 2 { |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if changed { |
| + t.Fatalf("pass %d: an app with no generation object must never trigger a sync", i) |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("pass %d: absent is not a read error: %v", i, errs) |
| + } |
| + } |
| + if got := cache[[2]string{"bab", "eleven"}]; got != genAbsent { |
| + t.Fatalf("cache = %q, want the known-absent sentinel %q", got, genAbsent) |
| + } |
| +} |
| + |
| +func TestGenPollPassAbsentToPresentTriggersSync(t *testing.T) { |
| + ctx, s := genKit(t) |
| + routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")} |
| + cache := map[[2]string]string{} |
| + genPollPass(ctx, routes, s, cache) // records known-absent |
| + |
| + touch(t, ctx, s, "bab", "eleven") // the app's first ever config write |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if !changed { |
| + t.Fatal("absent -> present is the first write to an old app: it must sync") |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| +} |
| + |
| +func TestGenPollPassOneChangedAppSyncsOnce(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "eleven") |
| + touch(t, ctx, s, "bab", "titogo") |
| + routes := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "titogo"), |
| + } |
| + cache := map[[2]string]string{} |
| + genPollPass(ctx, routes, s, cache) |
| + |
| + touch(t, ctx, s, "bab", "titogo") |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if !changed { |
| + t.Fatal("one moved app in the set must report the pass as changed") |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + // One flag for the whole pass: the caller runs ONE SyncExec, which |
| + // resolves every app itself. The unchanged app's cache entry must be |
| + // carried forward untouched so it doesn't re-fire next tick. |
| + if got, want := cache[[2]string{"bab", "eleven"}], genEtag(t, ctx, s, "bab", "eleven"); got != want { |
| + t.Fatalf("unchanged app's cache = %q, want %q", got, want) |
| + } |
| + if changed, _ := genPollPass(ctx, routes, s, cache); changed { |
| + t.Fatal("the next pass must be silent for both apps") |
| + } |
| +} |
| + |
| +func TestGenPollPassDropsVanishedApps(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touch(t, ctx, s, "bab", "eleven") |
| + touch(t, ctx, s, "bab", "titogo") |
| + both := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "titogo"), |
| + } |
| + one := both[:1] |
| + cache := map[[2]string]string{} |
| + genPollPass(ctx, both, s, cache) |
| + |
| + genPollPass(ctx, one, s, cache) // titogo's route is gone |
| + if _, ok := cache[[2]string{"bab", "titogo"}]; ok { |
| + t.Fatal("a route that left the registry must leave no cache entry behind") |
| + } |
| + // The proof that dropping matters: while the app was unrouted its |
| + // config moved. Re-adding the route must record silently (first sight |
| + // again), not fire a sync off a remembered etag — a route being |
| + // re-added already gets fresh config from the spawn path. |
| + touch(t, ctx, s, "bab", "titogo") |
| + changed, errs := genPollPass(ctx, both, s, cache) |
| + if changed { |
| + t.Fatal("a re-added app is a first sight, not a change") |
| + } |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| +} |
| + |
| +func TestGenPollPassReadErrorLeavesCacheAndKeepsGoing(t *testing.T) { |
| + ctx, dir := genKit(t) |
| + touch(t, ctx, dir, "bab", "eleven") |
| + touch(t, ctx, dir, "bab", "titogo") |
| + routes := []registry.Route{ |
| + execRoute("a.example.com", "bab", "eleven"), |
| + execRoute("b.example.com", "bab", "titogo"), |
| + } |
| + cache := map[[2]string]string{} |
| + genPollPass(ctx, routes, dir, cache) |
| + elevenEtag := cache[[2]string{"bab", "eleven"}] |
| + |
| + // eleven's generation object goes unreadable; titogo's moves. |
| + boom := errors.New("bucket said no") |
| + s := &failingKeyStore{Store: dir, failKey: appconfig.GenKey("bab", "eleven"), err: boom} |
| + touch(t, ctx, dir, "bab", "titogo") |
| + |
| + changed, errs := genPollPass(ctx, routes, s, cache) |
| + if !changed { |
| + t.Fatal("one app failing must not stop the others being evaluated") |
| + } |
| + if len(errs) != 1 { |
| + t.Fatalf("read errors = %v, want exactly one (aggregated into one log line per pass)", errs) |
| + } |
| + if !errors.Is(errs[0], boom) { |
| + t.Fatalf("read error = %v, want it to wrap %v", errs[0], boom) |
| + } |
| + if got := cache[[2]string{"bab", "eleven"}]; got != elevenEtag { |
| + t.Fatalf("unreadable app's cache = %q, want it untouched at %q (a cleared entry would re-fire on recovery)", got, elevenEtag) |
| + } |
| +} |