carlos / platform Public

settings-4: Astra round 2 fixes: the planner's recovery records; root's publication is fenced against a cut

Astra round 2 finding 1 (ruling R-PLANNED-RECOVERY): the activator carries
the planner's checked selection of the good build in place of another
(activator.PlannedSelection) on the context of that exec's record only: a
planned fallback after B failed, the good build booted untried, and a
recovery boot (step 2b, spawnPlanned, a claimed start). The pre-exec
revalidation (release.HeldExecStale) admits that build when the state read
under the lease holds exactly the replaced build as the held target; the
record is still written. The good build is not exempt in general.

Astra round 2 finding 2 (ruling R-CARRY-FENCE): root's consumed publication
and the cut's carry serialize on a per-host flock under the consumed
directory (Linux; a !linux stub). Root takes it, re-reads the route and
publishes only for a row no move holds (publishGate), else installs nothing
for it this pass. The agent's carry requires the row's move hold first, then
takes the fence (read-only fd) across the read and the write; no lock file
means no publication in flight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b56409e7f3b2c0e2aa1e74a8831371a271715828 parent bf596e1
16 files changed, +1090 −16
  • internal/activator/beforeexec.go +9 −1
  • internal/activator/claimwake.go +6 −0
  • internal/activator/plannedselection.go +73 −0
  • internal/activator/wakeplan.go +15 −7
  • internal/host/buildstate.go +31 −1
  • internal/host/buildstate_carry_test.go +5 −1
  • internal/host/buildstate_fence_linux_test.go +103 −0
  • internal/host/buildstate_planned_test.go +316 −0
  • internal/release/adopt.go +11 −4
  • internal/release/consumedfence.go +112 −0
  • internal/release/consumedfence_linux.go +75 −0
  • internal/release/consumedfence_linux_test.go +269 −0
  • internal/release/consumedfence_other.go +14 −0
  • internal/release/heldwake.go +17 −1
  • internal/release/heldwake_test.go +33 −0
  • internal/release/updatesim_test.go +1 −1
