carlos / platform Public

zdd-readiness: Task 9 fix round 1, the legacy sidecar and the sweep's cycle ask the launch gate, path-only resolution, marked good build from the link, move launches gated

Rulings 1-4 of the Task 9 review. The legacy co-spawned sidecar resolves the link without hashing, asks LiveExecGate and execs the resolved path (nested: asks before root's start); the sweep's drift cycle of an instance or hibernating worker asks the gate before beginStopLocked and spends no budget on a refusal; the nested residual is commented as accepted (expected identity on Control.Start owed on #272); the declined marker rule lets the marked build start whichever of stamp or link names it; release.ResolveLivePath replaces the per-wake hash on the flat spawn; claims move launches get declinedClaimGate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e32ac0c65de0e9db37dcf155e12426326e3d32a3 parent fa7d737
8 files changed, +314 −24
  • internal/activator/activator.go +12 −6
  • internal/activator/backend_exec.go +44 −10
  • internal/activator/exec_gate_test.go +135 −0
  • internal/host/claimmoveaccess.go +6 −0
  • internal/host/rolloutdeclined.go +10 −6
  • internal/host/rolloutdeclined_test.go +68 −2
  • internal/release/staged.go +13 −0
  • internal/release/staged_test.go +26 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index b9d14e9d6..587d25e16 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -2672,10 +2672,11 @@ func (a *Service) convergeWorkerLocked(e *actEntry, build string) bool {
}
// liveGateRefusesLocked asks the live exec gate (SetLiveExecGate) whether
-// e's worker may move onto build, the build live/<host> names, and reports
-// a refusal, which it logs once per host and build. No gate (every box but
-// a declined one that honours something) refuses nothing. The gate reads
-// only memory, the row and the live link. The caller holds a.mu.
+// e may move onto build (a worker's converge or recycle, or the sweep's
+// cycle of an instance or hibernating worker), the build live/<host> names,
+// and reports a refusal, which it logs once per host and build. No gate
+// (every box but a declined one that honours something) refuses nothing.
+// The gate reads only memory and the row. The caller holds a.mu.
func (a *Service) liveGateRefusesLocked(e *actEntry, build, left string) bool {
if a.liveExecGate == nil {
return false
@@ -2690,7 +2691,7 @@ func (a *Service) liveGateRefusesLocked(e *actEntry, build, left string) bool {
a.liveGateSaid = map[string]string{}
}
a.liveGateSaid[e.host] = build
- log.Printf("activator: worker %s: live/%s names %s, which this box will not start (%v), so it does not move onto it: %s", e.host, e.host, build, err, left)
+ log.Printf("activator: %s: live/%s names %s, which this box will not start (%v), so it does not move onto it: %s", e.host, e.host, build, err, left)
}
return true
}
@@ -3893,12 +3894,17 @@ func (a *Service) sweepOnce() {
// honours a record or marker holding cur, Task 9) stops nothing
// and spends no budget: the worker carries on to supervision
// below, running its current build.
+ //
+ // So is the cycle of an instance or a hibernating worker (fix
+ // round 1, ruling 2): its next wake would be refused, so stopping
+ // it now turns a held build into an outage. A refusal keeps it
+ // running on its current build.
if e.worker && !e.route.Hibernate {
if a.convergeWorkerLocked(e, cur) {
a.convergeBudget--
continue
}
- } else {
+ } else if !a.liveGateRefusesLocked(e, cur, "it keeps running "+e.spawnVersion) {
log.Printf("activator: %s serving %s but live symlink is now %s โ€” cycling onto the new build", e.host, e.spawnVersion, cur)
a.beginStopLocked(e)
a.convergeBudget--
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index f15b054e0..26f6a8808 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -1486,8 +1486,9 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
// installed, not what this process runs.
//
// With LiveExecGate set (a declined box that honours failure records or
- // hold-ran markers, Task 9), the live link is resolved ONCE here
- // (release.ResolveLive), the gate is asked about that build at the
+ // hold-ran markers, Task 9), the live link is resolved ONCE here, path
+ // only, with no hash (release.ResolveLivePath, fix round 1 ruling 4b),
+ // the gate is asked about that build at the
// launch, and the staged file the link named is what is exec'd, not the
// link. So the build the gate passed is the build that runs: a repoint
// between the gate and the exec changes nothing, where exec'ing the link
@@ -1497,7 +1498,7 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
spawnVer := ""
var liveGate func() error
if exe == "" && b.LiveExecGate != nil {
- id, err := release.ResolveLive(b.OptDir, host)
+ id, err := release.ResolveLivePath(b.OptDir, host)
if err != nil {
if ctl != nil {
ctl.Close()
@@ -1723,12 +1724,13 @@ func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock
// (Task 9) asks about the build the live link names now, the one
// spawnVer read immediately above: the plan read the link before the
// lease and the restore, and root may have repointed it at a held
- // build since. Residual: root mounts the link itself at a generation
- // 0 start (a staged path can be handed it only with generations,
- // which a declined box has off), so a repoint landing between this
- // read and root's start runs the build it names, where the flat
- // spawn execs the path it resolved. The next wake is refused at its
- // plan.
+ // build since.
+ //
+ // Accepted residual (Task 9 fix round 1, ruling 3; ledgered): the
+ // gate checks the version read above, before root's trampoline
+ // execs the link, and a repoint landing in between escapes it (the
+ // flat spawn execs the path it resolved instead). Closing it needs
+ // an expected identity on Control.Start, owed on #272.
err = b.LiveExecGate(rt, spawnVer)
}
if err != nil {
@@ -2090,8 +2092,28 @@ func (b *ExecBackend) StartSidecar(ctx context.Context, rt registry.Route) (Proc
//
// This arm serves the LEGACY co-spawn only (see HasSidecar) โ€” StartWorker is
// the native spawn path for a standalone registry.Route.IsSidecar row.
+//
+// With LiveExecGate set (a declined box that honours failure records or
+// hold-ran markers; Task 9 fix round 1, ruling 1), the link is resolved once,
+// without hashing (release.ResolveLivePath), the gate is asked about that
+// build, and the file it resolved is exec'd, not the link. The legs leave a
+// held instance running on its old build while root repoints the link, and
+// every respawn of its sidecar (the wake tail, late attachment, the sweep's
+// supervision) comes through here: exec'ing the link would run the held
+// build beside it. A refusal starts nothing, and the supervisor's backoff
+// keeps the sidecar down. With the gate nil it is today's exec of the link.
func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route) (Proc, error) {
- return b.startSidecarChildFrom(ctx, rt, b.liveLink(rt.Host))
+ if b.LiveExecGate == nil {
+ return b.startSidecarChildFrom(ctx, rt, b.liveLink(rt.Host))
+ }
+ id, err := release.ResolveLivePath(b.OptDir, rt.Host)
+ if err != nil {
+ return nil, fmt.Errorf("%s: sidecar: %w", rt.Host, err)
+ }
+ if err := b.LiveExecGate(rt, id.Version); err != nil {
+ return nil, err
+ }
+ return b.startSidecarChildFrom(ctx, rt, id.StagedPath)
}
// startSidecarChildFrom is startSidecarChild with the binary named. That is
@@ -2300,6 +2322,18 @@ func (b *ExecBackend) startSidecarUnitFrom(ctx context.Context, rt registry.Rout
}
inv, err = gc.StartSidecarExe(ctx, rt.Host, tier.MemoryMax, *exe)
} else {
+ if b.LiveExecGate != nil {
+ // A declined box that honours failure records or hold-ran
+ // markers (Task 9 fix round 1, ruling 1): the build the live link
+ // names, asked right before root is asked to start the sidecar,
+ // as the nested instance start asks it (spawnUnitFrom), with the
+ // same residual: root's trampoline execs the link itself.
+ ver, _ := b.LiveVersion(rt.Host)
+ if gerr := b.LiveExecGate(rt, ver); gerr != nil {
+ logs.Close()
+ return nil, gerr
+ }
+ }
inv, err = b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax)
}
if err != nil {
diff --git a/internal/activator/exec_gate_test.go b/internal/activator/exec_gate_test.go
index 9de1f76b4..4fb87823f 100644
--- a/internal/activator/exec_gate_test.go
+++ b/internal/activator/exec_gate_test.go
@@ -901,3 +901,138 @@ func TestADeclinedUncapturedWorkerIsNotRespawnedOntoAHeldBuild(t *testing.T) {
}
})
}
+
+// Task 9 fix round 1 (ruling 1): the legacy co-spawned sidecar execs the
+// live link too, and its wake tail, late attachment and the sweep's
+// supervision respawn it while the instance keeps running. On a declined box
+// with the gate set, root's repoint of the link at a held build must not
+// start that build as the sidecar: the start resolves the link (no hash),
+// asks the gate about that build, and execs the path it resolved. A refusal
+// starts nothing (the supervisor's backoff keeps it down). With the gate nil
+// the sidecar execs the link, as before.
+func TestALiveExecGateGatesTheLegacySidecarStart(t *testing.T) {
+ sidecarEnv := func(t *testing.T, b *ExecBackend, rt registry.Route) {
+ t.Helper()
+ if err := os.WriteFile(b.pathsFor(rt).sidecarEnv, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Run("flat, gated build is the exec'd build", func(t *testing.T) {
+ b, rt, v1, _, out := flatLiveFixture(t)
+ sidecarEnv(t, b, rt)
+ g := &liveGate{refuse: map[string]bool{"v2": true}}
+ b.LiveExecGate = g.gate
+ p, err := b.StartSidecar(context.Background(), rt)
+ if err != nil {
+ t.Fatalf("StartSidecar = %v", err)
+ }
+ defer p.Stop(context.Background())
+ if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != v1 {
+ t.Fatalf("sidecar $0 = %q, want the staged v1 the gate was asked about (%s)", got, v1)
+ }
+ if !slices.Equal(g.builds(), []string{"v1"}) {
+ t.Fatalf("gate asked %v, want [v1]", g.builds())
+ }
+ })
+ t.Run("flat, a held build starts nothing", func(t *testing.T) {
+ b, rt, _, v2, out := flatLiveFixture(t)
+ sidecarEnv(t, b, rt)
+ repointLive(t, b, rt.Host, v2)
+ b.LiveExecGate = (&liveGate{refuse: map[string]bool{"v2": true}}).gate
+ p, err := b.StartSidecar(context.Background(), rt)
+ if p != nil {
+ _ = p.Stop(context.Background())
+ }
+ if !errors.Is(err, errLiveHeld) || p != nil {
+ t.Fatalf("StartSidecar = %v, %v; want no process and the gate's refusal", p, err)
+ }
+ time.Sleep(200 * time.Millisecond)
+ if _, err := os.Stat(out); err == nil {
+ t.Fatal("held v2 ran as the sidecar")
+ }
+ })
+ t.Run("flat, no gate is today's", func(t *testing.T) {
+ b, rt, _, _, out := flatLiveFixture(t)
+ sidecarEnv(t, b, rt)
+ p, err := b.StartSidecar(context.Background(), rt)
+ if err != nil {
+ t.Fatalf("StartSidecar = %v", err)
+ }
+ defer p.Stop(context.Background())
+ if got := strings.TrimSpace(waitForFile(t, out, 5*time.Second)); got != b.liveLink(rt.Host) {
+ t.Fatalf("sidecar $0 = %q, want today's exec of %s", got, b.liveLink(rt.Host))
+ }
+ })
+ t.Run("nested, a held build asks root for nothing", func(t *testing.T) {
+ ctl := &fakeControl{inv: "inv1"}
+ b, rt := nestedBackend(t, ctl, &fakeUnit{})
+ g := &liveGate{refuse: map[string]bool{"v1": true}}
+ b.LiveExecGate = g.gate
+ p, err := b.StartSidecar(context.Background(), rt)
+ if !errors.Is(err, errLiveHeld) || p != nil {
+ t.Fatalf("StartSidecar = %v, %v; want the gate's refusal", p, err)
+ }
+ if strings.Contains(ctl.joined(), "start ") {
+ t.Fatalf("root was asked to start the sidecar after the gate refused: %q", ctl.joined())
+ }
+ if !slices.Equal(g.builds(), []string{"v1"}) {
+ t.Fatalf("gate asked %v, want [v1]", g.builds())
+ }
+ })
+}
+
+// Task 9 fix round 1 (ruling 2): the sweep's drift cycle of an awake
+// instance, or a hibernating worker, stops it so its next wake runs the new
+// build. On a declined box a held build's next wake is refused, so the gate
+// is asked first: a refusal keeps it running and spends no budget. A build
+// the gate allows cycles as before.
+func TestADeclinedSweepDoesNotCycleOntoAHeldBuild(t *testing.T) {
+ t.Run("instance", func(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ var ver atomic.Value
+ ver.Store("v1")
+ a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
+ b.execVerFn = func() string { return ver.Load().(string) }
+ a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
+ release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+ ver.Store("v2")
+ a.sweepOnce()
+ clk.advance(ConvergeGrace + time.Second)
+ a.sweepOnce()
+ a.sweepOnce()
+ time.Sleep(50 * time.Millisecond)
+ if a.entryCount() != 1 || b.lastProc().isStopped() {
+ t.Fatal("the sweep stopped an instance serving v1 into a wake onto held v2")
+ }
+ ver.Store("v3")
+ a.sweepOnce()
+ clk.advance(ConvergeGrace + time.Second)
+ a.sweepOnce()
+ waitFor(t, "the cycle onto v3", func() bool { return a.entryCount() == 0 })
+ })
+ t.Run("hibernating worker", func(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ var ver atomic.Value
+ ver.Store("v1")
+ a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
+ b.execVerFn = func() string { return ver.Load().(string) }
+ a.SetLiveExecGate((&liveGate{refuse: map[string]bool{"v2": true}}).gate)
+ host := "bot.x.internal"
+ if err := a.WakeWorker(testWorkerRoute(host)); err != nil {
+ t.Fatalf("WakeWorker: %v", err)
+ }
+ ver.Store("v2")
+ a.sweepOnce()
+ clk.advance(ConvergeGrace + time.Second)
+ a.sweepOnce()
+ a.sweepOnce()
+ time.Sleep(50 * time.Millisecond)
+ if !a.Owns(host) || b.lastWorker().Exited() {
+ t.Fatal("the sweep stopped a worker serving v1 into a wake onto held v2")
+ }
+ })
+}
diff --git a/internal/host/claimmoveaccess.go b/internal/host/claimmoveaccess.go
index 1177759a2..de575b45c 100644
--- a/internal/host/claimmoveaccess.go
+++ b/internal/host/claimmoveaccess.go
@@ -162,6 +162,12 @@ func claimMoveLaunchSource(backend *activator.ExecBackend) func(context.Context,
if err != nil {
return ClaimServingLaunch{}, moves.EnvReport{}, err
}
+ // A declined box's launch gate, as for a claims-backed wake (Task 9
+ // fix round 1, ruling 4c): a moves launch execs the snapshot's bytes
+ // too. Nil, and nothing read, on every other box.
+ if err := declinedClaimGate(backend.OptDir, rt, snapshot); err != nil {
+ return ClaimServingLaunch{}, moves.EnvReport{}, err
+ }
env, _, err := backend.LaunchEnvironment(rt)
if err != nil {
return ClaimServingLaunch{}, moves.EnvReport{}, err
diff --git a/internal/host/rolloutdeclined.go b/internal/host/rolloutdeclined.go
index 93cc730a7..5f9ccb984 100644
--- a/internal/host/rolloutdeclined.go
+++ b/internal/host/rolloutdeclined.go
@@ -114,11 +114,15 @@ func honourDeclinedFailures(svc *activator.Service, reg *registry.Registry, dd,
// 9; Astra R3 N5/N7, ruling 205):
// - the marked build may start, unless the instance has a good build and
// a record holds the marked build for it. A record never holds a build
-// the instance served, so a marker naming the installed good build
-// itself lets it start (the marked-build rule);
-// - the good build is refused: an installed good build is no proof of a
-// signed rollback, since root's automatic sibling repoint installs it
-// too, and only the pointer pass clears the marker on one (Task 10);
+// the instance served, so a marker naming the good build itself lets it
+// start (the marked-build rule), whether the stamp or the live link
+// names it: unlike the armed planner, a declined box never boots the
+// good build as a recovery, so the build being started is always the
+// installed one, stamped or linked (fix round 1, ruling 4a);
+// - otherwise the good build is refused: an installed good build is no
+// proof of a signed rollback, since root's automatic sibling repoint
+// installs it too, and only the pointer pass clears the marker on one
+// (Task 10);
// - any other build a record holds for the instance is refused.
//
// All three refuse in HeldDownRefusal's words for the marked build, as the
@@ -141,7 +145,7 @@ func (h *failureHolds) refusal(rt registry.Route, build string) string {
if mark, ok := h.book.HoldRanOn(host); ok && mark.Account == acct && mark.App == app {
isGood := hasGood && build == rt.GoodVersion
switch {
- case build == mark.Build && (!isGood || rt.Version == mark.Build):
+ case build == mark.Build:
if hasGood && h.book.Blocks(acct, app, build, host, isGood) {
return rollout.HeldDownRefusal(mark.Build)
}
diff --git a/internal/host/rolloutdeclined_test.go b/internal/host/rolloutdeclined_test.go
index 14fb1efae..f16e97da7 100644
--- a/internal/host/rolloutdeclined_test.go
+++ b/internal/host/rolloutdeclined_test.go
@@ -3,6 +3,7 @@ package host
import (
"bytes"
"context"
+ "encoding/base64"
"errors"
"net/http"
"net/http/httptest"
@@ -374,8 +375,9 @@ func TestDeclinedBoxKeepsAHeldBuildOffARealActivator(t *testing.T) {
t.Fatalf("spawned builds = %v, want only v1", got)
}
- // The sweep's own convergence is the activator's, unchanged on a
- // declined box; whatever stops the instance, its next wake is refused.
+ // The sweep's own drift cycle asks the launch gate too (Task 9 fix round
+ // 1); whatever else stops the instance (here a bounce), its next wake is
+ // refused.
svc.Bounce(host)
deadline := time.Now().Add(10 * time.Second)
for svc.Owns(host) {
@@ -733,3 +735,67 @@ func TestNoLiveExecGateUnlessADeclinedBoxHonoursSomething(t *testing.T) {
}
})
}
+
+// Task 9 fix round 1 (ruling 4a): the marked-build rule holds whichever of
+// the stamp and the live link names the marked build. The live link names
+// the marked good build v1 while the stamp says v3 (root repointing): that
+// is the build the marker says may run, and the instance served it, so a
+// sibling's record does not hold it either.
+func TestDeclinedBoxLetsTheMarkedGoodBuildStartFromTheLiveLink(t *testing.T) {
+ optDir := t.TempDir()
+ t.Setenv("CARLOS_OPT_DIR", optDir)
+ svc, reg, dd := rolloutFixture(t, false)
+ acct := release.RouteAccount(managedRoute("x", ""))
+ writeFailureBook(t, dd, []rollout.FailureRecord{{Account: acct, App: "blog", Build: "v1", Host: "sib.example.com"}}, nil)
+ b, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := b.MarkHoldRan("mg.example.com", rollout.HoldRan{Account: acct, App: "blog", Build: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.StageArtifact(optDir, acct, "v1", "blog", []byte("v1")); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PointLive(optDir, "mg.example.com", acct, "v1", "blog"); err != nil {
+ t.Fatal(err)
+ }
+ declineWithBook(t, svc, reg, dd)
+ rt := managedRoute("mg.example.com", "v3")
+ rt.GoodVersion, rt.GoodDigest = "v1", "sha256:v1"
+ if p := svc.Planner(context.Background(), rt); !isTodaysWake(p) {
+ t.Fatalf("plan with the link on the marked good build = %+v, want today's wake", p)
+ }
+ if err := heldHolds().gate(rt, "v1"); err != nil {
+ t.Fatalf("gate refused the marked good build the link names: %v", err)
+ }
+}
+
+// Task 9 fix round 1 (ruling 4c): a claims move launch (a moves probe or
+// landing) on a declined box is gated as a claims-backed wake is, after its
+// snapshot resolves the link.
+func TestDeclinedBoxGatesAClaimsMoveLaunch(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ if err := os.WriteFile(f.envPath(), []byte("CARLOS_ADMIN_TOKEN=tok-0123456789abcdef0123456789abcdef\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_SECRETS_KEY", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)))
+ rt := f.rt
+ rt.Kind, rt.Status, rt.Channel, rt.Account, rt.App, rt.Hibernate, rt.Version = "instance", "ready", "edge", "bab", "jam", true, "v1"
+ rt.GoodVersion, rt.GoodDigest = "v0", "sha256:v0"
+ launch := claimMoveLaunchSource(f.backend)
+ if _, _, err := launch(t.Context(), rt); err != nil {
+ t.Fatalf("move launch with nothing honoured = %v, want today's", err)
+ }
+ svc, reg, dd := rolloutFixture(t, false)
+ writeFailureBook(t, dd, []rollout.FailureRecord{{Account: release.RouteAccount(rt), App: "jam", Build: "v1", Host: "sib.example.com"}}, nil)
+ declineWithBook(t, svc, reg, dd)
+ if _, _, err := launch(t.Context(), rt); err == nil || err.Error() != refuseNoSwaps {
+ t.Fatalf("move launch of held v1 = %v, want %q", err, refuseNoSwaps)
+ }
+ served := rt
+ served.GoodVersion, served.GoodDigest = "v1", "sha256:v1"
+ if _, _, err := launch(t.Context(), served); err != nil {
+ t.Fatalf("move launch of the instance's own good build = %v, want it allowed", err)
+ }
+}
diff --git a/internal/release/staged.go b/internal/release/staged.go
index 5c2760aef..20ddcb84b 100644
--- a/internal/release/staged.go
+++ b/internal/release/staged.go
@@ -128,6 +128,19 @@ func ResolveLive(optDir, host string) (StagedIdentity, error) {
return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: path, Digest: sum}, nil
}
+// ResolveLivePath is ResolveLive without the hash: one read of host's live
+// link, the file at the end of its chain (linkChain) and the link's version,
+// with Digest empty. It is for a start that needs only to exec exactly the
+// build it named (the activator's live exec gate, Task 9), where hashing a
+// build of up to a hundred megabytes at every wake would buy nothing.
+func ResolveLivePath(optDir, host string) (StagedIdentity, error) {
+ raw, hops, _, err := linkChain(LiveLink(optDir, host))
+ if err != nil {
+ return StagedIdentity{}, err
+ }
+ return StagedIdentity{Version: versionOfTarget(optDir, raw), StagedPath: hops[len(hops)-1]}, nil
+}
+
// 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
diff --git a/internal/release/staged_test.go b/internal/release/staged_test.go
index 6588c993a..48e67a5b8 100644
--- a/internal/release/staged_test.go
+++ b/internal/release/staged_test.go
@@ -74,6 +74,32 @@ func TestResolveLiveFollowsARelativeLink(t *testing.T) {
}
}
+// Task 9 fix round 1: the path-only resolution names the same file and
+// version as ResolveLive, and hashes nothing.
+func TestResolveLivePathIsResolveLiveWithoutTheDigest(t *testing.T) {
+ opt := t.TempDir()
+ if err := StageArtifact(opt, "bab", "v1", "km", []byte("b")); err != nil {
+ t.Fatal(err)
+ }
+ if err := PointLive(opt, "h.example.com", "bab", "v1", "km"); err != nil {
+ t.Fatal(err)
+ }
+ full, err := ResolveLive(opt, "h.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := ResolveLivePath(opt, "h.example.com")
+ if err != nil {
+ t.Fatalf("ResolveLivePath: %v", err)
+ }
+ if want := (StagedIdentity{Version: full.Version, StagedPath: full.StagedPath}); got != want || got.Version != "v1" {
+ t.Fatalf("ResolveLivePath = %+v, want %+v", got, want)
+ }
+ if _, err := ResolveLivePath(t.TempDir(), "h.example.com"); err == nil {
+ t.Fatal("ResolveLivePath with no live link returned no error")
+ }
+}
+
func TestResolveLiveWithNoLinkIsAnError(t *testing.T) {
if _, err := ResolveLive(t.TempDir(), "h.example.com"); err == nil {
t.Fatal("ResolveLive with no live link returned no error")