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>
8 files changed,
+314
−24
internal/activator/activator.go+12 −6internal/activator/backend_exec.go+44 −10internal/activator/exec_gate_test.go+135 −0internal/host/claimmoveaccess.go+6 −0internal/host/rolloutdeclined.go+10 −6internal/host/rolloutdeclined_test.go+68 −2internal/release/staged.go+13 −0internal/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") |