carlos / platform Public

settings-4 task 7: a held instance's wake never runs a stale build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ab13192bbd4ebb2cfe1202be1d709833e2d231b5 parent 9d5a497
14 files changed, +1221 −31
  • internal/activator/activator.go +59 −1
  • internal/activator/beforeexec.go +31 −10
  • internal/activator/claimwake.go +55 −5
  • internal/activator/logsink.go +17 −0
  • internal/activator/swap.go +5 −2
  • internal/activator/wakecheck.go +132 −0
  • internal/activator/wakecheck_claim_linux_test.go +141 −0
  • internal/activator/wakecheck_test.go +404 −0
  • internal/activator/wakeidentity_test.go +9 −2
  • internal/host/agent.go +15 −0
  • internal/host/buildstate.go +112 −2
  • internal/host/buildstate_test.go +215 −1
  • internal/release/adopt.go +3 −8
  • internal/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