release+host: RestartHibernating — the adopt pass reaches WARM exec children
Review found the exec-bounce branch dead in production. release.Pass splits routes on the Hibernate FLAG and hands every flagged route restart=nil; exec instances are minted Hibernate:true (provision/reconcile.go), so the backing-dispatched callback was never consulted for them, warm or asleep. Net effect on exec instances: zero. The flag is not the question the restart decision wants answered. Hibernate means "this route MAY sleep", not "it is asleep right now" — a busy exec child spends every serving minute awake, and kept serving the binary its live symlink no longer named until it happened to go idle. RestartHibernating is the opt-in: hibernate-flagged routes get the restart callback too. "An adopt pass never wakes a sleeping host" is preserved, moved from withholding the callback to the callback's own semantics — Bounce returns false without starting anything when the host is not awake, and try-restart is a no-op on a stopped unit. Only a callback with those properties may be passed. adoptLoop and canaryAdoptLoop set it (they run inside the process that owns the children); RunAdopt does NOT, so root behavior is byte-identical. Sleepers are still adopted last and still poison a bad build. Also: makeRestartRoute's exec branch no longer swallows the unstamped unit-tenant state (issue #14, adopt_advice.go) — a bounce that cycled nothing falls through to try-restart, as it always did. The callback only fires on an actual version change, so a genuinely sleeping exec child costs one no-op try-restart per version, not a line per tick.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+347
−34
internal/host/agent.go+37 −7internal/host/agent_test.go+104 −16internal/release/adopt.go+76 −11internal/release/adopt_test.go+130 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 5eedb0a43..31927673a 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -283,8 +283,18 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service) { |
| // OnStageBlocked, by contrast, is exactly right here: it fires only |
| // when this sandboxed process cannot write the version store, and all |
| // it does is ask the root pass to do the part this one cannot. |
| + // |
| + // RestartHibernating, by contrast again, belongs HERE and not in |
| + // RunAdopt: this process owns the exec children, so it is the only one |
| + // whose restart callback can reach a warm hibernator at all. Without |
| + // it the pass would split on the Hibernate FLAG and hand every exec |
| + // instance the pure repoint — the flag says "may sleep", and a busy |
| + // instance would go on serving the binary its symlink no longer names |
| + // until it happened to go idle. Safe because makeRestartRoute cannot |
| + // start a process on either of its paths; see release.RestartHibernating. |
| if err := release.Pass(ctx, reg, store, pub, optDir, restart, |
| - release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir()))); err != nil { |
| + release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())), |
| + release.RestartHibernating(restart)); err != nil { |
| log.Printf("agent: adopt pass: %v", err) |
| } |
| } |
| @@ -703,8 +713,15 @@ func canaryAdoptLoop(reg *registry.Registry, svc *activator.Service) { |
| // the one step of that pickup this process cannot perform. Throttled |
| // inside stageRequester — this tick is 2-3 seconds, the request is at |
| // most one a minute. |
| + // |
| + // RestartHibernating IS wired here too, and for the same reason as on |
| + // adoptLoop: this process owns the exec children. It matters more on |
| + // this loop than on any other — a canary is promoted precisely to be |
| + // picked up in seconds, and the routes following canary/* are the warm |
| + // ones somebody is watching. |
| if err := release.PassFilter(ctx, reg, store, pub, optDir, restart, isCanaryRoute, |
| - release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir()))); err != nil { |
| + release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())), |
| + release.RestartHibernating(restart)); err != nil { |
| log.Printf("agent: canary adopt pass: %v", err) |
| } |
| } |
| @@ -1237,10 +1254,23 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p |
| // cycled the process. |
| // |
| // Hosts the registry cannot answer for take the unit path: try-restart of a |
| -// nonexistent unit is a logged no-op, whereas a bounce of a process we might |
| -// not own is not. A nil bounce (no activator armed on this box) makes the |
| -// exec path a no-op rather than a fallthrough to systemctl — there is no |
| -// carlos-app@ unit behind an exec route to try-restart. |
| +// nonexistent unit is a no-op, whereas a bounce of a process we might not own |
| +// is not. |
| +// |
| +// A bounce that cycled NOTHING falls through to try-restart rather than |
| +// returning: an exec-backed route with an ACTIVE carlos-app@<host> unit is a |
| +// real state — an unstamped unit tenant, the one adopt_advice.go warns about |
| +// (issue #14) — and try-restart is what has always cycled it. The same |
| +// fallthrough covers a box with no activator armed at all. When the route |
| +// really is a sleeping exec child the cost is one try-restart of a stopped or |
| +// absent unit, which starts nothing and at worst logs once: this callback |
| +// only fires when the version actually moves (adoptHost early-returns on |
| +// cur == ver), so it is one line per version change per host, never per tick. |
| +// |
| +// Neither path can WAKE anything, which is what lets release.Pass hand this |
| +// callback hibernating routes (RestartHibernating): Bounce returns false |
| +// without starting anything when the host is not awake, and try-restart is |
| +// defined as a no-op on a unit that is not running. |
| // |
| // Errors are logged, never returned: a failed restart must not poison the |
| // adopted version for the other hosts in the pass (release/adopt.go's rule). |
| @@ -1253,8 +1283,8 @@ func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), boun |
| if err == nil && rt != nil && rt.Backing != registry.BackingUnit { |
| if bounce != nil && bounce(host) { |
| log.Printf("adopt: bounced exec instance %s onto its new version", host) |
| + return nil |
| } |
| - return nil |
| } |
| tryRestart(host) |
| return nil |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index 1a11066e6..6253653b9 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -543,24 +543,112 @@ func TestMakeRestartRouteUnknownHostFallsBackToUnit(t *testing.T) { |
| } |
| } |
| -// TestMakeRestartRouteExecWithNoActivatorIsANoOp: an exec-backed route on a |
| -// box with no activator armed must NOT fall through to systemctl — there is |
| -// no carlos-app@ unit behind an exec route, so try-restart would only log a |
| -// failure every pass. Nothing running, nothing to restart; and a nil bounce |
| -// must not panic. |
| -func TestMakeRestartRouteExecWithNoActivatorIsANoOp(t *testing.T) { |
| +// TestMakeRestartRouteExecFallsThroughWhenNothingBounced covers the state |
| +// issue #14 is about (adopt_advice.go's warning): a route claims exec backing |
| +// but a carlos-app@<host> unit is ACTIVE — an unstamped unit tenant. The |
| +// bounce finds nothing of its own to cycle and returns false, and try-restart |
| +// must still run, exactly as it did before the dispatch split. Same for a box |
| +// with no activator at all (nil bounce), which must not panic either. |
| +// |
| +// The cost when the route really is a sleeping exec child is one try-restart |
| +// of a stopped or absent unit — a no-op, at most one log line per version |
| +// change per host, since the callback only fires when the version moves. |
| +func TestMakeRestartRouteExecFallsThroughWhenNothingBounced(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + bounce func(string) bool |
| + }{ |
| + {"no activator armed", nil}, |
| + {"activator armed but nothing awake", func(string) bool { return false }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + reg := restartTestRegistry(t) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "exec.test", Kind: "instance", App: "a", Status: "ready", |
| + }); err != nil { |
| + t.Fatalf("upsert exec route: %v", err) |
| + } |
| + var restarted []string |
| + restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, tc.bounce) |
| + if err := restart("exec.test"); err != nil { |
| + t.Fatalf("restart exec.test: %v", err) |
| + } |
| + if len(restarted) != 1 || restarted[0] != "exec.test" { |
| + t.Fatalf("a bounce that cycled nothing must fall through to try-restart: %v", restarted) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAdoptPassBouncesAWarmHibernatingInstance is the end-to-end this task |
| +// exists for, through the real release.Pass with the real closure: an exec |
| +// instance minted the way provision does it (Hibernate: true, Backing "") is |
| +// warm when a new version lands, and it BOUNCES onto that version instead of |
| +// serving the old binary until it happens to hibernate. |
| +// |
| +// Before the RestartHibernating option this was dead code in production: |
| +// release.Pass split on the Hibernate FLAG and handed every flagged route |
| +// restart=nil, so the dispatch never ran for the routes it was written for. |
| +func TestAdoptPassBouncesAWarmHibernatingInstance(t *testing.T) { |
| + dir := t.TempDir() |
| + optDir := t.TempDir() |
| + s := release.NewDirStore(dir) |
| + ctx := context.Background() |
| + plat := runtime.GOOS + "-" + runtime.GOARCH |
| + |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| reg := restartTestRegistry(t) |
| - if err := reg.Upsert(registry.Route{ |
| - Host: "exec.test", Kind: "instance", App: "a", Status: "ready", |
| - }); err != nil { |
| - t.Fatalf("upsert exec route: %v", err) |
| + // warm.test is awake; sleepy.test is not. Both are exec-backed hibernators |
| + // — the shape provision/reconcile.go mints. |
| + for _, h := range []string{"warm.test", "sleepy.test"} { |
| + if err := reg.Upsert(registry.Route{ |
| + Host: h, Kind: "instance", App: "warmapp", Channel: "canary/dev", |
| + Status: "ready", Hibernate: true, |
| + }); err != nil { |
| + t.Fatalf("upsert %s: %v", h, err) |
| + } |
| } |
| - var restarted []string |
| - restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, nil) |
| - if err := restart("exec.test"); err != nil { |
| - t.Fatalf("restart exec.test: %v", err) |
| + m := release.Manifest{ |
| + App: "warmapp", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]release.Artifact{plat: {}}, |
| } |
| - if len(restarted) != 0 { |
| - t.Fatalf("exec route must not take the systemctl path: %v", restarted) |
| + if err := release.Ship(ctx, s, "bab", m, map[string][]byte{plat: []byte("build one")}); err != nil { |
| + t.Fatalf("Ship: %v", err) |
| + } |
| + if err := release.Promote(ctx, s, priv, "bab", "warmapp", "canary/dev", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + var tried, bounced []string |
| + // The activator's answer: only warm.test has a running child. This is |
| + // exactly Service.Bounce's contract — false when the host is not awake, and |
| + // it never starts anything. |
| + restart := makeRestartRoute(reg, |
| + func(h string) { tried = append(tried, h) }, |
| + func(h string) bool { |
| + bounced = append(bounced, h) |
| + return h == "warm.test" |
| + }) |
| + |
| + if err := release.Pass(ctx, reg, s, pub, optDir, restart, release.RestartHibernating(restart)); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + |
| + // The warm instance was cycled onto the new binary. |
| + if len(bounced) != 2 { |
| + t.Fatalf("both hibernators must be offered to the callback, got %v", bounced) |
| + } |
| + if len(tried) != 1 || tried[0] != "sleepy.test" { |
| + t.Fatalf("only the host with nothing awake falls through to try-restart: %v", tried) |
| + } |
| + // And both adopted: the sleeper gets the repoint and execs the new binary |
| + // at its natural next wake — it is never started by the pass. |
| + for _, h := range []string{"warm.test", "sleepy.test"} { |
| + if v, err := release.LiveVersion(optDir, h); err != nil || v != "v1" { |
| + t.Fatalf("%s live = %q, %v; want v1", h, v, err) |
| + } |
| } |
| } |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 3cac99d93..49993e1d4 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -51,6 +51,12 @@ type passConfig struct { |
| // and every existing test — means the pass reports such a failure exactly |
| // as it always has and nothing else happens. |
| stageBlocked func(host, ver string, err error) |
| + |
| + // restartHibernating is the callback hibernate-flagged routes get |
| + // (RestartHibernating). nil — the default, and RunAdopt — keeps the |
| + // original split: sleepers are repointed with restart=nil and no callback |
| + // is consulted for them at all. |
| + restartHibernating func(host string) error |
| } |
| // NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one |
| @@ -148,6 +154,42 @@ func OnStageBlocked(fn func(host, ver string, err error)) PassOption { |
| return func(c *passConfig) { c.stageBlocked = fn } |
| } |
| +// RestartHibernating extends restart to hibernate-flagged routes, which |
| +// otherwise get the pure repoint (restart=nil) and are never offered to the |
| +// callback at all. |
| +// |
| +// It exists because the flag is not the question the restart decision |
| +// actually wants answered. Hibernate=true means "this route MAY sleep", not |
| +// "it is asleep right now" — an exec instance is minted hibernating |
| +// (provision/reconcile.go) and then spends every serving minute awake. With |
| +// the flag alone deciding, a busy exec child kept serving the binary its |
| +// live symlink no longer names until it happened to go idle. Handing the |
| +// callback the sleepers lets it answer the real question at the moment it |
| +// matters. |
| +// |
| +// This does NOT weaken "an adopt pass never wakes a sleeping host" — it moves |
| +// the guarantee from withholding the callback to the callback's own |
| +// semantics, and only a callback with those semantics may be passed here. |
| +// The agent's makeRestartRoute qualifies on both halves: activator Bounce |
| +// returns false without starting anything when the host is not awake, and |
| +// `systemctl try-restart` is defined as a no-op on a unit that is not |
| +// running. Nothing on either path can start a process. A callback that could |
| +// start one belongs on the awake loop only. |
| +// |
| +// DO NOT pass this from RunAdopt (carlos-adopt.service). That process is not |
| +// the activator's parent: the exec children belong to the agent, so its only |
| +// available restart is systemctl, which cannot reach them — offering it |
| +// sleepers would buy nothing and cost a try-restart per sleeping host per |
| +// version change. Set by the agent's in-process adoptLoop/canaryAdoptLoop |
| +// alone, the two loops that run inside the process owning the children. |
| +// |
| +// Sleepers are still adopted AFTER every awake host and still poison a bad |
| +// build the same way, both unchanged: the option decides who the callback |
| +// sees, never the order or the health gate. |
| +func RestartHibernating(restart func(host string) error) PassOption { |
| + return func(c *passConfig) { c.restartHibernating = restart } |
| +} |
| + |
| // stageBlocked reports whether err is the version store refusing a write to |
| // THIS uid, rather than any of the many other ways staging fails. errors.Is |
| // unwraps the *os.PathError that mkdir/openat return. |
| @@ -166,15 +208,23 @@ func stageBlocked(err error) bool { |
| // fleet unchecked. Only foundation failures (registry unreadable, bake clock |
| // unwritable) fail the pass. |
| // |
| -// restart cycles an awake host's process onto the new symlink target (the |
| +// restart cycles a host's process onto the new symlink target (the |
| // activator's proc converge); nil means symlink-only for every host. Hosts |
| -// whose route has Hibernate=true always get symlink-only and are NEVER |
| +// whose route has Hibernate=true get symlink-only by default and are NEVER |
| // woken: an hourly wake would defeat hibernation, and the next wake execs |
| // the new binary anyway since the spawn resolves live/<host> at exec time. |
| // The trade (from Eleven): no health check for a sleeper — a bad build |
| // surfaces at wake, recovered by Rollback, which repoints back. Hibernating |
| // hosts are processed AFTER every awake host, so an awake host discovers a |
| // boot-broken build (and poisons it) before any sleeper is moved onto it. |
| +// |
| +// RestartHibernating overrides only the FIRST half of that: it gives |
| +// hibernate-flagged hosts a restart callback too, because the flag means "may |
| +// sleep", not "is asleep" — a warm exec child must not go on serving a binary |
| +// its live symlink no longer names. It never makes the pass wake anything; |
| +// the option only accepts a callback that cannot start a process. See its doc |
| +// comment. Everything else above holds unchanged: sleepers still go last, |
| +// still poison a bad build, still have no health gate of their own. |
| func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, opts ...PassOption) error { |
| return PassFilter(ctx, reg, s, pub, optDir, restart, nil, opts...) |
| } |
| @@ -420,10 +470,18 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| } |
| for _, rt := range hibernating { |
| ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}] |
| - // restart=nil: hibernating hosts get the pure repoint — never woken. |
| - // The capability converge still runs for them (a drop-in is unit |
| - // text, not a wake); see adoptHost. |
| - adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg, bakeIgnore(rt)) |
| + // cfg.restartHibernating is nil by default (and for RunAdopt): the |
| + // pure repoint, never woken. The capability converge still runs for |
| + // them (a drop-in is unit text, not a wake); see adoptHost. |
| + // |
| + // When it IS set — the agent's in-process loops — the callback is |
| + // consulted for sleepers too, and the "never woken" guarantee moves |
| + // into the callback: RestartHibernating only accepts one that cannot |
| + // start a process (Bounce is false-when-not-awake, try-restart is a |
| + // no-op on a stopped unit). The flag says a route MAY sleep, not that |
| + // it is asleep now, and a warm exec child must not keep serving a |
| + // binary its symlink no longer names. |
| + adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, cfg.restartHibernating, now, cfg, bakeIgnore(rt)) |
| } |
| return nil |
| } |
| @@ -495,8 +553,12 @@ func RouteAccount(rt registry.Route) string { |
| // adoptHost converges one host onto its channel head ptr (nil = the group's |
| // pointer didn't resolve; skip quietly, the group phase already logged). |
| -// restart==nil is the symlink-only path (hibernating hosts, or a caller with |
| -// no proc manager): repoint and record, never wake, never health-gate. |
| +// restart==nil is the symlink-only path (a caller with no proc manager, or a |
| +// hibernating host on a pass that did not set RestartHibernating): repoint |
| +// and record, never wake, never health-gate. A hibernating host WITH that |
| +// option set arrives here with a non-nil callback and takes the ordinary |
| +// path — safe because the option only accepts a callback that cannot start a |
| +// process, so "never woken" survives the callback being consulted. |
| // cfg is the call's PassOptions, already applied: cfg.nag nil (the ordinary |
| // case) means the canary-TTL nag below never fires, never even builds its |
| // string, and cfg.capsOps nil means the capability converge never runs at |
| @@ -587,9 +649,12 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| // the previous version's unit shape silently. Here a manifest this pass |
| // could not obtain REFUSES the version instead (capabilityPlan). |
| // |
| - // A hibernating route reaches this with restart == nil and still |
| - // converges: a drop-in is unit text, not a wake, and nothing below |
| - // starts it. |
| + // A hibernating route converges here too: a drop-in is unit text, not a |
| + // wake, and nothing below starts it. (It reaches this with restart == nil |
| + // on an ordinary pass, or with the RestartHibernating callback on the |
| + // agent's loops — which cannot start anything either. In practice this |
| + // branch is root-only regardless: cfg.capsOps is set by RunAdopt alone, |
| + // and RunAdopt does not set RestartHibernating.) |
| var ( |
| prev caps.Snapshot |
| plan caps.Plan |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index b16f12a04..66e08cae4 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -78,6 +78,136 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) { |
| } |
| } |
| +// TestPassRestartHibernatingOptIn pins both sides of the RestartHibernating |
| +// door. Default (RunAdopt's shape, no option): hibernate-flagged routes get |
| +// the pure repoint and the callback never sees them — byte-identical to the |
| +// behavior TestPassRepointsAwakeAndHibernating above pins. Opted in (the |
| +// agent's in-process loops): the callback is consulted for sleepers too, so a |
| +// WARM exec child — minted Hibernate:true, but running right now — can be |
| +// cycled onto the binary this pass just pointed it at. |
| +// |
| +// The pass itself still starts nothing either way: all the option changes is |
| +// whether the callback is asked. Not waking a sleeper is the CALLBACK's |
| +// promise (Bounce returns false when nothing is awake; try-restart is a no-op |
| +// on a stopped unit), which is why handing it a sleeping host is safe. |
| +func TestPassRestartHibernatingOptIn(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + optIn bool |
| + want []string |
| + reason string |
| + }{ |
| + {"default: sleepers are not offered to the callback", false, |
| + []string{"awake.example.com"}, |
| + "the root adopt pass has no children of its own to cycle"}, |
| + {"opted in: sleepers are offered too", true, |
| + []string{"awake.example.com", "sleepy.example.com"}, |
| + "a warm exec child must bounce onto its new binary"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) |
| + addRoute(t, reg, "sleepy.example.com", "myapp", "canary/x", true) |
| + |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + var opts []PassOption |
| + if tc.optIn { |
| + opts = append(opts, RestartHibernating(restart)) |
| + } |
| + if err := Pass(ctx, reg, s, pub, opt, restart, opts...); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if strings.Join(restarted, ",") != strings.Join(tc.want, ",") { |
| + t.Fatalf("restarted = %v, want %v (%s)", restarted, tc.want, tc.reason) |
| + } |
| + // Either way the repoint happened for both — the option changes who |
| + // is offered to the callback, never which hosts adopt. |
| + for _, host := range []string{"awake.example.com", "sleepy.example.com"} { |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Fatalf("%s live = %q, want v1", host, v) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPassRestartHibernatingKeepsSleepersLast pins the ordering the option |
| +// must not disturb: awake hosts are still adopted BEFORE any sleeper, so an |
| +// awake host can discover a boot-broken build and poison it before a |
| +// hibernating host is moved onto it. Handing sleepers a callback must not |
| +// promote them into the first loop. |
| +func TestPassRestartHibernatingKeepsSleepersLast(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + // Named so a plain registry ordering (alphabetical by host) would put the |
| + // sleeper first — only the awake/hibernating split can produce the order |
| + // this test wants. |
| + addRoute(t, reg, "aaa-sleepy.example.com", "myapp", "canary/x", true) |
| + addRoute(t, reg, "zzz-awake.example.com", "myapp", "canary/x", false) |
| + |
| + var order []string |
| + restart := func(host string) error { order = append(order, host); return nil } |
| + if err := Pass(ctx, reg, s, pub, opt, restart, RestartHibernating(restart)); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + want := []string{"zzz-awake.example.com", "aaa-sleepy.example.com"} |
| + if strings.Join(order, ",") != strings.Join(want, ",") { |
| + t.Fatalf("restart order = %v, want %v (awake hosts must still go first)", order, want) |
| + } |
| +} |
| + |
| +// TestPassRestartHibernatingPoisonsLikeAnyOtherHost: a sleeper's failed |
| +// restart takes the same rollback the awake path takes — the symlink goes |
| +// back and the version is poisoned for the rest of the pass. Nothing about |
| +// hibernation makes a broken build safe to leave pointed at. |
| +func TestPassRestartHibernatingPoisonsLikeAnyOtherHost(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) |
| + shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("build two")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote v1: %v", err) |
| + } |
| + addRoute(t, reg, "sleepy.example.com", "myapp", "canary/x", true) |
| + ok := func(string) error { return nil } |
| + if err := Pass(ctx, reg, s, pub, opt, ok, RestartHibernating(ok)); err != nil { |
| + t.Fatalf("Pass v1: %v", err) |
| + } |
| + |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + boom := func(string) error { return errors.New("bounce failed") } |
| + if err := Pass(ctx, reg, s, pub, opt, boom, RestartHibernating(boom)); err != nil { |
| + t.Fatalf("Pass v2: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "sleepy.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want the symlink rolled back to v1", v) |
| + } |
| +} |
| + |
| // TestPassBakeDirMovesTheClock proves the BakeDir option's whole point |
| // (issue #13): the bake clock lands under the given dir, not optDir's |
| // legacy /opt-dir location. |