agent: the activator's start sequence is a named, tested function, so a claim box never backfills and a legacy box backfills between recovery and the pass's publication (task 6 fix round 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+122
−38
internal/host/agent.go+26 −36internal/host/instancekeys.go+2 −2internal/host/instancekeys_test.go+51 −0internal/host/layoutleg.go+43 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 3f6626576..9ab848b86 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -462,42 +462,32 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // channel that never answers logs and moves on, and the box is no |
| // worse off than before this existed (a leftover unit that keeps |
| // running is exactly what an agent restart left before this pass). |
| - if claims.Writes.Enabled { |
| - svc.Start() |
| - } else { |
| - // |
| - // Then layout recovery (design §8.2, "Recovery is the journal"): |
| - // every journal a previous agent left is completed, and the host |
| - // converged to the fleet's intent, before Start can wake anything. |
| - // The pass is published only after that, so the serial leg never |
| - // runs beside recovery. |
| - startActivator(context.Background(), |
| - func(ctx context.Context) error { |
| - sw, err := eb.Control.Sweep(ctx) |
| - if err != nil { |
| - log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err) |
| - } else if len(sw.Units) > 0 { |
| - log.Printf("agent: swept %d instance unit(s) left from a previous agent: %v", len(sw.Units), sw.Units) |
| - } |
| - return err |
| - }, |
| - func(ctx context.Context, swept bool) { |
| - pass := newLayoutPass(reg, svc, dd, eb.NestedOK, swept) |
| - intent := startupRecoveryIntent(ctx, dd, openDeployStore, metricsBoxLabel(os.Getenv, readMachineID())) |
| - recoverLayoutAtStart(ctx, pass, intent) |
| - // L21: every instance already on this box gets its key |
| - // after layout recovery has settled every journal and |
| - // before the layout pass is published or the activator |
| - // wakes anything, so nothing moves a file under it. |
| - if routes, err := reg.List(); err != nil { |
| - log.Printf("agent: instance keys: list routes: %v (each host's next start mints its key)", err) |
| - } else { |
| - backfillInstanceKeys(routes, eb) |
| - } |
| - armedLayoutPass.Store(pass) |
| - }, |
| - svc.Start) |
| - } |
| + // |
| + // Then, on a legacy box, layout recovery and the instance-key |
| + // backfill, before Start can wake anything; on a claim box, Start |
| + // alone. The order and the branch are activatorStart's. |
| + activatorStart{ |
| + Claims: claims.Writes.Enabled, |
| + Sweep: func(ctx context.Context) error { |
| + sw, err := eb.Control.Sweep(ctx) |
| + if err != nil { |
| + log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err) |
| + } else if len(sw.Units) > 0 { |
| + log.Printf("agent: swept %d instance unit(s) left from a previous agent: %v", len(sw.Units), sw.Units) |
| + } |
| + return err |
| + }, |
| + Recover: func(ctx context.Context, swept bool) *layout.Pass { |
| + pass := newLayoutPass(reg, svc, dd, eb.NestedOK, swept) |
| + intent := startupRecoveryIntent(ctx, dd, openDeployStore, metricsBoxLabel(os.Getenv, readMachineID())) |
| + recoverLayoutAtStart(ctx, pass, intent) |
| + return pass |
| + }, |
| + Routes: reg.List, |
| + Keys: eb, |
| + Publish: armedLayoutPass.Store, |
| + Start: svc.Start, |
| + }.Run(context.Background()) |
| act = svc |
| log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle) |
diff --git a/internal/host/instancekeys.go b/internal/host/instancekeys.go| index 44fb2311b..e68775afa 100644 |
| --- a/internal/host/instancekeys.go |
| +++ b/internal/host/instancekeys.go |
| @@ -60,7 +60,7 @@ func backfillInstanceKeys(routes []registry.Route, b instanceKeyBackfiller) inst |
| got, err := b.BackfillInstanceKey(rt) |
| if err != nil { |
| st.Failed++ |
| - log.Printf("agent: instance key for %s: %v (its next start mints it)", rt.Host, err) |
| + log.Printf("agent: instance key for %s: %v (its next start mints it, or refuses for the same reason)", rt.Host, err) |
| continue |
| } |
| switch got { |
| @@ -78,7 +78,7 @@ func backfillInstanceKeys(routes []registry.Route, b instanceKeyBackfiller) inst |
| st.Skipped++ |
| } |
| } |
| - log.Printf("agent: instance keys: %d minted, %d already present, %d set by app configuration, %d never started here, %d failed, %d rows not instances", |
| + log.Printf("agent: instance keys: %d minted, %d already present, %d set by app configuration, %d never started here, %d failed, %d rows skipped", |
| st.Minted, st.Present, st.FromConfig, st.NoEnvFile, st.Failed, st.Skipped) |
| return st |
| } |
diff --git a/internal/host/instancekeys_test.go b/internal/host/instancekeys_test.go| index e06dfafea..950dd7ccc 100644 |
| --- a/internal/host/instancekeys_test.go |
| +++ b/internal/host/instancekeys_test.go |
| @@ -1,10 +1,13 @@ |
| package host |
| import ( |
| + "context" |
| "errors" |
| + "reflect" |
| "testing" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -78,3 +81,51 @@ func TestBackfillInstanceKeysCountsAHostNeverStartedHere(t *testing.T) { |
| t.Fatalf("stats = %+v", st) |
| } |
| } |
| + |
| +// keyBackfillFunc is a backfiller made of one function. |
| +type keyBackfillFunc func(registry.Route) (activator.InstanceKeyOutcome, error) |
| + |
| +func (f keyBackfillFunc) BackfillInstanceKey(rt registry.Route) (activator.InstanceKeyOutcome, error) { |
| + return f(rt) |
| +} |
| + |
| +// Task 6 ruling (K1): the start sequence is activatorStart's, so the two |
| +// guards runAgent cannot be tested for are pinned here. A claim box starts |
| +// and nothing else: no sweep, no recovery, no backfill (ruling 7). A legacy |
| +// box sweeps, recovers, backfills, publishes the pass and then starts, in |
| +// that order: the backfill after every journal is settled and before the |
| +// pass is published or anything wakes. A route list that fails skips the |
| +// backfill only; the pass is still published and the service still starts. |
| +func TestActivatorStartBackfillsOnlyALegacyBoxBetweenRecoveryAndPublish(t *testing.T) { |
| + ready := registry.Route{Host: "shop.example.com", Kind: "instance", Status: "ready", Socket: "/run/carlos/shop.example.com.sock"} |
| + for _, tc := range []struct { |
| + name string |
| + claims bool |
| + listErr error |
| + want []string |
| + }{ |
| + {"legacy box", false, nil, []string{"sweep", "recover", "backfill", "publish", "start"}}, |
| + {"legacy box, routes unreadable", false, errors.New("db locked"), []string{"sweep", "recover", "publish", "start"}}, |
| + {"claim box", true, nil, []string{"start"}}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + var events []string |
| + note := func(e string) { events = append(events, e) } |
| + activatorStart{ |
| + Claims: tc.claims, |
| + Sweep: func(context.Context) error { note("sweep"); return nil }, |
| + Recover: func(context.Context, bool) *layout.Pass { note("recover"); return nil }, |
| + Routes: func() ([]registry.Route, error) { return []registry.Route{ready}, tc.listErr }, |
| + Keys: keyBackfillFunc(func(registry.Route) (activator.InstanceKeyOutcome, error) { |
| + note("backfill") |
| + return activator.InstanceKeyMinted, nil |
| + }), |
| + Publish: func(*layout.Pass) { note("publish") }, |
| + Start: func() { note("start") }, |
| + }.Run(context.Background()) |
| + if !reflect.DeepEqual(events, tc.want) { |
| + t.Fatalf("events = %v, want %v", events, tc.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go| index a035f412b..c3b7821aa 100644 |
| --- a/internal/host/layoutleg.go |
| +++ b/internal/host/layoutleg.go |
| @@ -343,6 +343,49 @@ func startActivator(ctx context.Context, sweep func(context.Context) error, reco |
| start() |
| } |
| +// activatorStart is the activator's start sequence, assembled in runAgent |
| +// and run once by Run. |
| +// |
| +// On a claim box it is Start alone: every write to a host's installation |
| +// runs under the claim's guard, and the claim's launch source mints the |
| +// instance key there before every cold admission (claim_snapshot.go |
| +// EnsureClaimAdminToken), so a claim box never backfills (instance-key plan |
| +// ruling 7). |
| +// |
| +// On a legacy box it is the sweep, then layout recovery (design §8.2, |
| +// "Recovery is the journal": every journal a previous agent left is |
| +// completed and the host converged to the fleet's intent), then the |
| +// instance-key backfill (L21), then the pass's publication, then Start. The |
| +// backfill runs after recovery has settled every journal and before the |
| +// pass is published or the activator wakes anything, so nothing moves a |
| +// file under it; the pass is published only after recovery, so the serial |
| +// leg never runs beside it. |
| +type activatorStart struct { |
| + Claims bool // claims.Writes.Enabled: a claim box |
| + Sweep func(context.Context) error // the instance-unit sweep |
| + Recover func(context.Context, bool) *layout.Pass // builds the pass and recovers under it; the bool is whether the sweep succeeded |
| + Routes func() ([]registry.Route, error) // the registry's List |
| + Keys instanceKeyBackfiller // the exec backend |
| + Publish func(*layout.Pass) // armedLayoutPass.Store |
| + Start func() // the service's Start |
| +} |
| + |
| +func (s activatorStart) Run(ctx context.Context) { |
| + if s.Claims { |
| + s.Start() |
| + return |
| + } |
| + startActivator(ctx, s.Sweep, func(ctx context.Context, swept bool) { |
| + pass := s.Recover(ctx, swept) |
| + if routes, err := s.Routes(); err != nil { |
| + log.Printf("agent: instance keys: list routes: %v (each host's next start mints its key)", err) |
| + } else { |
| + backfillInstanceKeys(routes, s.Keys) |
| + } |
| + s.Publish(pass) |
| + }, s.Start) |
| +} |
| + |
| // layoutIntentTimeout bounds the one intent read at startup, which the |
| // activator's start waits on. |
| const layoutIntentTimeout = 30 * time.Second |