diff --git a/internal/activator/beforeexec.go b/internal/activator/beforeexec.go
index 1777dd292..5f6a6be18 100644
--- a/internal/activator/beforeexec.go
+++ b/internal/activator/beforeexec.go
@@ -152,7 +152,15 @@ func (a *Service) settleWakeIdentity(ctx context.Context, ib identityBackend, e
return id, pend, errStagedBuildNotPublished
}
}
- if err := a.recordBuild(ctx, rt.Host, id.Version); err != nil {
+ // A recovery plan's boot (the good build in place of the installed one
+ // the failure book blocks) is recorded with the planner's selection
+ // (ruling R-PLANNED-RECOVERY), so the agent's revalidation admits it
+ // before root's consumed file names the installed build failed.
+ rctx := ctx
+ if plan.planned() {
+ rctx = withRecoverySelection(ctx, plan, rt, id.Version)
+ }
+ if err := a.recordBuild(rctx, rt.Host, id.Version); err != nil {
return id, pend, err
}
return id, pend, nil
diff --git a/internal/activator/claimwake.go b/internal/activator/claimwake.go
index 2ad6aa3ea..81b6616ee 100644
--- a/internal/activator/claimwake.go
+++ b/internal/activator/claimwake.go
@@ -533,6 +533,12 @@ func (a *Service) startAdmitted(ctx context.Context, e *actEntry, admitted hostc
}
}
at := WithBuildStateLocation(ctx, BuildStateLocation{Bucket: r.Bucket, Prefix: admitted.Claim.Holder.Location})
+ // A planned recovery boot records with the planner's selection
+ // (ruling R-PLANNED-RECOVERY), as doWake's step 2b does.
+ a.mu.Lock()
+ plan, route := e.plan, e.route
+ a.mu.Unlock()
+ at = withRecoverySelection(at, plan, route, s.Staged.Version)
if err := a.recordBuild(at, e.host, s.Staged.Version); err != nil {
unrecorded = true
return err
diff --git a/internal/activator/plannedselection.go b/internal/activator/plannedselection.go
new file mode 100644
index 000000000..1ff9b515a
--- /dev/null
+++ b/internal/activator/plannedselection.go
@@ -0,0 +1,73 @@
+package activator
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// PlannedSelection is the planner's checked choice of a build in place of
+// another, for one exec (settings plan 4, ruling R-PLANNED-RECOVERY; zero-
+// downtime spec §8.2): Build is the good build the plan boots instead of
+// InPlaceOf. It is one of
+//
+// - a planned fallback: a first wake's B failed and was proven stopped,
+// and the good build starts in its place (spawnPlanned,
+// spawnPlannedWorker);
+// - the good build booted untried, when too little of B's health window
+// was left at its exec (spawnPlanned);
+// - a recovery boot: decideWake planned the good build in place of the
+// installed build, which the failure book blocks (settleWakeIdentity,
+// spawnPlanned, a claimed start).
+//
+// The activator puts it on the context of that exec's record only, after
+// the plan's selection gate (Check) passed for Build. The agent's pre-exec
+// revalidation (release.HeldExecStale) admits Build when the state read under
+// the lease holds InPlaceOf as the instance's target: the planner's reason
+// for leaving InPlaceOf is then the reason the held target is left, as
+// zero-downtime spec §6 and settings spec §6 require. The record is still
+// written. It never admits the good build in general: a context with no
+// selection, or one made in place of another build, is revalidated as
+// before.
+type PlannedSelection struct{ Build, InPlaceOf string }
+
+type plannedSelectionKey struct{}
+
+// WithPlannedSelection returns ctx carrying s. A selection with either part
+// empty reads back as none, so a caller can mask one it inherited.
+func WithPlannedSelection(ctx context.Context, s PlannedSelection) context.Context {
+ return context.WithValue(ctx, plannedSelectionKey{}, s)
+}
+
+// PlannedSelectionFrom is the selection ctx carries, if a whole one.
+func PlannedSelectionFrom(ctx context.Context) (PlannedSelection, bool) {
+ s, ok := ctx.Value(plannedSelectionKey{}).(PlannedSelection)
+ if !ok || s.Build == "" || s.InPlaceOf == "" {
+ return PlannedSelection{}, false
+ }
+ return s, true
+}
+
+// recoverySelection is the selection a recovery plan makes (decideWake's
+// recovery: the good build booted because the failure book blocks the
+// installed build, or the installed build is not staged correctly): plan
+// boots the route's good build, as no first wake, in place of the build root
+// installed (rt.Version, the planner's "installed"). A plan that boots the
+// installed build itself, the good build included, selects nothing in place
+// of anything, and its record is revalidated as any other.
+func recoverySelection(plan WakePlan, rt registry.Route) (PlannedSelection, bool) {
+ boot := plan.Boot.Version
+ if !plan.planned() || plan.FirstWake || boot == "" || boot != rt.GoodVersion || rt.Version == "" || rt.Version == boot {
+ return PlannedSelection{}, false
+ }
+ return PlannedSelection{Build: boot, InPlaceOf: rt.Version}, true
+}
+
+// withRecoverySelection is ctx carrying plan's recovery selection when plan
+// is a recovery and build is its boot; ctx itself otherwise.
+func withRecoverySelection(ctx context.Context, plan WakePlan, rt registry.Route, build string) context.Context {
+ if s, ok := recoverySelection(plan, rt); ok && s.Build == build {
+ return WithPlannedSelection(ctx, s)
+ }
+ return ctx
+}
diff --git a/internal/activator/wakeplan.go b/internal/activator/wakeplan.go
index fb035f276..a74ae5839 100644
--- a/internal/activator/wakeplan.go
+++ b/internal/activator/wakeplan.go
@@ -357,7 +357,11 @@ func (a *Service) spawnPlanned(ctx context.Context, e *actEntry, rt registry.Rou
healthBy = e.wakeStart.Add(firstWakeBudget - firstWakeKillReserve)
gate = verdictFloorGate(gate, healthBy, &left)
}
- proc, ver, g, err := a.startPinnedAt(ctx, rt, genOf(boot), gate, healthBy)
+ // Every exec here that runs the good build in another build's place is
+ // recorded with the planner's selection (ruling R-PLANNED-RECOVERY): a
+ // recovery boot (normally recorded at step 2b already), the good build
+ // booted untried and the fallback. Each exec's own context only.
+ proc, ver, g, err := a.startPinnedAt(withRecoverySelection(ctx, plan, rt, boot.Version), rt, genOf(boot), gate, healthBy)
w.bootRan = err == nil || startMayHaveRun(proc, err)
if proc == nil && errors.Is(err, errNoTimeForVerdict) {
// B never ran: too little of its window was left for a verdict at
@@ -369,7 +373,8 @@ func (a *Service) spawnPlanned(ctx context.Context, e *actEntry, rt registry.Rou
log.Printf("activator: %s: first wake of %s had %v of its health window left at the exec, under the %v floor; booting the good build %s without trying it",
rt.Host, boot.Version, left.Round(time.Millisecond), firstWakeMinVerdict, good.Version)
w.untried = true
- gproc, gver, gg, gerr := a.startPinnedAt(ctx, rt, genOf(good), a.planGate(ctx, e, lease, plan, good.Version), time.Time{})
+ gctx := WithPlannedSelection(ctx, PlannedSelection{Build: good.Version, InPlaceOf: boot.Version})
+ gproc, gver, gg, gerr := a.startPinnedAt(gctx, rt, genOf(good), a.planGate(ctx, e, lease, plan, good.Version), time.Time{})
if gerr != nil {
return gproc, gver, gg, fmt.Errorf("the good build %s, booted without trying %s: %w", good.Version, boot.Version, gerr)
}
@@ -397,7 +402,8 @@ func (a *Service) spawnPlanned(ctx context.Context, e *actEntry, rt registry.Rou
return proc, ver, g, fmt.Errorf("%w; its end was not proven, so the good build was not started: %v", err, kerr)
}
good := *plan.Fallback
- fproc, fver, fg, ferr := a.startPinnedAt(ctx, rt, genOf(good), a.planGate(ctx, e, lease, plan, good.Version), time.Time{})
+ fctx := WithPlannedSelection(ctx, PlannedSelection{Build: good.Version, InPlaceOf: boot.Version})
+ fproc, fver, fg, ferr := a.startPinnedAt(fctx, rt, genOf(good), a.planGate(ctx, e, lease, plan, good.Version), time.Time{})
if ferr != nil {
if fproc == nil && !genStartUnproven(ferr) {
// Nothing of the good build ran (its gate refused: a fence, a
@@ -457,8 +463,8 @@ func (a *Service) spawnPlannedWorker(ctx context.Context, e *actEntry, rt regist
if err != nil {
return nil, release.StagedIdentity{}, err
}
- start := func(id release.StagedIdentity) (Proc, release.StagedIdentity, error) {
- p, got, pend, err := a.startWorker(ctx, rt, env, &id, a.planGate(ctx, e, lease, plan, id.Version))
+ start := func(sctx context.Context, id release.StagedIdentity) (Proc, release.StagedIdentity, error) {
+ p, got, pend, err := a.startWorker(sctx, rt, env, &id, a.planGate(ctx, e, lease, plan, id.Version))
if err != nil {
return p, got, err
}
@@ -474,7 +480,7 @@ func (a *Service) spawnPlannedWorker(ctx context.Context, e *actEntry, rt regist
}
canFall := plan.FirstWake && plan.Fallback != nil && plan.Mode == ModeOverlap
boot := plan.Boot
- proc, id, err := start(boot)
+ proc, id, err := start(withRecoverySelection(ctx, plan, rt, boot.Version), boot)
w.bootRan = err == nil || startMayHaveRun(proc, err)
if err == nil || proc == nil {
// Ready, or nothing of the build ran: a refusal at the gate, an
@@ -496,7 +502,9 @@ func (a *Service) spawnPlannedWorker(ctx context.Context, e *actEntry, rt regist
if kerr := a.killFailed(rt, Gen{Build: id.Version}, proc, true); kerr != nil {
return proc, id, fmt.Errorf("%w; its end was not proven, so the good build was not started: %v", err, kerr)
}
- fproc, fid, ferr := start(*plan.Fallback)
+ // The fallback is recorded with the planner's selection (ruling
+ // R-PLANNED-RECOVERY), as spawnPlanned's is.
+ fproc, fid, ferr := start(WithPlannedSelection(ctx, PlannedSelection{Build: plan.Fallback.Version, InPlaceOf: boot.Version}), *plan.Fallback)
if ferr != nil {
if fproc == nil {
// Nothing of the good build ran: B's failure is still recorded,
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go
index 81dfa2e41..1634b42e5 100644
--- a/internal/host/buildstate.go
+++ b/internal/host/buildstate.go
@@ -504,6 +504,10 @@ func (b *buildStates) write(ctx context.Context, host string, merge func(cur *re
// cannot parse rather than overwrite it (ruling R-BS-CORRUPT). For a row
// settings apply to, a state that now names another held target than build
// (release.HeldExecStale) refuses it too: the wake decided on a stale copy.
+// The one exception is the zero-downtime planner's checked selection of
+// build in place of that target, which the activator carries on ctx for this
+// exec only (activator.PlannedSelection; ruling R-PLANNED-RECOVERY): it is
+// admitted, and still recorded.
func (b *buildStates) beforeExec(ctx context.Context, host, build string) error {
if build == "" {
return fmt.Errorf("no build to record for %s", host)
@@ -517,9 +521,13 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error
// refused here, before anything is written or run.
rt, _ := b.reg.Get(host)
held := rt != nil && settingsApply(*rt)
+ inPlaceOf := ""
+ if sel, ok := activator.PlannedSelectionFrom(ctx); ok && sel.Build == build {
+ inPlaceOf = sel.InPlaceOf
+ }
_, err = b.write(ctx, host, func(cur *release.BuildState) (release.BuildState, bool, error) {
if held {
- if err := release.HeldExecStale(release.DecideHeldWake(b.heldWakeInput(*rt, cur)), cur, build); err != nil {
+ if err := release.HeldExecStale(release.DecideHeldWake(b.heldWakeInput(*rt, cur)), cur, build, inPlaceOf); err != nil {
return release.BuildState{}, false, err
}
}
@@ -538,7 +546,29 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error
// host's lock and against the etag it read (write), so it never overwrites a
// newer record. A consumed file that does not read refuses the carry, and so
// the cut: a request this box closed would reopen on the destination.
+//
+// The carry runs inside the carry fence (release.FenceCarry; ruling
+// R-CARRY-FENCE): root's publication for host and this carry serialize on
+// one lock, and root publishes only for a row no move holds. So the row's
+// move hold must already be effective here (every lane sets it before its
+// carry: the legacy cut's "moving", the Phase 1 cut's held "moving", the
+// return lane's "releasing"); a row in no hold refuses the carry, as root
+// could still publish after it. A row gone from the registry gets no
+// publication either. Then the fence, held across the read and the write: a
+// root record in flight lands first and is carried.
func (b *buildStates) carryRequest(ctx context.Context, host, bucket, prefix string) error {
+ rt, err := b.reg.Get(host)
+ if err != nil {
+ return fmt.Errorf("read the route of %s: %w", host, err)
+ }
+ if rt != nil && !registry.IsHold(rt.Status) {
+ return fmt.Errorf("%s is in no move hold (status %q), so root could still publish a request record after the carry", host, rt.Status)
+ }
+ unlock, err := release.FenceCarry(ctx, b.optDir, host)
+ if err != nil {
+ return fmt.Errorf("the carry fence for %s: %w", host, err)
+ }
+ defer unlock()
consumed, err := release.ReadConsumed(b.optDir, host)
if err != nil {
return fmt.Errorf("read root's consumed file for %s: %w", host, err)
diff --git a/internal/host/buildstate_carry_test.go b/internal/host/buildstate_carry_test.go
index 282a7241f..f2b454d19 100644
--- a/internal/host/buildstate_carry_test.go
+++ b/internal/host/buildstate_carry_test.go
@@ -165,11 +165,15 @@ func TestTheReturnLaneCarriesTheRequest(t *testing.T) {
// given (release.CarryRequest), last_executed untouched, and writes only
// when something changes.
func TestCarryRequestWritesTheConsumedRecordAtTheLocation(t *testing.T) {
- b, s, _ := testBuildStates(t)
+ b, s, reg := testBuildStates(t)
ctx := context.Background()
if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "A"}); err != nil {
t.Fatal(err)
}
+ // The cut holds the row before it carries (ruling R-CARRY-FENCE).
+ if err := reg.SetStatus(astraHost, "moving"); err != nil {
+ t.Fatal(err)
+ }
key := release.BuildStateKey("inst/" + astraHost)
etag := mustEtag(t, s, key)
// No consumed file: nothing to carry, nothing written.
diff --git a/internal/host/buildstate_fence_linux_test.go b/internal/host/buildstate_fence_linux_test.go
new file mode 100644
index 000000000..1c51c2012
--- /dev/null
+++ b/internal/host/buildstate_fence_linux_test.go
@@ -0,0 +1,103 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "golang.org/x/sys/unix"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Settings plan 4, Astra round 2 finding 2 (ruling R-CARRY-FENCE): the
+// agent's carry (buildStates.carryRequest) takes the carry fence only once
+// the row's move hold is effective, and holds it across the carry, so a root
+// publication in flight lands before the carry reads the file and is
+// carried.
+
+// rootHoldsFence takes root's side of host's fence as root's publication
+// does (the lock file created, flock exclusive) and returns its release.
+func rootHoldsFence(t *testing.T, optDir, host string) func() {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Join(optDir, "consumed"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.OpenFile(release.ConsumedLockPath(optDir, host), os.O_RDWR|os.O_CREATE, 0o644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil {
+ t.Fatal(err)
+ }
+ return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN); _ = f.Close() }
+}
+
+// Root holds the fence and is about to publish R1 completed, failed; the
+// cut, its row held moving, asks for the carry: it waits for root, then
+// carries the record root published.
+func TestTheCarryWaitsForRootsPublicationAndCarriesIt(t *testing.T) {
+ b, s, reg := testBuildStates(t)
+ ctx := context.Background()
+ if err := put(t, b, ctx, astraHost, release.BuildState{LastExecuted: "A", Resolved: &release.ResolvedRequest{ID: 1, Target: "T"}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetStatus(astraHost, "moving"); err != nil {
+ t.Fatal(err)
+ }
+ unlock := rootHoldsFence(t, b.optDir, astraHost)
+ done := make(chan error, 1)
+ go func() { done <- b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost) }()
+ select {
+ case err := <-done:
+ unlock()
+ t.Fatalf("the carry ran while root held the fence: %v", err)
+ case <-time.After(200 * time.Millisecond):
+ }
+ writeConsumed(t, b, release.Consumed{Resolved: &release.ResolvedRequest{ID: 1, Target: "T"}, Completed: 1, CompletedFailed: true})
+ unlock()
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("the carry never took the fence")
+ }
+ st, _, err := getState(ctx, s, release.BuildStateKey("inst/"+astraHost), astraHost)
+ if err != nil || st == nil || st.Completed != 1 || !st.CompletedFailed || st.LastExecuted != "A" {
+ t.Fatalf("state %+v, %v; want R1 completed failed carried, A untouched", st, err)
+ }
+}
+
+// A carry for a row the move does not hold is refused: root could still
+// publish after it. Nothing is written.
+func TestTheCarryRefusesARowNoMoveHolds(t *testing.T) {
+ b, s, _ := testBuildStates(t)
+ ctx := context.Background()
+ writeConsumed(t, b, release.Consumed{Resolved: &release.ResolvedRequest{ID: 1, Target: "T"}, Completed: 1, CompletedFailed: true})
+ if err := b.carryRequest(ctx, astraHost, "hib", "inst/"+astraHost); err == nil {
+ t.Fatal("a carry for a row no move holds went ahead")
+ }
+ if _, _, err := s.Get(ctx, release.BuildStateKey("inst/"+astraHost)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the refused carry wrote: %v", err)
+ }
+}
+
+// No consumed directory: no record to carry and no lock to take; the agent
+// creates nothing there.
+func TestTheCarryWithNoConsumedDirectoryTakesNoLock(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ if err := reg.SetStatus(astraHost, "moving"); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.carryRequest(context.Background(), astraHost, "hib", "inst/"+astraHost); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(b.optDir, "consumed")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the carry created the consumed directory: %v", err)
+ }
+}
diff --git a/internal/host/buildstate_planned_test.go b/internal/host/buildstate_planned_test.go
new file mode 100644
index 000000000..38eec7b52
--- /dev/null
+++ b/internal/host/buildstate_planned_test.go
@@ -0,0 +1,316 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Settings plan 4, Astra round 2 finding 1 (ruling R-PLANNED-RECOVERY): the
+// pre-exec write's revalidation admits the planner's checked recovery or
+// fallback onto the good build, for that transition only, and still writes
+// the record. These run a real activator wake (ServeHTTP, the planner, the
+// held wake check and every exec through startPinnedAt and step 2b) with the
+// real build-state hook (buildStates.beforeExec) and heldTarget.
+
+// plannedProc is a fake process: Stop ends it.
+type plannedProc struct{ exited atomic.Bool }
+
+func (p *plannedProc) Stop(context.Context) error { p.exited.Store(true); return nil }
+func (p *plannedProc) Exited() bool { return p.exited.Load() }
+
+type plannedLease struct{}
+
+func (plannedLease) Replicate(context.Context, string, time.Duration) (activator.Rep, error) {
+ return plannedRep{}, nil
+}
+func (plannedLease) Release(context.Context) error { return nil }
+
+type plannedRep struct{}
+
+func (plannedRep) Err() error { return nil }
+func (plannedRep) Close(context.Context) error { return nil }
+
+// plannedBackend is the smallest exec backend a planned wake runs on: a
+// lease and restore that always succeed, staged generations (a build in
+// fail exits during its health check, with its process; any other answers
+// HTTP 200 on a real socket), a generation kill, the live link's version,
+// and the executable identity step 2b records.
+type plannedBackend struct {
+ socket string
+ live string
+ fail map[string]bool
+
+ mu sync.Mutex
+ started []string
+ killed []uint64
+ procs map[uint64]*plannedProc
+}
+
+func (b *plannedBackend) Acquire(context.Context, registry.Route) (activator.Lease, error) {
+ return plannedLease{}, nil
+}
+func (b *plannedBackend) Restore(context.Context, registry.Route) (bool, error) { return false, nil }
+func (b *plannedBackend) Seed(context.Context, registry.Route) error { return nil }
+func (b *plannedBackend) Start(context.Context, registry.Route) (activator.Proc, string, error) {
+ return nil, "", errors.New("a planned wake never starts the live link")
+}
+func (b *plannedBackend) NextDue(context.Context, registry.Route) (int64, error) { return 0, nil }
+func (b *plannedBackend) HasSidecar(registry.Route) bool { return false }
+func (b *plannedBackend) StartSidecar(context.Context, registry.Route) (activator.Proc, error) {
+ return nil, errors.New("no sidecar")
+}
+func (b *plannedBackend) StartWorker(context.Context, registry.Route, map[string]string) (activator.Proc, string, error) {
+ return nil, "", errors.New("no worker")
+}
+
+func (b *plannedBackend) StartStaged(_ context.Context, _ registry.Route, g activator.Gen) (activator.Gen, activator.Proc, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.started = append(b.started, g.Build)
+ p := &plannedProc{}
+ if b.procs == nil {
+ b.procs = map[uint64]*plannedProc{}
+ }
+ b.procs[g.N] = p
+ if b.fail[g.Build] {
+ return g, p, errors.New("the process exited during its health check")
+ }
+ g.Socket = b.socket
+ return g, p, nil
+}
+func (b *plannedBackend) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault }
+func (b *plannedBackend) GenReady(registry.Route, activator.Gen, activator.Proc) {}
+func (b *plannedBackend) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error {
+ return nil
+}
+func (b *plannedBackend) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error {
+ return nil
+}
+func (b *plannedBackend) GenSwitched(registry.Route, activator.Gen, activator.Proc) error { return nil }
+func (b *plannedBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.killed = append(b.killed, n)
+ if p := b.procs[n]; p != nil {
+ p.exited.Store(true)
+ }
+ return nil
+}
+func (b *plannedBackend) LiveVersion(string) (string, error) { return b.live, nil }
+func (b *plannedBackend) LiveIdentity(context.Context, registry.Route) (release.StagedIdentity, error) {
+ return release.StagedIdentity{}, errors.New("a planned wake never captures the live link")
+}
+func (b *plannedBackend) StartGenExe(context.Context, registry.Route, uint64, bool, release.StagedIdentity) (activator.Proc, activator.Gen, error) {
+ return nil, activator.Gen{}, errors.New("a planned wake never starts the live link")
+}
+func (b *plannedBackend) SeedExe(context.Context, registry.Route, release.StagedIdentity) error {
+ return nil
+}
+
+func (b *plannedBackend) builds() []string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return append([]string(nil), b.started...)
+}
+
+// healthySocket serves HTTP 200 on a unix socket under a short directory
+// (a socket path is bounded).
+func healthySocket(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "pr")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ path := filepath.Join(dir, "s")
+ l, err := net.Listen("unix", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })}
+ go srv.Serve(l) //nolint:errcheck
+ t.Cleanup(func() { srv.Close() })
+ return path
+}
+
+// plannedStaged writes a staged build and returns its identity (the
+// activator verifies the bytes before every staged exec).
+func plannedStaged(t *testing.T, dir, ver string) release.StagedIdentity {
+ t.Helper()
+ body := []byte("build " + ver)
+ path := filepath.Join(dir, ver)
+ if err := os.WriteFile(path, body, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(body)
+ return release.StagedIdentity{Version: ver, StagedPath: path, Digest: hex.EncodeToString(sum[:])}
+}
+
+// plannedWake is a manual instance with good build A (GoodVersion), a
+// planner that answers plan, and the real build-state hook and held wake
+// check over b's store and registry. The activator leases at the hibernate
+// prefix of bucket "hib", which b reads there too.
+func plannedWake(t *testing.T, live string, fail map[string]bool, plan func(good, t release.StagedIdentity) activator.WakePlan) (*activator.Service, *plannedBackend, *buildStates, release.Store) {
+ t.Helper()
+ b, s, reg := testBuildStates(t)
+ b.prefix = func(h string) string { return activator.HibernatePrefix("", h) }
+ dir := t.TempDir()
+ good, next := plannedStaged(t, dir, "A"), plannedStaged(t, dir, "T")
+ if err := reg.SetGoodBuild(astraHost, registry.GoodBuild{}, registry.GoodBuild{Version: "A", Digest: good.Digest}); err != nil {
+ t.Fatal(err)
+ }
+ // Root's install stamp names the live build, as root's repoint leaves it.
+ if err := reg.SetVersions(astraHost, live, ""); err != nil {
+ t.Fatal(err)
+ }
+ setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 1})
+ be := &plannedBackend{socket: healthySocket(t), live: live, fail: fail}
+ svc := activator.New(activator.Config{Bucket: "hib", Idle: time.Hour}, be, reg)
+ svc.Planner = func(context.Context, registry.Route) activator.WakePlan { return plan(good, next) }
+ svc.OnGen = func(activator.GenEvent) {}
+ svc.BeforeExec = b.beforeExec
+ svc.HeldTarget = b.heldTarget
+ t.Cleanup(func() { svc.Shutdown(context.Background()) })
+ return svc, be, b, s
+}
+
+// serve sends one request through the activator: it wakes the instance.
+func serve(t *testing.T, svc *activator.Service, reg *registry.Registry) *httptest.ResponseRecorder {
+ t.Helper()
+ rt, err := reg.Get(astraHost)
+ if err != nil || rt == nil {
+ t.Fatalf("route: %v", err)
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "http://"+astraHost+"/", nil)
+ proxy := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("served")) }) //nolint:errcheck
+ if !svc.ServeHTTP(rec, req, *rt, proxy) {
+ t.Fatal("the activator did not take the request")
+ }
+ return rec
+}
+
+func durable(t *testing.T, b *buildStates, s release.Store) *release.BuildState {
+ t.Helper()
+ st, _, err := getState(context.Background(), s, release.BuildStateKey(astraHost), astraHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return st
+}
+
+// The overlap wake's fallback (Astra round 2 finding 1's scenario): a manual
+// instance with good build A and Update now resolved to T. Its first wake
+// records T (completing R1) and T fails its health check. The planner's
+// fallback A must record and run, though root has not reported T failed and
+// the durable state's last_executed is T.
+func TestTheOverlapFallbackAfterTFailsIsRecordedAndRuns(t *testing.T) {
+ svc, be, b, s := plannedWake(t, "T", map[string]bool{"T": true}, func(good, next release.StagedIdentity) activator.WakePlan {
+ return activator.WakePlan{Boot: next, Mode: activator.ModeOverlap, FirstWake: true, Fallback: &good}
+ })
+ if err := put(t, b, context.Background(), astraHost, release.BuildState{LastExecuted: "A"}); err != nil {
+ t.Fatal(err)
+ }
+ setCopy(t, b.reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "A"}})
+ decided(t, b, release.Consumed{Resolved: &release.ResolvedRequest{ID: 1, Target: "T"}, Reason: string(release.ReasonRequest), Desired: "T", Detail: "T"})
+
+ rec := serve(t, svc, b.reg)
+ if rec.Code != http.StatusOK || rec.Body.String() != "served" {
+ t.Fatalf("the wake was not served by the fallback: %d %q; generations %v", rec.Code, rec.Body.String(), be.builds())
+ }
+ if got := be.builds(); len(got) != 2 || got[0] != "T" || got[1] != "A" {
+ t.Fatalf("generations %v, want T then the good build A", got)
+ }
+ st := durable(t, b, s)
+ if st == nil || st.LastExecuted != "A" || st.Completed != 1 {
+ t.Fatalf("durable state %+v; want A recorded before it ran, R1 completed by T's run", st)
+ }
+}
+
+// A planned recovery boot onto the good build (decideWake's recovery: the
+// failure book blocks T) before root's consumed file names T failed: the
+// durable state's last_executed is T, and the planner chose A in T's place.
+// The record admits A for that transition and the wake runs it.
+func TestAPlannedRecoveryBeforeRootRecordsTheFailureRuns(t *testing.T) {
+ svc, be, b, s := plannedWake(t, "T", nil, func(good, _ release.StagedIdentity) activator.WakePlan {
+ return activator.WakePlan{Boot: good, Mode: activator.ModeOverlap}
+ })
+ tState := release.BuildState{LastExecuted: "T", Resolved: &release.ResolvedRequest{ID: 1, Target: "T"}, Completed: 1}
+ if err := put(t, b, context.Background(), astraHost, tState); err != nil {
+ t.Fatal(err)
+ }
+ setCopy(t, b.reg, release.BuildStateCopy{Known: true, Replica: true, State: &tState})
+ decided(t, b, release.Consumed{Resolved: &release.ResolvedRequest{ID: 1, Target: "T"}, Reason: string(release.ReasonRequest), Desired: "T", Detail: "T"})
+
+ rec := serve(t, svc, b.reg)
+ if rec.Code != http.StatusOK || rec.Body.String() != "served" {
+ t.Fatalf("the recovery boot was not served: %d %q; generations %v", rec.Code, rec.Body.String(), be.builds())
+ }
+ if got := be.builds(); len(got) != 1 || got[0] != "A" {
+ t.Fatalf("generations %v, want the good build A alone", got)
+ }
+ if st := durable(t, b, s); st == nil || st.LastExecuted != "A" || st.Completed != 1 {
+ t.Fatalf("durable state %+v; want A recorded, R1 still completed", st)
+ }
+}
+
+// The negative (GoodVersion is not exempt in general; Astra round 1 finding
+// 1): the wake decided on a stale copy that names the good build A, which is
+// installed, so the planner plans A as an ordinary wake, not in place of
+// anything. The durable state says the data last ran T. With no planner
+// selection the record refuses A and nothing runs. The same hook, called
+// directly with no selection, or with a selection for another transition,
+// refuses A too.
+func TestTheGoodBuildWithoutThePlannersSelectionIsRefused(t *testing.T) {
+ svc, be, b, s := plannedWake(t, "A", nil, func(good, _ release.StagedIdentity) activator.WakePlan {
+ return activator.WakePlan{Boot: good, Mode: activator.ModeOverlap}
+ })
+ if err := put(t, b, context.Background(), astraHost, release.BuildState{LastExecuted: "T"}); err != nil {
+ t.Fatal(err)
+ }
+ setCopy(t, b.reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "A"}})
+ decided(t, b, release.Consumed{Reason: string(release.ReasonManual), Desired: "A", Detail: "A"})
+
+ rec := serve(t, svc, b.reg)
+ if rec.Code == http.StatusOK {
+ t.Fatalf("the good build ran over T's data with no planner selection: %q; generations %v", rec.Body.String(), be.builds())
+ }
+ if got := be.builds(); len(got) != 0 {
+ t.Fatalf("generations %v, want none", got)
+ }
+ if st := durable(t, b, s); st == nil || st.LastExecuted != "T" {
+ t.Fatalf("durable state %+v; want T untouched", st)
+ }
+ ctx := context.Background()
+ if err := b.beforeExec(ctx, astraHost, "A"); err == nil || !strings.Contains(err.Error(), "T") {
+ t.Fatalf("beforeExec(A) with no selection = %v; want a stale refusal naming T", err)
+ }
+ other := activator.WithPlannedSelection(ctx, activator.PlannedSelection{Build: "A", InPlaceOf: "B"})
+ if err := b.beforeExec(other, astraHost, "A"); err == nil {
+ t.Fatal("beforeExec(A) with a selection made in place of another build was admitted")
+ }
+ wrong := activator.WithPlannedSelection(ctx, activator.PlannedSelection{Build: "C", InPlaceOf: "T"})
+ if err := b.beforeExec(wrong, astraHost, "A"); err == nil {
+ t.Fatal("beforeExec(A) with a selection of another build was admitted")
+ }
+ if st := durable(t, b, s); st == nil || st.LastExecuted != "T" {
+ t.Fatalf("durable state %+v; want T untouched", st)
+ }
+}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 09993da94..6c7d515a7 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -114,6 +114,9 @@ type passConfig struct {
managed func(registry.Route) bool
failed FailedBuild
failedErr error
+ // route reads one registry row now, for root's re-check under the carry
+ // fence (publishConsumed, ruling R-CARRY-FENCE); PassFilter sets it.
+ route func(host string) (*registry.Route, error)
// pub verifies promotion records (§4.1 evidence); PassFilter sets it.
pub ed25519.PublicKey
// tags is "<acct>/<app>" -> release version -> its newest semver tag, read
@@ -424,6 +427,7 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
opt(&cfg)
}
cfg.pub = pub
+ cfg.route = reg.Get
// Settings spec §4.3: the failure book is read fresh for every pass, so a
// record the agent persisted since the last one counts now.
if cfg.settings && cfg.openFailed != nil {
@@ -1217,7 +1221,7 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
var dec TargetDecision
if settings {
var stop bool
- ver, dec, stop = settingsTarget(optDir, bake, rt, ptr, cur, now, cfg, holds, ignore)
+ ver, dec, stop = settingsTarget(ctx, optDir, bake, rt, ptr, cur, now, cfg, holds, ignore)
if stop {
return
}
@@ -2157,7 +2161,7 @@ func backfillManifestDigest(ctx context.Context, s Store, optDir, acct, app, ver
// the head, with bake, on the pointer's evidence, and no book, no Target and
// no consumed write. Only an unreadable failure book on a managed route
// changes that (the zero-downtime plans' refuseManaged posture).
-func settingsTarget(optDir string, bake *FirstSeen, rt registry.Route, ptr *ChannelPointer, cur string, now time.Time,
+func settingsTarget(ctx context.Context, optDir string, bake *FirstSeen, rt registry.Route, ptr *ChannelPointer, cur string, now time.Time,
cfg passConfig, holds map[string]time.Duration, ignore bool) (string, TargetDecision, bool) {
stop := func(dec TargetDecision) (string, TargetDecision, bool) {
cfg.proof.clear(rt.Host)
@@ -2190,7 +2194,7 @@ func settingsTarget(optDir string, bake *FirstSeen, rt registry.Route, ptr *Chan
if cfg.resolve {
fmt.Printf(" %s: update settings unreadable (%v): held, nothing installed\n", rt.Host, uerr)
in := TargetInput{Route: rt, Pointer: ptr, Consumed: consumed, Installed: cur}
- if err := WriteConsumed(optDir, NextConsumed(in, dec)); err != nil {
+ if err := publishConsumed(ctx, optDir, cfg.route, NextConsumed(in, dec)); err != nil {
fmt.Printf(" %s: recording the update decision: %v\n", rt.Host, err)
}
}
@@ -2219,7 +2223,10 @@ func settingsTarget(optDir string, bake *FirstSeen, rt registry.Route, ptr *Chan
}
dec := Target(in)
if cfg.resolve {
- if err := WriteConsumed(optDir, NextConsumed(in, dec)); err != nil {
+ // Under the carry fence (ruling R-CARRY-FENCE): a route a move has
+ // held since this pass listed it gets no record, and nothing is
+ // installed for it on this pass.
+ if err := publishConsumed(ctx, optDir, cfg.route, NextConsumed(in, dec)); err != nil {
fmt.Printf(" %s: recording the update decision: %v; nothing installed\n", rt.Host, err)
return stop(dec)
}
diff --git a/internal/release/consumedfence.go b/internal/release/consumedfence.go
new file mode 100644
index 000000000..16068845b
--- /dev/null
+++ b/internal/release/consumedfence.go
@@ -0,0 +1,112 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The carry fence (settings plan 4, ruling R-CARRY-FENCE; Astra round 2
+// finding 2). Root publishes its request record for a host in
+// /opt/carlos/consumed/<host> (WriteConsumed); before a graceful move seals
+// the build state, the agent carries that record into it (CarryRequest).
+// Root's pass decides from a registry snapshot taken before the pass, so
+// without a fence a pass already running when a cut begins could publish a
+// resolution or a failed completion after the carry read the file: the cut
+// seals a state without it, and the destination resolves the request again.
+//
+// The two serialize on one cross-process lock per host, flock on
+// ConsumedLockPath. Root takes it, re-reads the route and publishes only
+// when the route is in no move hold (publishGate), then releases. The cut
+// takes it only once its move hold is effective (the row's status is a hold:
+// registry.IsHold), carries, then releases. So a root publication lands
+// before the carry and is carried, or sees the hold and is not made at all
+// (nothing is installed for that route on that pass). Neither side waits on
+// the other for more than one record write or one build-state write.
+
+// consumedFenceWait bounds a wait for the carry fence, on either side. A
+// var so tests can shrink it.
+var consumedFenceWait = 30 * time.Second
+
+// ErrMoveHeld is root's refusal to publish a request record for a route a
+// move holds (publishGate): nothing is written and nothing installed for it
+// on that pass.
+var ErrMoveHeld = errors.New("the route is held by a move, so root publishes no request record for it")
+
+// ConsumedLockPath is host's carry-fence lock file, beside its consumed file.
+// The leading dot is no host's shape, so it never reads as a consumed file.
+// The caller validates host (controlname.ValidHostShape).
+func ConsumedLockPath(optDir, host string) string {
+ return filepath.Join(optDir, "consumed", "."+host+".lock")
+}
+
+// publishGate is root's re-check inside the carry fence: nil when root may
+// publish a request record for rt, the route as the registry holds it now.
+// A row a move holds (any of registry.HoldStatuses: the source moving, a
+// rollback's restoring or releasing, a landing) or a row gone from this box
+// is refused with ErrMoveHeld. The update simulation calls it too.
+func publishGate(rt *registry.Route) error {
+ if rt == nil {
+ return fmt.Errorf("%w: the row is gone from this box", ErrMoveHeld)
+ }
+ if registry.IsHold(rt.Status) {
+ return fmt.Errorf("%w: its status is %q", ErrMoveHeld, rt.Status)
+ }
+ return nil
+}
+
+// beforeConsumedWrite, when set (tests only), runs inside root's fenced
+// publication: at "fence" before the lock is taken, and at "write" with the
+// lock held and the re-check passed, immediately before WriteConsumed.
+var beforeConsumedWrite func(host, at string)
+
+// publishConsumed is root's publication of c under the carry fence: the
+// lock, the route re-read and re-checked (publishGate), then WriteConsumed.
+// route reads the registry row now. A refusal (ErrMoveHeld), a fence that
+// could not be taken, or a row that does not read writes nothing.
+func publishConsumed(ctx context.Context, optDir string, route func(host string) (*registry.Route, error), c Consumed) error {
+ if !controlname.ValidHostShape(c.Host) {
+ return fmt.Errorf("consumed: invalid host %q", c.Host)
+ }
+ if hook := beforeConsumedWrite; hook != nil {
+ hook(c.Host, "fence")
+ }
+ unlock, err := lockConsumedPublish(ctx, optDir, c.Host)
+ if err != nil {
+ return fmt.Errorf("the carry fence: %w", err)
+ }
+ defer unlock()
+ if route == nil {
+ return errors.New("no registry to re-check the route under the carry fence")
+ }
+ rt, err := route(c.Host)
+ if err != nil {
+ return fmt.Errorf("re-read the route under the carry fence: %w", err)
+ }
+ if err := publishGate(rt); err != nil {
+ return err
+ }
+ if hook := beforeConsumedWrite; hook != nil {
+ hook(c.Host, "write")
+ }
+ return WriteConsumed(optDir, c)
+}
+
+// FenceCarry is the cut's side of the carry fence for host, taken once the
+// move hold on its row is effective and held across the carry: unlock
+// releases it. A host whose lock file does not exist (root has never
+// published under the fence for it, or the consumed directory does not
+// exist) is carried without the lock: any later publication takes the lock
+// first and then sees the hold. A lock file that does not open, or a fence
+// not taken within its bound, is an error, and the cut does not carry.
+func FenceCarry(ctx context.Context, optDir, host string) (unlock func(), err error) {
+ if !controlname.ValidHostShape(host) {
+ return nil, fmt.Errorf("consumed: invalid host %q", host)
+ }
+ return lockConsumedCarry(ctx, optDir, host)
+}
diff --git a/internal/release/consumedfence_linux.go b/internal/release/consumedfence_linux.go
new file mode 100644
index 000000000..06daec246
--- /dev/null
+++ b/internal/release/consumedfence_linux.go
@@ -0,0 +1,75 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+// fencePoll is how often a wait for the carry fence asks again.
+const fencePoll = 20 * time.Millisecond
+
+// lockConsumedPublish is root's side of the carry fence for host: the lock
+// file is created if it is missing (root owns the consumed directory), and
+// held exclusively until unlock.
+func lockConsumedPublish(ctx context.Context, optDir, host string) (func(), error) {
+ if err := os.MkdirAll(filepath.Join(optDir, "consumed"), 0o755); err != nil {
+ return nil, err
+ }
+ f, err := os.OpenFile(ConsumedLockPath(optDir, host), os.O_RDWR|os.O_CREATE, 0o644)
+ if err != nil {
+ return nil, err
+ }
+ return flockWait(ctx, f)
+}
+
+// lockConsumedCarry is the cut's side for host. The agent may only be able
+// to read the consumed directory; flock takes an exclusive lock on a
+// read-only descriptor all the same. A lock file that does not exist is no
+// lock to take (FenceCarry).
+func lockConsumedCarry(ctx context.Context, optDir, host string) (func(), error) {
+ f, err := os.Open(ConsumedLockPath(optDir, host))
+ if errors.Is(err, os.ErrNotExist) {
+ return func() {}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return flockWait(ctx, f)
+}
+
+// flockWait takes an exclusive flock on f, asking again every fencePoll
+// until ctx ends or consumedFenceWait passes. f is closed on failure, and by
+// the unlock it returns.
+func flockWait(ctx context.Context, f *os.File) (func(), error) {
+ fd := int(f.Fd())
+ deadline := time.Now().Add(consumedFenceWait)
+ for {
+ err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB)
+ if err == nil {
+ return func() {
+ _ = unix.Flock(fd, unix.LOCK_UN)
+ _ = f.Close()
+ }, nil
+ }
+ if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EINTR) {
+ _ = f.Close()
+ return nil, fmt.Errorf("lock %s: %w", f.Name(), err)
+ }
+ if !time.Now().Before(deadline) {
+ _ = f.Close()
+ return nil, fmt.Errorf("lock %s: still held after %s", f.Name(), consumedFenceWait)
+ }
+ select {
+ case <-ctx.Done():
+ _ = f.Close()
+ return nil, fmt.Errorf("lock %s: %w", f.Name(), ctx.Err())
+ case <-time.After(fencePoll):
+ }
+ }
+}
diff --git a/internal/release/consumedfence_linux_test.go b/internal/release/consumedfence_linux_test.go
new file mode 100644
index 000000000..2a755f2c8
--- /dev/null
+++ b/internal/release/consumedfence_linux_test.go
@@ -0,0 +1,269 @@
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Settings plan 4, Astra round 2 finding 2 (ruling R-CARRY-FENCE): a root
+// pass already running when a graceful cut begins publishes its request
+// record before the cut's carry, and is carried, or not at all. A late
+// record is never left behind on the source box.
+//
+// The root pass is the real one (Pass with the update settings, root's
+// resolve). It is paused by the test hook at one of the two points of its
+// fenced publication. The cut's half is the agent's carry as
+// buildStates.carryRequest runs it: the row held moving first, then the
+// fence (FenceCarry), the consumed file read and merged (CarryRequest), the
+// fence released.
+
+const fenceHost = "a.example.com"
+
+// fenceCutResult is what the cut sealed: the build state after its carry.
+type fenceCutResult struct {
+ state BuildState
+ err error
+}
+
+// fenceCut runs the cut's half: marked is closed once the row is held.
+func fenceCut(reg *registry.Registry, opt string, state BuildState, marked chan<- struct{}) fenceCutResult {
+ if err := reg.SetStatus(fenceHost, "moving"); err != nil {
+ return fenceCutResult{err: err}
+ }
+ close(marked)
+ unlock, err := FenceCarry(context.Background(), opt, fenceHost)
+ if err != nil {
+ return fenceCutResult{err: err}
+ }
+ defer unlock()
+ c, err := ReadConsumed(opt, fenceHost)
+ if err != nil {
+ return fenceCutResult{err: err}
+ }
+ return fenceCutResult{state: CarryRequest(&state, c)}
+}
+
+// fenceScenario is one root pass over one route: setup prepares the store,
+// registry and consumed file and returns the build state the cut would
+// seal and the pass options.
+type fenceScenario struct {
+ name string
+ setup func(t *testing.T, s Store, priv ed25519.PrivateKey, opt string, reg *registry.Registry) (BuildState, []PassOption)
+ // published reports whether the consumed file holds the pass's record.
+ published func(c *Consumed) bool
+}
+
+func fenceScenarios() []fenceScenario {
+ ctx := context.Background()
+ return []fenceScenario{
+ {
+ // Update now, resolved by the pass to the head before staging.
+ name: "a resolution",
+ setup: func(t *testing.T, s Store, priv ed25519.PrivateKey, opt string, reg *registry.Registry) (BuildState, []PassOption) {
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ st := BuildState{LastExecuted: "v0"}
+ settingsRoute(t, reg, fenceHost, "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1},
+ BuildStateCopy{Known: true, Replica: true, State: &st})
+ return st, nil
+ },
+ published: func(c *Consumed) bool { return c != nil && c.Resolved != nil && c.Resolved.ID == 1 },
+ },
+ {
+ // R1 resolved to v2, which the failure book holds: the pass
+ // records R1 completed, failed.
+ name: "a failed completion",
+ setup: func(t *testing.T, s Store, priv ed25519.PrivateKey, opt string, reg *registry.Registry) (BuildState, []PassOption) {
+ for _, v := range []string{"v1", "v2"} {
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ st := BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 1, Target: "v2"}}
+ settingsRoute(t, reg, fenceHost, "km", "canary/x", UpdateSettings{Updates: UpdatesManual, Channel: "canary/x", InstallRequest: 1},
+ BuildStateCopy{Known: true, Replica: true, State: &st})
+ if err := WriteConsumed(opt, Consumed{Host: fenceHost, Resolved: &ResolvedRequest{ID: 1, Target: "v2"}}); err != nil {
+ t.Fatal(err)
+ }
+ book := func() (FailedBuild, error) {
+ return func(_, _, build, _ string, _ bool) bool { return build == "v2" }, nil
+ }
+ return st, []PassOption{WithUpdateSettings(book, func(registry.Route) bool { return false }, true)}
+ },
+ published: func(c *Consumed) bool { return c != nil && c.Completed == 1 && c.CompletedFailed },
+ },
+ }
+}
+
+func withFenceHook(t *testing.T, hook func(host, at string)) {
+ t.Helper()
+ old := beforeConsumedWrite
+ beforeConsumedWrite = hook
+ t.Cleanup(func() { beforeConsumedWrite = old })
+}
+
+// leftBehind reports whether the source's consumed file holds request state
+// the sealed build state lacks: carrying it again would change the state.
+func leftBehind(t *testing.T, opt string, sealed BuildState) bool {
+ t.Helper()
+ c, err := ReadConsumed(opt, fenceHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ again := CarryRequest(&sealed, c)
+ return !SameRequestState(&sealed, &again)
+}
+
+// A cut that holds the row and carries while root's pass is paused before
+// its fence: root then takes the fence, sees the hold, publishes nothing and
+// installs nothing. The record is never published, so nothing is left behind.
+func TestARootPassPausedBeforeTheFenceDoesNotPublishOverACut(t *testing.T) {
+ for _, sc := range fenceScenarios() {
+ t.Run(sc.name, func(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ state, opts := sc.setup(t, s, priv, opt, reg)
+ before, _ := ReadConsumed(opt, fenceHost)
+ var cut fenceCutResult
+ withFenceHook(t, func(host, at string) {
+ if at == "fence" {
+ cut = fenceCut(reg, opt, state, make(chan struct{}))
+ }
+ })
+ passWithSettings(t, reg, s, pub, opt, opts...)
+ if cut.err != nil {
+ t.Fatalf("the cut: %v", cut.err)
+ }
+ after, _ := ReadConsumed(opt, fenceHost)
+ if sc.published(after) && !sc.published(before) {
+ t.Fatalf("root published %+v after the cut held the row and carried", after)
+ }
+ if leftBehind(t, opt, cut.state) {
+ t.Fatalf("a record was left behind: consumed %+v, sealed %+v", after, cut.state)
+ }
+ if v, _ := LiveVersion(opt, fenceHost); v != "" {
+ t.Fatalf("live %q: root installed for a route the cut holds", v)
+ }
+ })
+ }
+}
+
+// A root pass paused immediately before WriteConsumed, its fence held and
+// its re-check passed, while a cut holds the row and asks for the fence:
+// the cut waits, root's record lands, and the cut carries it.
+func TestARootPassPausedBeforeItsWriteIsCarriedByTheCut(t *testing.T) {
+ for _, sc := range fenceScenarios() {
+ t.Run(sc.name, func(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ state, opts := sc.setup(t, s, priv, opt, reg)
+ done := make(chan fenceCutResult, 1)
+ early := false
+ withFenceHook(t, func(host, at string) {
+ if at != "write" {
+ return
+ }
+ marked := make(chan struct{})
+ go func() { done <- fenceCut(reg, opt, state, marked) }()
+ <-marked
+ select {
+ case <-done:
+ early = true
+ case <-time.After(200 * time.Millisecond):
+ }
+ })
+ passWithSettings(t, reg, s, pub, opt, opts...)
+ if early {
+ t.Fatal("the cut carried while root held the fence")
+ }
+ var cut fenceCutResult
+ select {
+ case cut = <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("the cut never took the fence")
+ }
+ if cut.err != nil {
+ t.Fatalf("the cut: %v", cut.err)
+ }
+ after, _ := ReadConsumed(opt, fenceHost)
+ if !sc.published(after) {
+ t.Fatalf("consumed %+v: root's record did not land", after)
+ }
+ if leftBehind(t, opt, cut.state) {
+ t.Fatalf("a record was left behind: consumed %+v, sealed %+v", after, cut.state)
+ }
+ })
+ }
+}
+
+// With no move in progress the fenced pass is the pass it was: the hook runs
+// at both points and the record lands as without it.
+func TestARootPassWithNoMoveIsUnchangedByTheFence(t *testing.T) {
+ for _, sc := range fenceScenarios() {
+ t.Run(sc.name, func(t *testing.T) {
+ var want *Consumed
+ var wantLive string
+ for _, hooked := range []bool{false, true} {
+ s := NewDirStore(t.TempDir())
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ _, opts := sc.setup(t, s, priv, opt, reg)
+ var seen []string
+ if hooked {
+ withFenceHook(t, func(_, at string) { seen = append(seen, at) })
+ }
+ passWithSettings(t, reg, s, pub, opt, opts...)
+ got, err := ReadConsumed(opt, fenceHost)
+ if err != nil || !sc.published(got) {
+ t.Fatalf("hooked %v: consumed %+v, %v; want the pass's record", hooked, got, err)
+ }
+ live, _ := LiveVersion(opt, fenceHost)
+ if !hooked {
+ want, wantLive = got, live
+ continue
+ }
+ if len(seen) != 2 || seen[0] != "fence" || seen[1] != "write" {
+ t.Fatalf("hook points %v, want fence then write", seen)
+ }
+ g, w := *got, *want
+ g.At, w.At = time.Time{}, time.Time{}
+ if !sameConsumedForTest(g, w) || live != wantLive {
+ t.Fatalf("fenced pass: consumed %+v live %q; unfenced: %+v live %q", g, live, w, wantLive)
+ }
+ }
+ })
+ }
+}
+
+func sameConsumedForTest(a, b Consumed) bool {
+ ra, rb := a.Resolved, b.Resolved
+ a.Resolved, b.Resolved = nil, nil
+ return a == b && ((ra == nil && rb == nil) || (ra != nil && rb != nil && *ra == *rb))
+}
+
+// publishGate: a held or missing row is refused; a ready one is not.
+func TestPublishGateRefusesAMoveHold(t *testing.T) {
+ for _, st := range []string{"moving", "landing", "restoring", "releasing"} {
+ if err := publishGate(&registry.Route{Host: fenceHost, Status: st}); !errors.Is(err, ErrMoveHeld) {
+ t.Errorf("status %q: %v; want ErrMoveHeld", st, err)
+ }
+ }
+ if err := publishGate(nil); !errors.Is(err, ErrMoveHeld) {
+ t.Errorf("no row: %v; want ErrMoveHeld", err)
+ }
+ for _, st := range []string{"", "ready"} {
+ if err := publishGate(&registry.Route{Host: fenceHost, Status: st}); err != nil {
+ t.Errorf("status %q: %v; want nil", st, err)
+ }
+ }
+}
diff --git a/internal/release/consumedfence_other.go b/internal/release/consumedfence_other.go
new file mode 100644
index 000000000..07fcc453e
--- /dev/null
+++ b/internal/release/consumedfence_other.go
@@ -0,0 +1,14 @@
+//go:build !linux
+
+package release
+
+import "context"
+
+// Boxes run Linux; other builds (a developer's darwin, the windows cross
+// compile) have no carry-fence lock. Root's re-check of the move hold
+// (publishGate) still runs; only the serialization with a cut is absent, and
+// no cut runs off Linux.
+
+func lockConsumedPublish(context.Context, string, string) (func(), error) { return func() {}, nil }
+
+func lockConsumedCarry(context.Context, string, string) (func(), error) { return func() {}, nil }
diff --git a/internal/release/heldwake.go b/internal/release/heldwake.go
index 58dd25342..d11fb4ade 100644
--- a/internal/release/heldwake.go
+++ b/internal/release/heldwake.go
@@ -132,12 +132,28 @@ func DecideHeldWake(in HeldWakeInput) HeldWakeDecision {
// root reports failed is left to the planner's recovery (ruling
// R-HELD-FAILED), as an unknown target is to the wake's own refusal. nil
// when the build may run.
-func HeldExecStale(d HeldWakeDecision, st *BuildState, build string) error {
+//
+// inPlaceOf is the build the zero-downtime planner chose build in place of,
+// for this exec only ("" when it chose nothing: ruling R-PLANNED-RECOVERY):
+// a planned fallback after B failed, the good build booted untried, or a
+// recovery boot onto the good build because the failure book blocks the
+// installed build. When the state read under the lease holds exactly that
+// build as the target, the planner's reason for leaving it is the reason the
+// held target is left (settings spec §6: the scheduler, swap, failure
+// records and fallback act on the settings' build), so build runs, before
+// root's consumed file names the target failed. A selection made in place of
+// any other build admits nothing: the good build is never exempt in general,
+// since it is often the stale build a wake decided on (Astra round 1
+// finding 1).
+func HeldExecStale(d HeldWakeDecision, st *BuildState, build, inPlaceOf string) error {
if !d.Held || d.Unknown || d.Unread || d.Failed || d.Target == "" || d.Target == build {
return nil
}
if st != nil && st.LastExecuted == build {
return nil
}
+ if inPlaceOf != "" && inPlaceOf == d.Target {
+ return nil
+ }
return fmt.Errorf("the build state names %s as this instance's build, not %s: the wake decided on a stale copy", d.Target, build)
}
diff --git a/internal/release/heldwake_test.go b/internal/release/heldwake_test.go
new file mode 100644
index 000000000..cd2a3181f
--- /dev/null
+++ b/internal/release/heldwake_test.go
@@ -0,0 +1,33 @@
+package release
+
+import "testing"
+
+// Ruling R-PLANNED-RECOVERY (Astra round 2 finding 1): the pre-exec
+// revalidation admits the planner's selection of a build in place of exactly
+// the held target, and nothing wider.
+func TestHeldExecStaleAdmitsOnlyThePlannersTransition(t *testing.T) {
+ heldT := HeldWakeDecision{Target: "T", Held: true}
+ st := &BuildState{LastExecuted: "T"}
+ cases := []struct {
+ name string
+ d HeldWakeDecision
+ st *BuildState
+ build string
+ inPlaceOf string
+ admitted bool
+ }{
+ {"the target itself", heldT, st, "T", "", true},
+ {"the good build, no selection", heldT, st, "A", "", false},
+ {"the good build, chosen in place of T", heldT, st, "A", "T", true},
+ {"the good build, chosen in place of another build", heldT, st, "A", "B", false},
+ {"last_executed restarts", HeldWakeDecision{Target: "R", Held: true}, &BuildState{LastExecuted: "A"}, "A", "", true},
+ {"a pending request's T, the fallback in place of it", HeldWakeDecision{Target: "T", Held: true}, &BuildState{LastExecuted: "A0"}, "A", "T", true},
+ {"not held", HeldWakeDecision{}, st, "A", "", true},
+ }
+ for _, c := range cases {
+ err := HeldExecStale(c.d, c.st, c.build, c.inPlaceOf)
+ if (err == nil) != c.admitted {
+ t.Errorf("%s: HeldExecStale = %v; admitted want %v", c.name, err, c.admitted)
+ }
+ }
+}
diff --git a/internal/release/updatesim_test.go b/internal/release/updatesim_test.go
index 9006b425e..8ff4ee8d3 100644
--- a/internal/release/updatesim_test.go
+++ b/internal/release/updatesim_test.go
@@ -1150,7 +1150,7 @@ func (w *simWorld) wake(b *simBox) string {
// lease, then the merge. A refused record runs nothing.
fresh := DecideHeldWake(HeldWakeInput{Settings: col, Build: BuildStateCopy{Known: true, Replica: true, State: cloneSimState(w.state)},
Consumed: cloneSimConsumed(b.consumed), Installed: b.live})
- if err := HeldExecStale(fresh, w.state, run); err != nil {
+ if err := HeldExecStale(fresh, w.state, run, ""); err != nil { // the model's wake has no planner
return desc + ": record refused: " + err.Error()
}
w.checkWakeRun(b, run)