settings-4 task 7: a held instance's wake never runs a stale build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+1221
−31
internal/activator/activator.go+59 −1internal/activator/beforeexec.go+31 −10internal/activator/claimwake.go+55 −5internal/activator/logsink.go+17 −0internal/activator/swap.go+5 −2internal/activator/wakecheck.go+132 −0internal/activator/wakecheck_claim_linux_test.go+141 −0internal/activator/wakecheck_test.go+404 −0internal/activator/wakeidentity_test.go+9 −2internal/host/agent.go+15 −0internal/host/buildstate.go+112 −2internal/host/buildstate_test.go+215 −1internal/release/adopt.go+3 −8internal/release/staged.go+23 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 6dc7b13b6..abd3a453f 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -1043,6 +1043,19 @@ type Service struct { |
| // replica bucket. Set before Start, like OnWake. |
| BeforeExec func(ctx context.Context, host, build string) error |
| + // HeldTarget, when set, is the agent's answer for a held instance's wake |
| + // (settings spec §4.3 rule 3): the build it must run, in root's rule |
| + // order, or Held false for an instance root follows the channel for. The |
| + // wake asks it before planning and before the lease (wakeCheck), never |
| + // for a hold swap's pinned wake or a moves probe. It runs off a.mu and |
| + // may read the bucket. nil checks nothing: today's wake. Set before |
| + // Start, like OnWake. |
| + HeldTarget func(host string) HeldWake |
| + // RequestStage, when set, asks root to install build for host (the |
| + // stage relay), for a held wake whose target is not installed. It must |
| + // not block: the wake waits for the install itself. Set before Start. |
| + RequestStage func(host, build string) |
| + |
| // OnWorkerServing, when set, hears a worker's captured serving identity |
| // each time a wake or a respawn installs a child (zero-downtime spec |
| // §7.9), an empty version included. It runs with no lock held, through |
| @@ -1761,6 +1774,41 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro |
| return w, fmt.Errorf("%s: refusing to wake: %w", host, errWakeUnproven) |
| } |
| + // 0.65. The held wake check (settings spec §4.3 rule 3): a held |
| + // instance never wakes on an installed build other than its target |
| + // (wakeCheck). Before planning and before the lease, so a refusal or a |
| + // wait for root holds nothing. Not for a hold swap's pinned wake: the |
| + // swap chose e.pin from root's install. A route whose backend can exec a |
| + // named file (identityFor) may run a retained copy of the target |
| + // directly; any other waits for root. After a wait the row is re-read |
| + // as step 0 reads it, so the planner sees the build root just installed. |
| + var ( |
| + held string |
| + heldID *release.StagedIdentity |
| + ) |
| + if e.pin == nil { |
| + var err error |
| + held, heldID, err = a.wakeCheck(ctx, host, identityFor(a.backend, rt) != nil) |
| + if err != nil { |
| + return w, fmt.Errorf("%s: %w", host, err) |
| + } |
| + if held != "" && heldID == nil && a.Routes != nil { |
| + fresh, err := a.Routes.Get(host) |
| + if err != nil { |
| + return w, fmt.Errorf("%s: route lookup: %w", host, err) |
| + } |
| + if fresh == nil { |
| + return w, fmt.Errorf("%s: no route", host) |
| + } |
| + rt = *fresh |
| + a.mu.Lock() |
| + if e := a.entries[rt.Host]; e != nil { |
| + e.route = rt |
| + } |
| + a.mu.Unlock() |
| + } |
| + } |
| + |
| // 0.7. Which build this wake boots (zero-downtime spec §8.2). A hold |
| // swap's pinned wake (e.pin) already knows; everything else asks the |
| // planner, off a.mu, before the lease: a refused plan takes no lease. |
| @@ -1858,9 +1906,19 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro |
| } |
| ib := identityFor(a.backend, rt) |
| var id release.StagedIdentity |
| + if ib == nil && held != "" && !plan.planned() { |
| + // A held route whose backend cannot pin an executable (a unit row: |
| + // systemd execs the live link itself): nothing here can capture the |
| + // file, so the link is read once more just before the start. It |
| + // narrows a repoint after the wait; it cannot close it. |
| + if v := a.liveVersion(host); v != held { |
| + w.stopOutcome.leaseReleased = releaseLease(ctx, lease) == nil |
| + return w, fmt.Errorf("start %s: %w: %s is on %q, wants %s", host, errStaleLink, host, v, held) |
| + } |
| + } |
| if ib != nil { |
| var idErr error |
| - id, w.pend, idErr = a.settleWakeIdentity(ctx, ib, e, rt) |
| + id, w.pend, idErr = a.settleWakeIdentity(ctx, ib, e, rt, held, heldID) |
| if idErr != nil { |
| w.stopOutcome.leaseReleased = releaseLease(ctx, lease) == nil |
| return w, fmt.Errorf("start %s: %w", host, idErr) |
diff --git a/internal/activator/beforeexec.go b/internal/activator/beforeexec.go| index 15879fdb3..1777dd292 100644 |
| --- a/internal/activator/beforeexec.go |
| +++ b/internal/activator/beforeexec.go |
| @@ -80,15 +80,26 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error { |
| // |
| // - A planned wake runs its plan's boot build, and a pinned wake (a hold |
| // swap's) its pin. Step 0.8 published both before the lease. |
| +// - A held wake with a retained, verified copy of its target (heldID; |
| +// the held wake check, settings spec §4.3 rule 3, with no planner) runs |
| +// that copy. |
| // - Any other wake (flag off; flag on with no plan; a worker) captures the |
| // live link once (LiveIdentity, which materializes a missing link |
| -// first). That capture is published as a pending generation before it |
| -// is recorded (ruling R-COLDPUBLISH): the generations file names a build |
| -// before its build state does, and root's sweep keeps the file until the |
| -// process that runs it is gone. A write that fails refuses the wake as |
| -// a worker spawn's refuses its exec. The pending record is returned so |
| -// the wake drops it once the entry names the build itself (wake), or the |
| -// wake failed. |
| +// first). That capture, and a retained copy, is published as a pending |
| +// generation before it is recorded (ruling R-COLDPUBLISH; anchors D18: |
| +// no plan or pin published a retained copy): the generations file names |
| +// a build before its build state does, and root's sweep keeps the file |
| +// until the process that runs it is gone. A write that fails refuses the |
| +// wake as a worker spawn's refuses its exec. The pending record is |
| +// returned so the wake drops it once the entry names the build itself |
| +// (wake), or the wake failed. |
| +// - A held wake (held, its target) refuses an identity that is neither |
| +// its target nor the planner's recovery boot (heldIdentityOK; Astra I2 |
| +// #4): root repointed between the wait and the capture. That refusal |
| +// comes before the publication and the record, so nothing names it. An |
| +// identity with no version is never the target, so a held wake never |
| +// takes the unversioned live-link fallback below (ruling |
| +// R-EXEC-NOVERSION-2). |
| // - A live link outside the version store (Version "") names no build a |
| // build state could hold, and root refuses a staged executable with no |
| // version (instancectl.validateStagedExe). With BeforeExec set it is |
| @@ -99,24 +110,34 @@ func (a *Service) claimedBuild(host string, s ClaimStart) error { |
| // |
| // Then the record (recordBuild). Any error means nothing was seeded or |
| // spawned; the caller releases the lease. |
| -func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route) (release.StagedIdentity, pendingGeneration, error) { |
| +func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e *actEntry, rt registry.Route, held string, heldID *release.StagedIdentity) (release.StagedIdentity, pendingGeneration, error) { |
| a.mu.Lock() |
| pin, plan := e.pin, e.plan |
| a.mu.Unlock() |
| var ( |
| - id release.StagedIdentity |
| - pend pendingGeneration |
| + id release.StagedIdentity |
| + pend pendingGeneration |
| + captured bool // neither planned nor pinned: published here, below |
| ) |
| switch { |
| case plan.planned(): |
| id = plan.Boot |
| case pin != nil: |
| id = release.StagedIdentity{Version: pin.Build, StagedPath: pin.StagedPath, Digest: pin.Digest, Demand: pin.Demand} |
| + case heldID != nil: |
| + id, captured = *heldID, true |
| default: |
| var err error |
| if id, err = ib.LiveIdentity(ctx, rt); err != nil { |
| return release.StagedIdentity{}, 0, err |
| } |
| + captured = true |
| + } |
| + if !heldIdentityOK(held, id, plan, rt) { |
| + log.Printf("activator: %s not woken: it is held on %s, and this wake would run %q (the live link moved after the check); the next wake checks again", rt.Host, held, id.Version) |
| + return release.StagedIdentity{}, 0, fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, rt.Host, id.Version, held) |
| + } |
| + if captured { |
| if id.Version == "" { |
| return release.StagedIdentity{}, 0, a.recordBuild(ctx, rt.Host, "") |
| } |
diff --git a/internal/activator/claimwake.go b/internal/activator/claimwake.go| index 7a30b7b7e..107ae829e 100644 |
| --- a/internal/activator/claimwake.go |
| +++ b/internal/activator/claimwake.go |
| @@ -62,6 +62,12 @@ type StartPolicy struct { |
| // WakePlan.Check) for the pin about to exec. startAdmitted runs it |
| // beside Gate, at every exec. |
| PinCheck func(pin Gen) error |
| + // Held is a held instance's target (settings spec §4.3 rule 3), "" when |
| + // the wake is not held. startAdmitted's before hook refuses a start |
| + // whose hashed build is neither the target nor a planned wake's |
| + // recovery boot (heldIdentityOK), before the record and the exec. The |
| + // activator sets it; providers ignore it. |
| + Held string |
| } |
| type startPolicy = StartPolicy |
| @@ -211,6 +217,33 @@ func (a *Service) doClaimWake(ctx context.Context, e *actEntry) (*wokenInstance, |
| } |
| rt = *fresh |
| } |
| + // The held wake check (settings spec §4.3 rule 3), as doWake's step |
| + // 0.65, before planning and before the admission. Never for a hold's |
| + // pinned wake (e.pinCheck: the swap chose its build; planned claimed |
| + // wakes set e.pin too, below, so e.pin alone does not say "hold"), and |
| + // never for a moves probe, which runs the landing's prepared build under |
| + // its own policy. The launcher execs the claim inputs' file, so a |
| + // retained copy cannot be injected here (direct false): a target not |
| + // installed is asked of root and waited for, and the row re-read. The |
| + // launch's before hook refuses a hashed build that is not the target |
| + // (StartPolicy.Held). |
| + var held string |
| + if e.pinCheck == nil && probe == nil && e.recoveryPolicy == nil { |
| + held, _, err = a.wakeCheck(ctx, rt.Host, false) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: %w", rt.Host, err) |
| + } |
| + if held != "" && a.Routes != nil { |
| + fresh, err := a.Routes.Get(e.host) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if fresh == nil { |
| + return nil, hostbox.ErrChanged |
| + } |
| + rt = *fresh |
| + } |
| + } |
| // Claimed wakes run plan 3's pinned start (StartPolicy.Pin) and get the |
| // same first-wake budget and fallback as an unclaimed wake (zero-downtime |
| // spec §8.2): firstWake extends the waiters' deadline in begin, and the |
| @@ -250,7 +283,7 @@ func (a *Service) doClaimWake(ctx context.Context, e *actEntry) (*wokenInstance, |
| return nil, fmt.Errorf("%s: refusing to wake: %w", rt.Host, err) |
| } |
| } |
| - policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin} |
| + policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin, Held: held} |
| if e.recoveryPolicy != nil { |
| policy = *e.recoveryPolicy |
| } else if probe != nil { |
| @@ -471,6 +504,23 @@ func (a *Service) startAdmitted(ctx context.Context, e *actEntry, admitted hostc |
| // (RoleLaunch.Start). Every exec records, a retry's included; |
| // the same build makes the write idempotent. Moves probes come |
| // through here too (ruling R-EXEC-PROBES). |
| + // |
| + // A held wake first checks the build the snapshot hashed |
| + // (s.Staged, the version of the bytes opened; Task 5d) against |
| + // its target (settings spec §4.3 rule 3, Astra I2 #4): root may |
| + // have repointed between the wait and the snapshot. A refusal |
| + // records nothing, and like a refused record it is terminal for |
| + // this loop: no failed attempt, no retry (unrecorded). |
| + if policy.Held != "" { |
| + a.mu.Lock() |
| + plan, route := e.plan, e.route |
| + a.mu.Unlock() |
| + if !heldIdentityOK(policy.Held, s.Staged, plan, route) { |
| + unrecorded = true |
| + log.Printf("activator: %s not started: it is held on %s, and the launch hashed %q (the live link moved after the check); the next wake checks again", e.host, policy.Held, s.Staged.Version) |
| + return fmt.Errorf("%w: %s would run %q, wants %s", errStaleLink, e.host, s.Staged.Version, policy.Held) |
| + } |
| + } |
| at := WithBuildStateLocation(ctx, BuildStateLocation{Bucket: r.Bucket, Prefix: admitted.Claim.Holder.Location}) |
| if err := a.recordBuild(at, e.host, s.Staged.Version); err != nil { |
| unrecorded = true |
| @@ -496,10 +546,10 @@ func (a *Service) startAdmitted(ctx context.Context, e *actEntry, admitted hostc |
| p.settle = settled |
| p.bindProducer(producer) |
| if unrecorded { |
| - // A refused record ran nothing of the build (ruling |
| - // R-EXEC-CLAIMRETRY): no failed attempt is written (s.Failed), |
| - // and the loop does not retry it. The wake fails as fenced |
| - // (memberSafeWakeReason). |
| + // A refused record, or a held wake's refused identity, ran |
| + // nothing of the build (ruling R-EXEC-CLAIMRETRY): no failed |
| + // attempt is written (s.Failed), and the loop does not retry it. |
| + // The wake fails as fenced (memberSafeWakeReason). |
| cleanup, cancelCleanup := context.WithTimeout(context.Background(), 10*time.Second) |
| cleanErr := p.Stop(cleanup) |
| cancelCleanup() |
diff --git a/internal/activator/logsink.go b/internal/activator/logsink.go| index 77b21ea8c..fbf551a99 100644 |
| --- a/internal/activator/logsink.go |
| +++ b/internal/activator/logsink.go |
| @@ -8,11 +8,13 @@ package activator |
| import ( |
| "errors" |
| + "fmt" |
| "regexp" |
| "strings" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| ) |
| // EventSink is where the activator publishes member-visible log lines. |
| @@ -48,6 +50,21 @@ var absPathToken = regexp.MustCompile(`(^|[\s"(=])(/[\w@.\-/]+)`) |
| // text itself originates in our own code (backends, materialize, lease) — |
| // this is a rendering pass, not a secrecy boundary. |
| func memberSafeWakeReason(err error) string { |
| + // The held wake check's refusals (settings spec §4.3 rule 3; ruling |
| + // R-WAKEWORDS). Unknown and failed need the member's action, so they get |
| + // the settings words (drafts, TODO-COPY until approved; R-COPY). A wait |
| + // for root that ran out, or a repoint after it, is the platform's own |
| + // work and reopens by itself: the fence's existing wording. |
| + if errors.Is(err, errBuildUnknown) { |
| + return updatewords.Text("settings.box.reason.unknown") |
| + } |
| + var failed *targetFailedError |
| + if errors.As(err, &failed) { |
| + return fmt.Sprintf(updatewords.Text("settings.box.reason.failed"), failed.build) |
| + } |
| + if errors.Is(err, errStaleLink) { |
| + err = ErrFenced |
| + } |
| if errors.Is(err, errStagedBuildNotPublished) { |
| // A staged instance wake refused because its build could not be |
| // published first (final review F9, flag on only). The platform |
diff --git a/internal/activator/swap.go b/internal/activator/swap.go| index 1756114f6..389133f9d 100644 |
| --- a/internal/activator/swap.go |
| +++ b/internal/activator/swap.go |
| @@ -136,7 +136,7 @@ func classifyStep(lifecycle context.Context, err error) stepOutcome { |
| // the build ran because of it (spec §7.4). |
| return stepStale |
| case errors.Is(err, ErrBuildNotPublished), errors.Is(err, errStagedBuildNotPublished), |
| - errors.Is(err, errBuildStateUnwritten): |
| + errors.Is(err, errBuildStateUnwritten), errors.Is(err, errStaleLink): |
| // A spawn refused because its build could not be published before |
| // the exec (ruling R19 item 3; the worker and the staged instance |
| // sentinels, final review F9): nothing of the build ran, so it is |
| @@ -145,7 +145,10 @@ func classifyStep(lifecycle context.Context, err error) stepOutcome { |
| // classifyStep is flag-on (swaps, holds, planned wakes). |
| // errBuildStateUnwritten (settings spec §4.2) is the same kind of |
| // refusal; verdictOf also names it, so it is stale through either |
| - // arm, and named here beside its kin. |
| + // arm, and named here beside its kin. errStaleLink (settings spec |
| + // §4.3 rule 3) is a held wake refusing, before its record and its |
| + // exec, an identity that is not its target: a planned wake's boot |
| + // never ran either. |
| return stepStale |
| } |
| return stepFailed |
diff --git a/internal/activator/wakecheck.go b/internal/activator/wakecheck.go| new file mode 100644 |
| index 000000000..dedc674e6 |
| --- /dev/null |
| +++ b/internal/activator/wakecheck.go |
| @@ -0,0 +1,132 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The held wake check's refusals (settings spec §4.3 rule 3). None of them |
| +// ran anything of a build, so none is a failure of one. Members read them |
| +// through memberSafeWakeReason (ruling R-WAKEWORDS), never as this text. |
| +var ( |
| + errBuildUnknown = errors.New("build unknown: use Update now or set a pin") |
| + errStaleLink = errors.New("the installed build is not this instance's build, and root has not installed it yet") |
| + errTargetFailed = errors.New("this instance's build failed on this box, so it is not started again") |
| +) |
| + |
| +// targetFailedError is errTargetFailed naming the build, so the member's |
| +// sentence can name it too. |
| +type targetFailedError struct{ host, build string } |
| + |
| +func (e *targetFailedError) Error() string { |
| + return fmt.Sprintf("%v: %s on %s", errTargetFailed, e.build, e.host) |
| +} |
| + |
| +func (e *targetFailedError) Unwrap() error { return errTargetFailed } |
| + |
| +// HeldWake is the agent's answer for a held instance's wake (settings spec |
| +// §4.3 rule 3). |
| +type HeldWake struct { |
| + Target string // the build the instance must run |
| + Held bool // false: root follows the channel for it; today's wake |
| + Unknown bool // rule 3's "build unknown" |
| + Failed bool // root reports Target failed on this box (its decision, or a refusal record) |
| + // Staged is a retained copy of Target on this box whose bytes match its |
| + // staged manifest; nil when there is none. |
| + Staged *release.StagedIdentity |
| +} |
| + |
| +// heldPoll is how often a held wake re-reads the live link while it waits |
| +// for root to install its target. |
| +const heldPoll = 250 * time.Millisecond |
| + |
| +// wakeCheck is settings spec §4.3 rule 3's wake half, with its own admission |
| +// rule so it holds with CARLOS_SWAP off and no planner (Astra I3 #2, #5): |
| +// |
| +// - not held: "" and nothing to do, today's wake; |
| +// - unknown: errBuildUnknown, and root is not asked (it installs nothing); |
| +// - a target root reports failed: refused (errTargetFailed) with no |
| +// planner, even while it is still the live link; with a planner the |
| +// wake is not held, and zdd-5's failure-aware plan chooses (recovery |
| +// onto the good build, or a refusal); |
| +// - the target installed: the target, and the caller checks the identity |
| +// it captures against it (heldIdentityOK); |
| +// - with no planner and direct set (the caller can exec a named file), a |
| +// retained verified copy of the target, which the caller runs as its |
| +// identity, recorded first; |
| +// - otherwise root is asked to install the target (RequestStage, the |
| +// stage relay) and the wake waits for the live link to name it, within |
| +// its own deadline and never past wakeWaitBound, the bound its waiters |
| +// have. A wait that ends first is errStaleLink. |
| +// |
| +// It never sets e.pin (Astra I2 #4): with a planner, what boots after root's |
| +// install is the plan's. The string is the target ("" when the wake is not |
| +// held). |
| +func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (string, *release.StagedIdentity, error) { |
| + if a.HeldTarget == nil { |
| + return "", nil, nil |
| + } |
| + h := a.HeldTarget(host) |
| + if !h.Held { |
| + return "", nil, nil |
| + } |
| + if h.Unknown || h.Target == "" { |
| + return "", nil, errBuildUnknown |
| + } |
| + if h.Failed { |
| + if a.Planner != nil { |
| + return "", nil, nil // zdd-5's decideWake: recovery onto the good build, or a refusal |
| + } |
| + return "", nil, &targetFailedError{host: host, build: h.Target} |
| + } |
| + installed := a.liveVersion(host) |
| + if installed == h.Target { |
| + return h.Target, nil, nil |
| + } |
| + if direct && a.Planner == nil && h.Staged != nil && h.Staged.Version == h.Target { |
| + return h.Target, h.Staged, nil // run the retained copy; step 2b records it first |
| + } |
| + if a.RequestStage != nil { |
| + a.RequestStage(host, h.Target) |
| + } |
| + wait, cancel := context.WithTimeout(ctx, wakeWaitBound) |
| + defer cancel() |
| + tick := time.NewTicker(heldPoll) |
| + defer tick.Stop() |
| + for { |
| + if a.liveVersion(host) == h.Target { |
| + return h.Target, nil, nil |
| + } |
| + select { |
| + case <-wait.Done(): |
| + return "", nil, fmt.Errorf("%w: %s is on %q, wants %s", errStaleLink, host, installed, h.Target) |
| + case <-tick.C: |
| + } |
| + } |
| +} |
| + |
| +// liveVersion is what live/<host> names now, "" when it cannot be read. |
| +func (a *Service) liveVersion(host string) string { |
| + if a.liveVer == nil { |
| + return "" |
| + } |
| + v, _ := a.liveVer(host) |
| + return v |
| +} |
| + |
| +// heldIdentityOK reports whether a held wake may run id: its target, or the |
| +// good build the planner chose as recovery because the failure book blocks |
| +// the target (zero-downtime failure records outrank every rule, §4.3). An |
| +// identity with no version (a live link outside the version store) is never |
| +// the target (ruling R-EXEC-NOVERSION-2). |
| +func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt registry.Route) bool { |
| + if target == "" || id.Version == target { |
| + return true |
| + } |
| + return plan.planned() && !plan.FirstWake && id.Version != "" && id.Version == rt.GoodVersion |
| +} |
diff --git a/internal/activator/wakecheck_claim_linux_test.go b/internal/activator/wakecheck_claim_linux_test.go| new file mode 100644 |
| index 000000000..46340e9e9 |
| --- /dev/null |
| +++ b/internal/activator/wakecheck_claim_linux_test.go |
| @@ -0,0 +1,141 @@ |
| +//go:build linux |
| + |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path/filepath" |
| + "slices" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The held wake check on the claimed path (CARLOS_HOST_CLAIM; settings plan |
| +// 4 Task 7). Its launcher execs the claim inputs' file, so a retained copy |
| +// cannot be injected: the wake asks root, waits for the install, and the |
| +// launch's before hook refuses a hashed build that is not the target. |
| + |
| +type heldClaimed struct { |
| + a *Service |
| + b *planGenBackend |
| + f *rootLaunchFixture |
| + log *execLog |
| + rt registry.Route |
| + mu sync.Mutex |
| + reserved int |
| + failed int |
| +} |
| + |
| +func (c *heldClaimed) counts() (reserved, failed, launches int) { |
| + c.mu.Lock() |
| + reserved, failed = c.reserved, c.failed |
| + c.mu.Unlock() |
| + c.f.root.mu.Lock() |
| + defer c.f.root.mu.Unlock() |
| + return reserved, failed, len(c.f.root.launches) |
| +} |
| + |
| +// newHeldClaimed is a claims-on Service whose wakes go through doClaimWake |
| +// and launch through the root fixture. hashed is the build the launch |
| +// snapshot hashes and names; live is what live/<host> names at the start. |
| +func newHeldClaimed(t *testing.T, live, hashed string) *heldClaimed { |
| + t.Helper() |
| + f := newRootLaunchFixture(t) |
| + f.root.grant = launchedChild(t) |
| + host := f.launch.Target.Host |
| + obs := claimedObs(host) |
| + obs.Claim.Holder.WriterBox = "box" |
| + admitted := probeAdmitted(obs) |
| + work, _ := (hostwrite.Config{}).Select(t.Context(), host, "spawn") |
| + c := &heldClaimed{f: f, b: newPlanGenBackend()} |
| + c.b.fakeBackend.setLive(live) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var mu sync.Mutex |
| + reads := 0 |
| + c.a = New(Config{Idle: time.Hour, HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Bucket: "data", |
| + Read: func(context.Context, string) (hostclaim.Observed, error) { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if reads++; reads > 1 { |
| + return admitted, nil |
| + } |
| + return obs, nil |
| + }, |
| + StopForeign: func(context.Context, string) error { return nil }, |
| + Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error { return nil }, |
| + Admit: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) { |
| + return admitted, nil |
| + }, |
| + FailedAdmission: func(context.Context, hostclaim.Observed, error) error { return nil }, |
| + Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) { |
| + s := rootLaunched(t, f, claimedStartFor(admitted, work, "v1")) |
| + s.Staged, s.Version = claimedBuildOf(t, f, hashed), hashed |
| + s.Replicate = func(context.Context, string) (Rep, error) { return &fakeRep{}, nil } |
| + s.Reserve = func(context.Context, int) error { c.mu.Lock(); c.reserved++; c.mu.Unlock(); return nil } |
| + s.Failed = func(context.Context, int) error { c.mu.Lock(); c.failed++; c.mu.Unlock(); return nil } |
| + return s, nil |
| + }}}, c.b, reg) |
| + c.log = &execLog{spawns: func() int { f.root.mu.Lock(); defer f.root.mu.Unlock(); return len(f.root.launches) }} |
| + c.a.BeforeExec = c.log.hook |
| + c.rt = registry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: filepath.Join(f.runDir, "app.sock"), DBPath: filepath.Join(f.launchDir, "app.db")} |
| + return c |
| +} |
| + |
| +// A claimed held wake on a stale live link asks root for the target and |
| +// waits for the install; the launch then snapshots the target and runs it, |
| +// recorded first. |
| +func TestAClaimedHeldWakeWaitsForRootsInstall(t *testing.T) { |
| + c := newHeldClaimed(t, "v3", "v1") |
| + c.a.HeldTarget = heldOn("v1") |
| + r := &asks{then: func(string, string) { c.b.fakeBackend.setLive("v1") }} |
| + c.a.RequestStage = r.ask |
| + done, err := c.a.beginRequest(t.Context(), c.rt) |
| + if err != nil { |
| + t.Fatalf("the claimed held wake: %v", err) |
| + } |
| + done() |
| + if got := r.seen(); !slices.Equal(got, []string{c.rt.Host + "@v1"}) { |
| + t.Fatalf("asked root %v, want v1", got) |
| + } |
| + calls, at, _ := c.log.seen() |
| + if _, _, launches := c.counts(); !slices.Equal(calls, []string{c.rt.Host + "@v1"}) || at[0] != 0 || launches != 1 { |
| + t.Fatalf("records %v at exec counts %v, %d launches; want v1 recorded, then launched once", calls, at, launches) |
| + } |
| +} |
| + |
| +// The race half: root installed something other than the target between |
| +// the wait and the launch snapshot. The before hook refuses it before the |
| +// record: nothing records or execs, no failed attempt is written, and the |
| +// start loop does not retry it (ruling R-EXEC-CLAIMRETRY's shape). |
| +func TestAClaimedHeldWakeRefusesAnIdentityThatIsNotItsTarget(t *testing.T) { |
| + c := newHeldClaimed(t, "v1", "v2") |
| + c.a.HeldTarget = heldOn("v1") |
| + c.a.RequestStage = func(string, string) { t.Error("asked root, with the target already installed") } |
| + began := time.Now() |
| + _, err := c.a.beginRequest(t.Context(), c.rt) |
| + if !errors.Is(err, errStaleLink) { |
| + t.Fatalf("err = %v, want errStaleLink", err) |
| + } |
| + waitFor(t, "the claimed wake to end", func() bool { return c.a.entryGone(c.rt.Host) }) |
| + if calls, _, _ := c.log.seen(); len(calls) != 0 { |
| + t.Fatalf("records %v: a build that is not the target was recorded", calls) |
| + } |
| + if reserved, failed, launches := c.counts(); launches != 0 || failed != 0 || reserved != 1 { |
| + t.Fatalf("reserved=%d failed=%d launches=%d, want one attempt, nothing run, no failed attempt", reserved, failed, launches) |
| + } |
| + if took := time.Since(began); took >= 5*time.Second { |
| + t.Fatalf("took %v: the refusal waited out a retry backoff", took) |
| + } |
| + if got := memberSafeWakeReason(err); got != memberSafeWakeReason(ErrFenced) { |
| + t.Fatalf("member reads %q, want the fence's wording", got) |
| + } |
| +} |
diff --git a/internal/activator/wakecheck_test.go b/internal/activator/wakecheck_test.go| new file mode 100644 |
| index 000000000..28768495a |
| --- /dev/null |
| +++ b/internal/activator/wakecheck_test.go |
| @@ -0,0 +1,404 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "slices" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/updatewords" |
| +) |
| + |
| +// Settings spec §4.3 rule 3, "The wake checks too" (settings plan 4 Task 7): |
| +// before a held instance spawns, the wake compares the installed build with |
| +// its target and never runs a different installed build. |
| + |
| +func heldOn(v string) func(string) HeldWake { |
| + return func(string) HeldWake { return HeldWake{Target: v, Held: true} } |
| +} |
| + |
| +// shrinkWakeWait bounds a held wake's wait for root (wakeWaitBound, the |
| +// bound its waiters have). |
| +func shrinkWakeWait(t *testing.T, d time.Duration) { |
| + t.Helper() |
| + old := wakeWaitBound |
| + wakeWaitBound = d |
| + t.Cleanup(func() { wakeWaitBound = old }) |
| +} |
| + |
| +// asks records RequestStage calls as host@build. |
| +type asks struct { |
| + mu sync.Mutex |
| + got []string |
| + then func(host, build string) |
| +} |
| + |
| +func (r *asks) ask(host, build string) { |
| + r.mu.Lock() |
| + r.got = append(r.got, host+"@"+build) |
| + r.mu.Unlock() |
| + if r.then != nil { |
| + r.then(host, build) |
| + } |
| +} |
| + |
| +func (r *asks) seen() []string { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return append([]string(nil), r.got...) |
| +} |
| + |
| +func (a *Service) entryGone(host string) bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.entries[host] == nil |
| +} |
| + |
| +// Spec §8 "Returning residence": the wake never runs a stale link. It asks |
| +// root to install the target, waits for it, and runs exactly that. |
| +func TestAHeldWakeNeverRunsAStaleLink(t *testing.T) { |
| + a, b := identityActivator(t, "v3") // an old live link left on this box |
| + a.HeldTarget = heldOn("v1") |
| + r := &asks{then: func(string, string) { b.setLive("v1") }} // root installs v1 |
| + a.RequestStage = r.ask |
| + l := &execLog{} |
| + a.BeforeExec = l.hook |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if got := r.seen(); !slices.Equal(got, []string{"one.example.com@v1"}) || b.lastExeVersion() != "v1" || b.startCount() != 1 { |
| + t.Fatalf("asked %v, started %d of %q; want v1 asked for and started, never v3", got, b.startCount(), b.lastExeVersion()) |
| + } |
| + if calls, _, _ := l.seen(); !slices.Equal(calls, []string{"one.example.com@v1"}) { |
| + t.Fatalf("records %v, want v1", calls) |
| + } |
| +} |
| + |
| +// The wait is bounded: a root that never installs the target fails the wake |
| +// within the waiters' bound, and nothing runs. |
| +func TestAHeldWakeWaitsOnlyWithinItsBound(t *testing.T) { |
| + shrinkWakeWait(t, 300*time.Millisecond) |
| + a, b := identityActivator(t, "v3") |
| + a.HeldTarget = heldOn("v1") |
| + r := &asks{} |
| + a.RequestStage = r.ask |
| + host := "one.example.com" |
| + began := time.Now() |
| + if _, err := a.beginRequest(context.Background(), identityRoute(t, host)); !errors.Is(err, errStaleLink) && !errors.Is(err, errWakeTimeout) { |
| + t.Fatalf("err = %v; want the wake to fail, not run v3", err) |
| + } |
| + waitFor(t, "the held wake to end", func() bool { return a.entryGone(host) }) |
| + if took := time.Since(began); took > 5*time.Second { |
| + t.Fatalf("the held wake waited %v, past its bound", took) |
| + } |
| + if b.startCount() != 0 || b.seedCount() != 0 { |
| + t.Fatalf("started %d, seeded %d: the stale build ran", b.startCount(), b.seedCount()) |
| + } |
| + if got := r.seen(); len(got) != 1 { |
| + t.Fatalf("asked root %v, want once", got) |
| + } |
| +} |
| + |
| +func TestAnUnknownBuildNeverWakes(t *testing.T) { |
| + a, b := identityActivator(t, "v1") |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Held: true, Unknown: true} } |
| + a.RequestStage = func(string, string) { t.Error("asked root to install an unknown build") } |
| + _, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if !errors.Is(err, errBuildUnknown) { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if acq, _, starts := b.counts(); acq != 0 || starts != 0 { |
| + t.Fatalf("acquired %d, started %d: an unknown build woke", acq, starts) |
| + } |
| + if got, want := memberSafeWakeReason(err), updatewords.Text("settings.box.reason.unknown"); got != want { |
| + t.Fatalf("member reads %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestAnAutoInstanceIsNotChecked(t *testing.T) { |
| + a, b := identityActivator(t, "v1") |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{} } |
| + a.RequestStage = func(string, string) { t.Error("asked root for an instance that follows its channel") } |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if b.startCount() != 1 || b.lastExeVersion() != "v1" { |
| + t.Fatal("an auto instance's wake changed") |
| + } |
| +} |
| + |
| +// Astra I3 #2: with CARLOS_SWAP off there is no planner. A target root |
| +// reports failed is never run, even while it is still the live link. |
| +func TestWithNoPlannerAFailedTargetIsRefusedAtTheLiveLink(t *testing.T) { |
| + a, b := identityActivator(t, "v9") // the failed build is still installed; Planner nil |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v9", Held: true, Failed: true} } |
| + a.RequestStage = func(string, string) { t.Error("asked root to install a build it reports failed") } |
| + _, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if !errors.Is(err, errTargetFailed) { |
| + t.Fatalf("err = %v, want errTargetFailed", err) |
| + } |
| + if b.startCount() != 0 { |
| + t.Fatal("a build root reported failed ran again") |
| + } |
| + if got, want := memberSafeWakeReason(err), fmt.Sprintf(updatewords.Text("settings.box.reason.failed"), "v9"); got != want { |
| + t.Fatalf("member reads %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// Astra I3 #5: with no planner, a retained, verified staged copy of the |
| +// target runs directly, recorded first; root's repoint is not needed. |
| +func TestWithNoPlannerARetainedCopyRunsWithoutARepoint(t *testing.T) { |
| + shrinkWakeWait(t, 2*time.Second) |
| + a, b := identityActivator(t, "v3") // stale v3 installed |
| + retained := stagedID(t, t.TempDir(), "v1") |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v1", Held: true, Staged: &retained} } |
| + a.RequestStage = func(string, string) { t.Error("asked root to repoint, with the retained copy on the box") } |
| + l := &execLog{spawns: b.startCount} |
| + a.BeforeExec = l.hook |
| + rt := identityRoute(t, "one.example.com") |
| + done, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("the wake waited for root instead of running the retained copy: %v", err) |
| + } |
| + done() |
| + x, ok := b.lastExe() |
| + calls, at, _ := l.seen() |
| + if !ok || x.id != retained || !slices.Equal(calls, []string{"one.example.com@v1"}) || at[0] != 0 { |
| + t.Fatalf("ran %+v, recorded %v at spawn counts %v; want the retained v1, recorded first", x, calls, at) |
| + } |
| + if c, _ := b.identityCounts(); c != 0 || b.liveStarts() != 0 { |
| + t.Fatalf("%d live-link captures, %d live-link starts: the retained copy is the wake's identity", c, b.liveStarts()) |
| + } |
| + if g, _ := a.entryGen(rt.Host); g.StagedPath != retained.StagedPath || g.Build != "v1" { |
| + t.Fatalf("serving generation %+v, want the retained file's", g) |
| + } |
| +} |
| + |
| +// Anchors D18: a retained copy is a staged file run that no plan or pin |
| +// published. The wake publishes it as pending (ruling R-COLDPUBLISH) before |
| +// it records, so root's sweep cannot remove the file between the capture |
| +// and the exec. |
| +func TestARetainedCopyIsPublishedBeforeItIsRecorded(t *testing.T) { |
| + shrinkWakeWait(t, 2*time.Second) |
| + a, b := identityActivator(t, "v3") |
| + a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json") |
| + retained := stagedID(t, t.TempDir(), "v1") |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v1", Held: true, Staged: &retained} } |
| + names := func() bool { |
| + g, err := release.ReadGenerations(a.GenerationsFile) |
| + return err == nil && g != nil && slices.Contains(g.Paths["one.example.com"], retained.StagedPath) |
| + } |
| + var publishedFirst atomic.Bool |
| + l := &execLog{} |
| + a.BeforeExec = func(ctx context.Context, host, build string) error { |
| + publishedFirst.Store(names()) |
| + return l.hook(ctx, host, build) |
| + } |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if !publishedFirst.Load() { |
| + t.Fatal("the build state named the retained copy before the generations file did") |
| + } |
| + waitFor(t, "the serving generation to stay published", names) |
| + if b.startCount() != 1 { |
| + t.Fatalf("starts = %d", b.startCount()) |
| + } |
| +} |
| + |
| +// With a planner, a failed target is the planner's call (I2 #4): the wake is |
| +// not refused by the settings rule, and the planner's recovery boots. |
| +func TestWithAPlannerAFailedTargetDefersToIt(t *testing.T) { |
| + good := stagedID(t, t.TempDir(), "v0") |
| + a, b, _ := planActivator(t, WakePlan{Boot: good, Mode: ModeOverlap}) |
| + b.fakeBackend.setIdentity("v9") |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v9", Held: true, Failed: true} } |
| + a.RequestStage = func(string, string) { t.Error("asked root for a target the planner decides") } |
| + done, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if gens := b.started(); len(gens) != 1 || gens[0].Build != "v0" { |
| + t.Fatalf("started %+v, want the planner's recovery v0", gens) |
| + } |
| +} |
| + |
| +// stampRoutes is the registry row the wake re-reads (RouteLookup); root's |
| +// repoint stamps Version, as adoption does. |
| +type stampRoutes struct { |
| + mu sync.Mutex |
| + rt registry.Route |
| +} |
| + |
| +func (s *stampRoutes) Get(string) (*registry.Route, error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + rt := s.rt |
| + return &rt, nil |
| +} |
| + |
| +func (s *stampRoutes) stamp(v string) { s.mu.Lock(); s.rt.Version = v; s.mu.Unlock() } |
| + |
| +// Astra I2 #4: the held wake never pins its target itself. Generations are |
| +// on and the target v1 is staged on this box (the case where the I1 wake |
| +// check set e.pin and skipped the planner), but the failure book blocks v1 |
| +// here: the planner, which sees root's install, boots the good build v0. |
| +func TestAHeldWakeGoesThroughTheFailureAwarePlan(t *testing.T) { |
| + dir := t.TempDir() |
| + good, target := stagedID(t, dir, "v0"), stagedID(t, dir, "v1") |
| + a, b, _ := planActivator(t, WakePlan{}) // generations on, a recording GenBackend |
| + routes := &stampRoutes{rt: identityRoute(t, "one.example.com")} |
| + routes.rt.Version, routes.rt.GoodVersion = "v3", "v0" |
| + a.Routes = routes |
| + b.fakeBackend.setIdentity("v3") |
| + var mu sync.Mutex |
| + var planned []string |
| + a.Planner = func(_ context.Context, rt registry.Route) WakePlan { |
| + mu.Lock() |
| + planned = append(planned, rt.Version) |
| + mu.Unlock() |
| + return WakePlan{Boot: good, Mode: ModeOverlap} // decideWake's recovery when Blocks(v1) |
| + } |
| + // The target's staged copy exists, but a planner is on: the wake asks |
| + // root and lets the planner choose, never running the copy itself. |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v1", Held: true, Staged: &target} } |
| + a.RequestStage = func(host, build string) { b.fakeBackend.setLive(build); routes.stamp(build) } |
| + done, err := a.beginRequest(context.Background(), routes.rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if gens := b.started(); len(planned) != 1 || planned[0] != "v1" || len(gens) != 1 || gens[0].Build != "v0" { |
| + t.Fatalf("planner saw %v, started %+v; want one plan on root's install v1, booting the good v0", planned, gens) |
| + } |
| + a.mu.Lock() |
| + pinned := a.entries["one.example.com"] != nil && a.entries["one.example.com"].pin != nil |
| + a.mu.Unlock() |
| + if pinned { |
| + t.Fatal("the held wake pinned its target") |
| + } |
| +} |
| + |
| +// Astra I2 #4, the race half: root installs something other than the target |
| +// between the wait and the identity; the wake refuses rather than run it, |
| +// and records and publishes nothing. |
| +func TestAHeldWakeRefusesAnIdentityThatIsNotItsTarget(t *testing.T) { |
| + a, b := identityActivator(t, "v3") |
| + a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json") |
| + a.HeldTarget = heldOn("v1") |
| + a.RequestStage = func(string, string) { b.setLive("v1") } |
| + b.mu.Lock() |
| + b.ident.onLiveIdentity = func(f *identityFake) { f.live = "v2" } // a repoint after the wait |
| + b.mu.Unlock() |
| + l := &execLog{} |
| + a.BeforeExec = l.hook |
| + _, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if !errors.Is(err, errStaleLink) { |
| + t.Fatalf("err = %v, want errStaleLink", err) |
| + } |
| + if b.startCount() != 0 || b.seedCount() != 0 { |
| + t.Fatal("the wake ran a build that is not its target") |
| + } |
| + if calls, _, _ := l.seen(); len(calls) != 0 { |
| + t.Fatalf("records %v: a refused identity was recorded", calls) |
| + } |
| + waitFor(t, "the lease to go back", func() bool { return b.releaseCount() == 1 }) |
| + if g, err := release.ReadGenerations(a.GenerationsFile); err == nil && g != nil && len(g.Paths["one.example.com"]) != 0 { |
| + t.Fatalf("published %v for a refused identity", g.Paths) |
| + } |
| + if got := memberSafeWakeReason(err); got != memberSafeWakeReason(ErrFenced) { |
| + t.Fatalf("member reads %q, want the fence's wording", got) |
| + } |
| +} |
| + |
| +// Ruling R-EXEC-NOVERSION-2: a held wake never takes the unversioned |
| +// live-link fallback, because its target is a version. |
| +func TestAHeldWakeNeverRunsAnUnversionedLink(t *testing.T) { |
| + a, b := identityActivator(t, "v1") // the link's version reads v1 ... |
| + b.mu.Lock() |
| + b.ident.unversioned = true // ... but the file it resolves to is outside the version store |
| + b.mu.Unlock() |
| + a.HeldTarget = heldOn("v1") |
| + _, err := a.beginRequest(context.Background(), identityRoute(t, "one.example.com")) |
| + if !errors.Is(err, errStaleLink) { |
| + t.Fatalf("err = %v, want errStaleLink", err) |
| + } |
| + if b.startCount() != 0 || b.liveStarts() != 0 { |
| + t.Fatalf("started %d (live-link %d): a held wake ran an unversioned file", b.startCount(), b.liveStarts()) |
| + } |
| +} |
| + |
| +// A hold swap's pinned wake was chosen by the swap from root's install |
| +// (e.pin): the held check does not run for it. |
| +func TestAHoldSwapsPinnedWakeIsNotChecked(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.a.HeldTarget = func(string) HeldWake { |
| + t.Error("the held check ran for a hold swap's pinned wake") |
| + return HeldWake{} |
| + } |
| + if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDone { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| +} |
| + |
| +// Ruling R-WAKEWORDS: members never see the held check's internal text. |
| +func TestTheHeldWakeRefusalsReadInTheMembersTerms(t *testing.T) { |
| + cases := []struct { |
| + err error |
| + want string |
| + }{ |
| + {fmt.Errorf("one.example.com: %w", errBuildUnknown), updatewords.Text("settings.box.reason.unknown")}, |
| + {fmt.Errorf("one.example.com: %w", &targetFailedError{host: "one.example.com", build: "v9"}), fmt.Sprintf(updatewords.Text("settings.box.reason.failed"), "v9")}, |
| + {fmt.Errorf("start one.example.com: %w: would run v2, wants v1", errStaleLink), memberSafeWakeReason(ErrFenced)}, |
| + } |
| + for _, c := range cases { |
| + if got := memberSafeWakeReason(c.err); got != c.want { |
| + t.Errorf("memberSafeWakeReason(%v) = %q, want %q", c.err, got, c.want) |
| + } |
| + } |
| + if !errors.Is(&targetFailedError{build: "v9"}, errTargetFailed) { |
| + t.Fatal("a failed target is not errTargetFailed") |
| + } |
| +} |
| + |
| +// heldIdentityOK: the target, or, under a plan that is not a first wake, |
| +// the good build the planner chose as recovery. Nothing else. |
| +func TestHeldIdentityOK(t *testing.T) { |
| + rt := registry.Route{GoodVersion: "v0"} |
| + plan := WakePlan{Boot: release.StagedIdentity{Version: "v0", StagedPath: "/x/v0"}} |
| + first := plan |
| + first.FirstWake = true |
| + for _, c := range []struct { |
| + target, id string |
| + plan WakePlan |
| + ok bool |
| + }{ |
| + {"", "v7", WakePlan{}, true}, |
| + {"v1", "v1", WakePlan{}, true}, |
| + {"v1", "v2", WakePlan{}, false}, |
| + {"v1", "v0", WakePlan{}, false}, |
| + {"v1", "v0", plan, true}, |
| + {"v1", "v0", first, false}, |
| + {"v1", "", WakePlan{}, false}, |
| + } { |
| + if got := heldIdentityOK(c.target, release.StagedIdentity{Version: c.id}, c.plan, rt); got != c.ok { |
| + t.Errorf("heldIdentityOK(%q, %q, planned=%v first=%v) = %v, want %v", c.target, c.id, c.plan.planned(), c.plan.FirstWake, got, c.ok) |
| + } |
| + } |
| +} |
diff --git a/internal/activator/wakeidentity_test.go b/internal/activator/wakeidentity_test.go| index c98a63181..e32e2ed32 100644 |
| --- a/internal/activator/wakeidentity_test.go |
| +++ b/internal/activator/wakeidentity_test.go |
| @@ -37,8 +37,12 @@ type identityFake struct { |
| // unversioned makes the live link name a file outside the version |
| // store (Version ""). |
| unversioned bool |
| - exe []exeCall |
| - last release.StagedIdentity |
| + // onLiveIdentity, when set, runs inside LiveIdentity before it resolves |
| + // the link (under the fake's lock): a repoint that lands between a held |
| + // wake's wait and its capture. |
| + onLiveIdentity func(*identityFake) |
| + exe []exeCall |
| + last release.StagedIdentity |
| } |
| type exeCall struct { |
| @@ -81,6 +85,9 @@ func (b *fakeBackend) LiveVersion(string) (string, error) { |
| func (b *fakeBackend) LiveIdentity(_ context.Context, rt registry.Route) (release.StagedIdentity, error) { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| + if f := b.ident.onLiveIdentity; f != nil { |
| + f(&b.ident) |
| + } |
| if b.ident.unversioned { |
| b.ident.captures++ |
| return release.StagedIdentity{StagedPath: unversionedPath, Digest: "sha-of-hand-placed"}, nil |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 365e8894e..d848e20d2 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -436,6 +436,21 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // store that failed to start refuses every spawn (never "run |
| // unrecorded"). Set before Start, like Routes. |
| svc.BeforeExec = buildStateHook(bs, bsErr) |
| + // The held wake check (settings spec §4.3 rule 3): a held instance |
| + // never wakes on an installed build other than its target. The copy |
| + // is refreshed before every wake that could be held (wakeTarget), and |
| + // a target not installed is asked of root through the stage relay, |
| + // one throttled requester for every wake. As every stage relay, not |
| + // in host-writes mode (ruling R-AGENT-RELAY; release's OnStageBlocked |
| + // callers): there a held wake waits for root's own pass. A box with |
| + // no build-state store keeps today's wake. |
| + if bs != nil { |
| + svc.HeldTarget = func(h string) activator.HeldWake { return bs.wakeTarget(context.Background(), h) } |
| + if !cfg.HostWrites.Enabled { |
| + askStage := stageRequester(dataDir()) |
| + svc.RequestStage = func(host, build string) { askStage(host, build, nil) } |
| + } |
| + } |
| // Every worker's captured serving build, published for root's pin |
| // set (zero-downtime deploys spec §4.2, ruling R17), whatever |
| // CARLOS_SWAP says. dd is dataDir(), the same data dir root's |
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go| index d3e00e8d1..f0e8dfef1 100644 |
| --- a/internal/host/buildstate.go |
| +++ b/internal/host/buildstate.go |
| @@ -38,7 +38,10 @@ type buildStates struct { |
| storeFor func(ctx context.Context, bucket string) (release.Store, error) |
| reg *registry.Registry |
| optDir string |
| - now func() time.Time |
| + // bakeDir is the data dir every agent and root pass hands release.BakeDir: |
| + // root's refusal records (release.ReadRefusal) live under it. |
| + bakeDir string |
| + now func() time.Time |
| // budget bounds one refreshAll; 0 means refreshBudget. |
| budget time.Duration |
| // beforePut, when set (tests only), runs in update between its read and |
| @@ -62,7 +65,7 @@ func newBuildStates(cfg *activator.Config, reg *registry.Registry, optDir string |
| return nil, err |
| } |
| b := &buildStates{hibBucket: cfg.Bucket, prefix: func(h string) string { return activator.HibernatePrefix(cfg.Prefix, h) }, |
| - reg: reg, optDir: optDir, now: time.Now} |
| + reg: reg, optDir: optDir, bakeDir: dataDir(), now: time.Now} |
| if cfg.HostWrites.Enabled { |
| b.claims, b.claimBucket = cfg.Claims.Read, cfg.Claims.Bucket |
| } |
| @@ -393,3 +396,110 @@ func buildStateHook(bs *buildStates, initErr error) func(ctx context.Context, ho |
| } |
| return bs.beforeExec |
| } |
| + |
| +// heldTarget is the held wake check's answer (activator.Service.HeldTarget; |
| +// settings spec §4.3 rule 3): the target a held instance must wake onto, in |
| +// root's rule order (Target): a pin, then a pending request's T, then |
| +// last_executed (Astra I2 #7). Held is false for an instance root follows the |
| +// channel for. Failed and Staged feed the wake's own admission rule, which |
| +// holds with no planner (Astra I3 #2, #5). |
| +// |
| +// It reads what root reads and what root wrote. The column is parsed as root |
| +// parses it (ParseUpdateSettingsLenient). One that does not parse even then |
| +// is held exactly as root holds it: root records reason unknown for it |
| +// (ruling R-SETTINGS-UNREADABLE), and a wake holds as unknown while that is |
| +// so; with no such word from root it is not held. Root's consumed reason |
| +// decides whether a major hold is in force (the agent does not read tags). |
| +// A pending request's T is followed only with the build state read (Known): |
| +// with it unread root does no request bookkeeping and will not install T. |
| +func (b *buildStates) heldTarget(host string) activator.HeldWake { |
| + rt, err := b.reg.Get(host) |
| + if err != nil || rt == nil { |
| + return activator.HeldWake{} |
| + } |
| + c, _ := release.ReadConsumed(b.optDir, host) |
| + rootUnknown := c != nil && c.Reason == string(release.ReasonUnknown) |
| + u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings) |
| + if err != nil { |
| + if rootUnknown { |
| + return activator.HeldWake{Held: true, Unknown: true} |
| + } |
| + return activator.HeldWake{} |
| + } |
| + if u.Default() { |
| + return activator.HeldWake{} |
| + } |
| + bs, _ := release.ParseBuildStateCopy(rt.BuildState) |
| + refusal, _ := release.ReadRefusal(b.bakeDir, host) |
| + // rootFailed: root's own decision found v failed, or root's refusal |
| + // record says v did not come up here. A wrong-program or not-proven |
| + // refusal is not a failure of the build (ruling R-REFUSALKIND). |
| + rootFailed := func(v string) bool { |
| + return (c != nil && c.Reason == string(release.ReasonFailed) && c.Detail == v) || |
| + (refusal != nil && refusal.Kind == release.RefusalNotUp && refusal.Version == v) |
| + } |
| + held := func(v string) activator.HeldWake { |
| + h := activator.HeldWake{Target: v, Held: true, Failed: rootFailed(v)} |
| + if !h.Failed { |
| + h.Staged = b.retained(*rt, v) |
| + } |
| + return h |
| + } |
| + // Root's order (Target, §4.3): a pin outranks a pending request (I2 #7). |
| + if u.Pin != "" { |
| + return held(u.Pin) |
| + } |
| + if bs.Known && u.InstallRequest > release.Watermark(c, bs.State) { |
| + if c != nil && c.Resolved != nil && c.Resolved.ID == u.InstallRequest { |
| + return held(c.Resolved.Target) |
| + } |
| + if bs.State != nil && bs.State.Resolved != nil && bs.State.Resolved.ID == u.InstallRequest { |
| + return held(bs.State.Resolved.Target) |
| + } |
| + } |
| + heldByRoot := c != nil && (c.Reason == string(release.ReasonManual) || c.Reason == string(release.ReasonMajor) || rootUnknown) |
| + if u.Updates != release.UpdatesManual && !heldByRoot { |
| + return activator.HeldWake{} |
| + } |
| + if rootUnknown { |
| + return activator.HeldWake{Held: true, Unknown: true} |
| + } |
| + if bs.State != nil && bs.State.LastExecuted != "" { |
| + return held(bs.State.LastExecuted) |
| + } |
| + return activator.HeldWake{} // no build state: whatever root installed is the instance's |
| +} |
| + |
| +// retained is v's staged executable on this box when its bytes match its |
| +// staged manifest's sha256 for this platform (release.RetainedStaged, the |
| +// check stageAuthorized's recovery uses), else nil (Astra I3 #5). |
| +func (b *buildStates) retained(rt registry.Route, v string) *release.StagedIdentity { |
| + id, _ := release.RetainedStaged(b.optDir, release.RouteAccount(rt), rt.App, v) |
| + return id |
| +} |
| + |
| +// wakeHeldBudget bounds wakeTarget's refresh: the wake waits on it. |
| +const wakeHeldBudget = 5 * time.Second |
| + |
| +// wakeTarget is heldTarget with the copy refreshed first (§4.2: "refreshed |
| +// at every reconcile and before every wake"), for a route whose settings |
| +// could hold it. A route with default settings is today's wake: nothing is |
| +// read for it. A refresh that fails keeps the last copy (refresh records the |
| +// failure), and the answer is read from that, as root reads it. |
| +func (b *buildStates) wakeTarget(ctx context.Context, host string) activator.HeldWake { |
| + rt, err := b.reg.Get(host) |
| + if err != nil || rt == nil { |
| + return activator.HeldWake{} |
| + } |
| + if u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings); err == nil && u.Default() { |
| + return activator.HeldWake{} |
| + } |
| + if keepsBuildState(*rt, b.claims != nil) { |
| + ctx, cancel := context.WithTimeout(ctx, wakeHeldBudget) |
| + if err := b.refresh(ctx, host); err != nil { |
| + log.Printf("agent: build state of %s before its wake: %v", host, err) |
| + } |
| + cancel() |
| + } |
| + return b.heldTarget(host) |
| +} |
diff --git a/internal/host/buildstate_test.go b/internal/host/buildstate_test.go| index adce25a47..6b2c3048d 100644 |
| --- a/internal/host/buildstate_test.go |
| +++ b/internal/host/buildstate_test.go |
| @@ -2,11 +2,16 @@ package host |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| "os" |
| "path/filepath" |
| + "runtime" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| @@ -38,7 +43,7 @@ func testBuildStates(t *testing.T) (*buildStates, release.Store, *registry.Regis |
| } |
| s := release.NewDirStore(t.TempDir()) |
| return &buildStates{hibBucket: "hib", prefix: func(h string) string { return "inst/" + h }, |
| - storeFor: storesOf(map[string]release.Store{"hib": s}), reg: reg, optDir: t.TempDir(), now: time.Now}, s, reg |
| + storeFor: storesOf(map[string]release.Store{"hib": s}), reg: reg, optDir: t.TempDir(), bakeDir: t.TempDir(), now: time.Now}, s, reg |
| } |
| func copyOf(t *testing.T, reg *registry.Registry) release.BuildStateCopy { |
| @@ -770,3 +775,212 @@ func TestAFailedBuildStateStartRefusesEveryExec(t *testing.T) { |
| t.Fatal("a started store's hook does not record") |
| } |
| } |
| + |
| +// --- the held wake check's answer (Service.HeldTarget, settings spec §4.3 |
| +// rule 3) --- |
| + |
| +func setSettings(t *testing.T, reg *registry.Registry, u release.UpdateSettings) { |
| + t.Helper() |
| + if err := reg.SetUpdateSettings("a.example.com", u.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func setCopy(t *testing.T, reg *registry.Registry, c release.BuildStateCopy) { |
| + t.Helper() |
| + if err := reg.SetBuildState("a.example.com", c.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// stageWithManifest stages body as acct/app at ver, with its manifest |
| +// sibling naming body's sha256 for this platform, as root's staging leaves |
| +// a verified copy. |
| +func stageWithManifest(t *testing.T, optDir, acct, app, ver string, body []byte) { |
| + t.Helper() |
| + if err := release.StageArtifact(optDir, acct, ver, app, body); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(body) |
| + m := release.Manifest{Schema: 1, App: app, Version: ver, Kind: "binary", |
| + Artifacts: map[string]release.Artifact{runtime.GOOS + "-" + runtime.GOARCH: {File: app, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(body))}}} |
| + raw, err := json.Marshal(m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.StageManifest(optDir, acct, ver, app, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func fileSHA(t *testing.T, path string) string { |
| + t.Helper() |
| + sum, err := release.FileSHA256(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return sum |
| +} |
| + |
| +// Astra I1 #8, the agent's half: root reports a major hold with a request |
| +// baking beside it (Consumed.Baking); the wake check still treats the |
| +// instance as held, on last_executed. |
| +func TestAMajorHoldStaysHeldWhileARequestBakes(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + two := 2 |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 1}) |
| + setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2"}}) |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonMajor), Desired: "v2", Baking: "v3"}) |
| + if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v2" { |
| + t.Fatalf("heldTarget = %+v; want v2, held", h) |
| + } |
| +} |
| + |
| +// Astra I2 #7: a pin set after a request resolved outranks it, as in root's |
| +// Target. The wake targets the pin P, never the request's T. |
| +func TestAPinOutranksAPendingRequestAtTheWake(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", Pin: "v5", InstallRequest: 2}) |
| + setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2", Completed: 1}}) |
| + writeConsumed(t, b, release.Consumed{Completed: 1, Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}}) |
| + if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v5" { |
| + t.Fatalf("heldTarget = %+v; want the pin v5", h) |
| + } |
| + // Without the pin, the pending request's T outranks last_executed. |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 2}) |
| + if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v4" { |
| + t.Fatalf("heldTarget = %+v; want the request's v4", h) |
| + } |
| +} |
| + |
| +// Task 2 review: with the build state unread (Known false) root does no |
| +// request bookkeeping and will not install T, so neither does the wake |
| +// follow it. Held settings then read root's unknown; auto ones are not held. |
| +func TestAnUnreadBuildStateDoesNotFollowAPendingRequest(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 2}) |
| + setCopy(t, reg, release.BuildStateCopy{Known: false, ReadErr: "s3: timeout"}) |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonUnknown), Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}}) |
| + if h := b.heldTarget("a.example.com"); !h.Held || !h.Unknown || h.Target != "" { |
| + t.Fatalf("heldTarget = %+v; want held, unknown, never the request's v4", h) |
| + } |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", InstallRequest: 2}) |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonAdopt), Desired: "v5", Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}}) |
| + if h := b.heldTarget("a.example.com"); h.Held { |
| + t.Fatalf("heldTarget = %+v; an auto instance with an unread state follows root's adoption", h) |
| + } |
| +} |
| + |
| +// Ruling R-SETTINGS-UNREADABLE (Task 6): a column that does not parse even |
| +// leniently holds the wake as unknown when root says unknown, and is not |
| +// held otherwise (root's own reading). A Control name this binary does not |
| +// know is dropped, not read as unreadable, so the hold stays. |
| +func TestAnUnreadableColumnHoldsOnlyAsRootDoes(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if err := reg.SetUpdateSettings("a.example.com", `{"updates":"sometimes"}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h := b.heldTarget("a.example.com"); h.Held { |
| + t.Fatalf("heldTarget = %+v with no word from root; want root's reading (not held)", h) |
| + } |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonUnknown), Detail: "update settings: updates \"sometimes\""}) |
| + if h := b.heldTarget("a.example.com"); !h.Held || !h.Unknown { |
| + t.Fatalf("heldTarget = %+v; want held as unknown, as root holds it", h) |
| + } |
| + if err := reg.SetUpdateSettings("a.example.com", `{"updates":"manual","channel":"stable","control":["updates","colour"]}`); err != nil { |
| + t.Fatal(err) |
| + } |
| + setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2"}}) |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "v2"}) |
| + if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v2" { |
| + t.Fatalf("heldTarget = %+v; want the manual hold on v2 through an unknown Control name", h) |
| + } |
| +} |
| + |
| +// Astra I3 #2: root's failed decision, or its refusal record, marks the |
| +// target failed, so the wake's own rule refuses it with no planner. Only a |
| +// not-up refusal is a build failure (ruling R-REFUSALKIND). |
| +func TestARootFailedTargetIsReportedFailed(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Pin: "v5"}) |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonFailed), Detail: "v5"}) |
| + stageWithManifest(t, b.optDir, "bab", "km", "v5", []byte("five")) |
| + if h := b.heldTarget("a.example.com"); !h.Held || !h.Failed || h.Target != "v5" || h.Staged != nil { |
| + t.Fatalf("heldTarget = %+v; want v5 held and failed, no retained copy offered", h) |
| + } |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonPin), Detail: "v5"}) |
| + for _, kind := range []string{release.RefusalWrongProgram, release.RefusalNotProven} { |
| + if err := release.WriteRefusal(b.bakeDir, release.Refusal{Kind: kind, Host: "a.example.com", App: "km", Version: "v5", Reason: "refused"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h := b.heldTarget("a.example.com"); h.Failed { |
| + t.Fatalf("a %s refusal of the pin = %+v; it is not a build failure", kind, h) |
| + } |
| + } |
| + if err := release.WriteRefusal(b.bakeDir, release.Refusal{Host: "a.example.com", App: "km", Version: "v5", Reason: "not up"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h := b.heldTarget("a.example.com"); !h.Failed || h.Staged != nil { |
| + t.Fatalf("a not-up refusal record for the pin = %+v; want failed", h) |
| + } |
| +} |
| + |
| +// Astra I3 #5: a retained copy whose bytes match its staged manifest is |
| +// offered; one whose bytes differ is not. |
| +func TestARetainedCopyIsOfferedOnlyWhenVerified(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) |
| + setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2"}}) |
| + stageWithManifest(t, b.optDir, "bab", "km", "v2", []byte("two")) |
| + h := b.heldTarget("a.example.com") |
| + if !h.Held || h.Target != "v2" || h.Staged == nil || h.Staged.Version != "v2" || h.Staged.StagedPath != release.VersionPath(b.optDir, "bab", "v2", "km") || h.Staged.Digest != fileSHA(t, h.Staged.StagedPath) { |
| + t.Fatalf("heldTarget = %+v; want the verified retained v2", h) |
| + } |
| + if err := os.WriteFile(release.VersionPath(b.optDir, "bab", "v2", "km"), []byte("tampered"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h := b.heldTarget("a.example.com"); h.Staged != nil { |
| + t.Fatal("a staged copy whose bytes differ from its manifest was offered") |
| + } |
| +} |
| + |
| +// Default settings are today's wake: not held, and nothing is read for it. |
| +// Held settings refresh the copy first (§4.2: "refreshed ... before every |
| +// wake"), so a last_executed the reconcile has not copied yet still counts. |
| +func TestTheWakeTargetRefreshesOnlyAHeldRoute(t *testing.T) { |
| + b, s, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + if err := put(t, b, ctx, "a.example.com", release.BuildState{LastExecuted: "v2"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + setCopy(t, reg, release.BuildStateCopy{}) // what the last reconcile saw: nothing yet |
| + gets := &getCounter{Store: s} |
| + b.storeFor = storesOf(map[string]release.Store{"hib": gets}) |
| + if h := b.wakeTarget(ctx, "a.example.com"); h.Held || gets.n() != 0 { |
| + t.Fatalf("default settings: %+v after %d reads; want today's wake, nothing read", h, gets.n()) |
| + } |
| + setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}) |
| + writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "v2"}) |
| + if h := b.wakeTarget(ctx, "a.example.com"); !h.Held || h.Target != "v2" || gets.n() == 0 { |
| + t.Fatalf("held: %+v after %d reads; want the refreshed last_executed v2", h, gets.n()) |
| + } |
| +} |
| + |
| +type getCounter struct { |
| + release.Store |
| + mu sync.Mutex |
| + gets int |
| +} |
| + |
| +func (g *getCounter) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + g.mu.Lock() |
| + g.gets++ |
| + g.mu.Unlock() |
| + return g.Store.Get(ctx, key) |
| +} |
| + |
| +func (g *getCounter) n() int { |
| + g.mu.Lock() |
| + defer g.mu.Unlock() |
| + return g.gets |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 10bdc29a9..688a779bf 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -2258,14 +2258,9 @@ func stageAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, optDir |
| if !errors.Is(err, ErrNoPromotionRecord) { |
| return nil, fmt.Errorf("%s: promotion record: %w", ver, err) |
| } |
| - path := VersionPath(optDir, acct, ver, app) |
| - if m, merr := ReadStagedManifest(optDir, acct, ver, app); merr == nil && m != nil { |
| - if a, ok := m.Artifacts[adoptPlatform]; ok { |
| - if verr := VerifyStaged(StagedIdentity{Version: ver, StagedPath: path, Digest: a.SHA256}); verr == nil { |
| - fmt.Printf(" %s: %s has no promotion record; recovered from this box's staged copy\n", host, ver) |
| - return m, nil |
| - } |
| - } |
| + if id, m := RetainedStaged(optDir, acct, app, ver); id != nil { |
| + fmt.Printf(" %s: %s has no promotion record; recovered from this box's staged copy\n", host, ver) |
| + return m, nil |
| } |
| return nil, fmt.Errorf("%s: %s", ver, updatewords.Text("settings.evidence.no-record")) |
| } |
diff --git a/internal/release/staged.go b/internal/release/staged.go| index a3883961a..f6eb08c68 100644 |
| --- a/internal/release/staged.go |
| +++ b/internal/release/staged.go |
| @@ -140,6 +140,29 @@ func ResolveLiveLink(optDir, host string) (StagedIdentity, error) { |
| return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: hops[len(hops)-1]}, nil |
| } |
| +// RetainedStaged is ver's staged executable of app on this box, when its |
| +// bytes still hash to this platform's artifact digest in its staged manifest |
| +// sibling: the manifest the copy was verified against when it was staged |
| +// (settings spec §4.1's recovery check; root's stageAuthorized and the held |
| +// wake check both use it). nil when the sibling is absent or unreadable, |
| +// names no artifact for this platform, or the bytes differ. Demand is left |
| +// empty, as ResolveLive leaves it: only CanonicalDemand can vouch for it. |
| +func RetainedStaged(optDir, acct, app, ver string) (*StagedIdentity, *Manifest) { |
| + m, err := ReadStagedManifest(optDir, acct, ver, app) |
| + if err != nil || m == nil { |
| + return nil, nil |
| + } |
| + a, ok := m.Artifacts[adoptPlatform] |
| + if !ok { |
| + return nil, nil |
| + } |
| + id := StagedIdentity{Version: ver, StagedPath: VersionPath(optDir, acct, ver, app), Digest: strings.ToLower(a.SHA256)} |
| + if VerifyStaged(id) != nil { |
| + return nil, nil |
| + } |
| + return &id, m |
| +} |
| + |
| // VerifyStaged checks the file at id.StagedPath against id.Digest, the digest |
| // retained when the build was exec'd (spec §4.2's recovery check). It never |
| // consults the canonical manifest: a later re-ship of the same name must not |