zdd plan 2 task 11: nested wakes start carlos-instance-gen@ under containment
Under ExecBackend.GenContainment, startUnitAt selects the generation unit carlos-instance-gen@<iid>:<n> through GenControl, with a generation-keyed launch file, log socket and exit record (zero-downtime spec section 7.6). prepareAt and startUnitAt gain a trailing generation parameter; prepare, logSocket, writeLaunch and newUnitProc stay as gen=0 wrappers. A generation unitProc stops, watches and consumes only its own unit, exit record and launch file. With the flag off every unit name, file and control call is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+338
−31
internal/activator/backend_exec.go+17 −9internal/activator/containment.go+14 −0internal/activator/prepare.go+47 −11internal/activator/unitproc.go+60 −11internal/activator/unitproc_gen_test.go+200 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 7e8ad7896..d0292183f 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -1151,7 +1151,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| p := b.pathsFor(rt) |
| switch p.layout { |
| case LayoutNested: |
| - return b.startUnitAt(ctx, rt, rt.Socket) |
| + return b.startUnitAt(ctx, rt, rt.Socket, 0) |
| case LayoutFenced, LayoutConflict: |
| return nil, "", fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason) |
| } |
| @@ -1189,7 +1189,7 @@ func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64, |
| switch p.layout { |
| case LayoutNested: |
| // prepareAt removes the stale file at sock for the nested layout. |
| - proc, ver, err = b.startUnitAt(ctx, rt, sock) |
| + proc, ver, err = b.startUnitAt(ctx, rt, sock, n) |
| case LayoutFenced, LayoutConflict: |
| return nil, Gen{}, fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason) |
| default: |
| @@ -1504,14 +1504,22 @@ func (b *ExecBackend) normalizeSocketMode(rt registry.Route, sock string, proc P |
| // started by the root instance-control channel from a launch file rather |
| // than exec'd directly. The agent has no privilege to enter the tenant's |
| // namespace, only the control service does. |
| -func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string) (Proc, string, error) { |
| +// |
| +// n is the generation this spawn is (StartGen's n; 0 from Start). Under |
| +// GenContainment it selects the carlos-instance-gen@<id>:<n> unit and its |
| +// generation-keyed files (unitGen); otherwise it is ignored. |
| +func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) { |
| if err := b.nestedGate(rt); err != nil { |
| return nil, "", err |
| } |
| + gen, err := b.unitGen(n) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| if err := b.ensureLiveLink(ctx, rt.Host); err != nil { |
| return nil, "", err |
| } |
| - p, ctl, logs, fileEnv, err := b.prepareAt(rt, instancectl.RoleRun, sock) |
| + p, ctl, logs, fileEnv, err := b.prepareAt(rt, instancectl.RoleRun, sock, gen) |
| if err != nil { |
| return nil, "", err |
| } |
| @@ -1524,14 +1532,14 @@ func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock s |
| } |
| childVars := b.agentOwnedVars(rt, sock+".control", fileEnv, ctl != nil) |
| argv := []string{b.liveLink(rt.Host), "--socket", sock, "--db", p.dbPath} |
| - if err := b.writeLaunch(p, instancectl.RoleRun, rt.Host, argv, childEnv(childVars)); err != nil { |
| + if err := b.writeLaunchFor(p, instancectl.RoleRun, gen, rt.Host, argv, childEnv(childVars)); err != nil { |
| return fail(err) |
| } |
| // Read the symlink immediately before the start, same fail-safe |
| // direction as startChild: a concurrent repoint at worst records the |
| // older version (one extra convergence cycle), never the newer. |
| spawnVer, _ := b.LiveVersion(rt.Host) |
| - inv, err := b.Control.Start(ctx, rt.Host, instancectl.RoleRun, b.tier(rt).MemoryMax) |
| + inv, err := b.startRole(ctx, rt, instancectl.RoleRun, gen) |
| if err != nil { |
| return fail(fmt.Errorf("%s: start unit: %w", rt.Host, err)) |
| } |
| @@ -1539,10 +1547,10 @@ func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock s |
| // The server read the invocation after the start and found none: |
| // the unit already exited (plan 2 final review). Nothing is |
| // running, so there is nothing to stop; the wake failed. |
| - return fail(fmt.Errorf("%s: %s exited before it was observed", rt.Host, instancectl.UnitName(instancectl.RoleRun, rt.Host))) |
| + return fail(fmt.Errorf("%s: %s exited before it was observed", rt.Host, unitName(instancectl.RoleRun, rt.Host, gen))) |
| } |
| - log.Printf("activator: %s: started %s memory.max=%d", rt.Host, instancectl.UnitName(instancectl.RoleRun, rt.Host), b.tier(rt).MemoryMax) |
| - up := b.newUnitProc(rt, instancectl.RoleRun, p, inv, ctl, logs) |
| + log.Printf("activator: %s: started %s memory.max=%d", rt.Host, unitName(instancectl.RoleRun, rt.Host, gen), b.tier(rt).MemoryMax) |
| + up := b.newUnitProcFor(rt, instancectl.RoleRun, gen, p, inv, ctl, logs) |
| if b.Cache != nil { |
| // A fresh process for this host is exactly the moment its cached |
| // content becomes suspect, same rule as startChild. |
diff --git a/internal/activator/containment.go b/internal/activator/containment.go| index 3669bb8df..621d2f3a6 100644 |
| --- a/internal/activator/containment.go |
| +++ b/internal/activator/containment.go |
| @@ -7,6 +7,7 @@ import ( |
| "os/exec" |
| "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -121,6 +122,19 @@ func (b *ExecBackend) unitGen(n uint64) (uint64, error) { |
| return n, nil |
| } |
| +// startRole starts one role's unit and returns its invocation: the |
| +// original template through Control, a generation through GenControl. |
| +func (b *ExecBackend) startRole(ctx context.Context, rt registry.Route, role instancectl.Role, gen uint64) (string, error) { |
| + if gen == 0 { |
| + return b.Control.Start(ctx, rt.Host, role, b.tier(rt).MemoryMax) |
| + } |
| + gc, err := b.genControl() |
| + if err != nil { |
| + return "", err |
| + } |
| + return gc.StartGen(ctx, rt.Host, gen, b.tier(rt).MemoryMax) |
| +} |
| + |
| // ParentLimit sets rt's generation parent to l: carlos-exec@<host> for a |
| // flat or foreign host, the host slice for a nested one (spec §7.3). The |
| // swap raises it to cgroup.OverlapLimit before the successor spawns and |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index f8ac05fcb..929aea921 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -31,14 +31,17 @@ import ( |
| // and stale records cleared. It returns the listeners so the caller can |
| // hand them to the proc or close them on a failed start. |
| func (b *ExecBackend) prepare(rt registry.Route, role instancectl.Role) (instancePaths, *edge.ControlListener, *logListener, map[string]string, error) { |
| - return b.prepareAt(rt, role, rt.Socket) |
| + return b.prepareAt(rt, role, rt.Socket, 0) |
| } |
| // prepareAt is prepare for a run role whose app socket is sock: rt.Socket, |
| // or a generation socket (StartGen, zero-downtime spec §7.1). Only the |
| // file at sock is removed, never a sibling generation's, and the control |
| // listener binds sock + ".control". The seed and sidecar roles ignore sock. |
| -func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock string) (instancePaths, *edge.ControlListener, *logListener, map[string]string, error) { |
| +// gen is the generation unit being prepared (0 for the original templates): |
| +// it keys the log socket and the exit records cleared (zero-downtime spec |
| +// §7.6). |
| +func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock string, gen uint64) (instancePaths, *edge.ControlListener, *logListener, map[string]string, error) { |
| p := b.pathsFor(rt) |
| if err := os.MkdirAll(p.dataDir, 0o700); err != nil { |
| return p, nil, nil, nil, err |
| @@ -46,7 +49,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| if err := os.MkdirAll(p.runDir, 0o750|os.ModeSetgid); err != nil { |
| return p, nil, nil, nil, err |
| } |
| - if err := os.MkdirAll(filepath.Dir(b.logSocket(rt.Host, role)), 0o700); err != nil { |
| + if err := os.MkdirAll(filepath.Dir(b.logSocketFor(rt.Host, role, gen)), 0o700); err != nil { |
| return p, nil, nil, nil, err |
| } |
| var env map[string]string |
| @@ -75,7 +78,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| // this one. |
| if entries, err := os.ReadDir(p.runDir); err == nil { |
| for _, e := range entries { |
| - if strings.HasPrefix(e.Name(), "exit."+string(role)+".") { |
| + if strings.HasPrefix(e.Name(), exitPrefix(role, gen)) { |
| _ = os.Remove(filepath.Join(p.runDir, e.Name())) |
| } |
| } |
| @@ -99,7 +102,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| _ = os.Remove(sock) |
| ctl = b.listenControl(rt, sock+".control", p.envFile) |
| } |
| - logs, err := listenLogs(b.logSocket(rt.Host, role), b.logEmitter(rt)) |
| + logs, err := listenLogs(b.logSocketFor(rt.Host, role, gen), b.logEmitter(rt)) |
| if err != nil { |
| if ctl != nil { |
| ctl.Close() |
| @@ -109,6 +112,17 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| return p, ctl, logs, env, nil |
| } |
| +// exitPrefix is the exit records prepare clears before a start: this |
| +// role's for the original templates, this generation's alone otherwise. |
| +// A sibling generation's record is never touched (zero-downtime spec |
| +// §7.6). |
| +func exitPrefix(role instancectl.Role, gen uint64) string { |
| + if gen == 0 { |
| + return "exit." + string(role) + "." |
| + } |
| + return instancectl.GenExitPrefix(gen) |
| +} |
| + |
| // listenControl binds the control socket exactly as startChild does for an |
| // exec child (the tokenReader and schedule mount are unchanged, aside from |
| // reading beneath the instance directory rather than following a plain |
| @@ -134,9 +148,21 @@ func (b *ExecBackend) listenControl(rt registry.Route, control, envPath string) |
| return ctl |
| } |
| -// logSocket is the role's log socket path, honouring the test override of |
| -// the run directory (RunDirOverride). |
| +// logSocket is logSocketFor for the original templates. |
| func (b *ExecBackend) logSocket(host string, role instancectl.Role) string { |
| + return b.logSocketFor(host, role, 0) |
| +} |
| + |
| +// logSocketFor is the role's log socket path, keyed by generation for a |
| +// generation unit, honouring the test override of the run directory |
| +// (RunDirOverride). |
| +func (b *ExecBackend) logSocketFor(host string, role instancectl.Role, gen uint64) string { |
| + if gen != 0 { |
| + if b.RunDirOverride != "" { |
| + return filepath.Join(b.RunDirOverride, "logs", instancectl.GenKey(host, gen)+".run.sock") |
| + } |
| + return instancectl.GenLogSocket(host, gen) |
| + } |
| if b.RunDirOverride != "" { |
| return filepath.Join(b.RunDirOverride, "logs", provision.InstanceID(host)+"."+string(role)+".sock") |
| } |
| @@ -151,15 +177,25 @@ func (b *ExecBackend) logEmitter(rt registry.Route) func(string) { |
| return func(line string) { b.Logs.App(release.RouteAccount(rt), rt.App, rt.Host, line) } |
| } |
| -// writeLaunch writes launch.<role>.json atomically into the instance |
| -// directory (design §6.2). The env is childEnv's deduplicated list; the |
| -// trampoline execs it as is. |
| +// writeLaunch is writeLaunchFor for the original templates. |
| func (b *ExecBackend) writeLaunch(p instancePaths, role instancectl.Role, host string, argv, env []string) error { |
| + return b.writeLaunchFor(p, role, 0, host, argv, env) |
| +} |
| + |
| +// writeLaunchFor writes the launch file atomically into the instance |
| +// directory (design §6.2): launch.<role>.json, or launch.gen.<n>.json for a |
| +// generation (zero-downtime spec §7.6). The env is childEnv's deduplicated |
| +// list; the trampoline execs it as is. |
| +func (b *ExecBackend) writeLaunchFor(p instancePaths, role instancectl.Role, gen uint64, host string, argv, env []string) error { |
| raw, err := json.MarshalIndent(instancectl.Launch{Schema: instancectl.LaunchSchema, Host: host, Argv: argv, Env: env}, "", " ") |
| if err != nil { |
| return err |
| } |
| - return instancefs.WriteAtomic(p.dataDir, instancectl.LaunchFileName(role), append(raw, '\n'), 0o600) |
| + name := instancectl.LaunchFileName(role) |
| + if gen != 0 { |
| + name = instancectl.GenLaunchFileName(gen) |
| + } |
| + return instancefs.WriteAtomic(p.dataDir, name, append(raw, '\n'), 0o600) |
| } |
| // readEnvFileBeneath is readEnvFile through instancefs: a nested host's env |
diff --git a/internal/activator/unitproc.go b/internal/activator/unitproc.go| index 480a6a3a0..4fa0fcf41 100644 |
| --- a/internal/activator/unitproc.go |
| +++ b/internal/activator/unitproc.go |
| @@ -35,11 +35,15 @@ type unitProc struct { |
| unit, slice string |
| invocation string |
| runDir string |
| - ctl *edge.ControlListener |
| - logs *logListener |
| - exited atomic.Bool |
| - done chan struct{} |
| - closeOnce atomic.Bool |
| + // gen is the generation of a carlos-instance-gen@ unit, 0 for the |
| + // original templates; dataDir holds its launch file. |
| + gen uint64 |
| + dataDir string |
| + ctl *edge.ControlListener |
| + logs *logListener |
| + exited atomic.Bool |
| + done chan struct{} |
| + closeOnce atomic.Bool |
| // listenersArmed guards the done-watcher goroutine Stop's ctx.Done |
| // branch starts: several callers bound their wait on Stop shorter than |
| // the unit may actually take to die (a sidecar co-stop, a discarded |
| @@ -56,12 +60,39 @@ type unitProc struct { |
| } |
| func (b *ExecBackend) newUnitProc(rt registry.Route, role instancectl.Role, p instancePaths, inv string, ctl *edge.ControlListener, logs *logListener) *unitProc { |
| - up := &unitProc{b: b, rt: rt, role: role, unit: instancectl.UnitName(role, rt.Host), slice: instancectl.SliceName(rt.Host), |
| - invocation: inv, runDir: p.runDir, ctl: ctl, logs: logs, done: make(chan struct{})} |
| + return b.newUnitProcFor(rt, role, 0, p, inv, ctl, logs) |
| +} |
| + |
| +func (b *ExecBackend) newUnitProcFor(rt registry.Route, role instancectl.Role, gen uint64, p instancePaths, inv string, ctl *edge.ControlListener, logs *logListener) *unitProc { |
| + up := &unitProc{b: b, rt: rt, role: role, gen: gen, unit: unitName(role, rt.Host, gen), slice: instancectl.SliceName(rt.Host), |
| + invocation: inv, runDir: p.runDir, dataDir: p.dataDir, ctl: ctl, logs: logs, done: make(chan struct{})} |
| go b.observe(up) |
| return up |
| } |
| +// unitName is the unit a role of host runs as: the original template's, or |
| +// generation gen's (zero-downtime spec §7.6). |
| +func unitName(role instancectl.Role, host string, gen uint64) string { |
| + if gen == 0 { |
| + return instancectl.UnitName(role, host) |
| + } |
| + return instancectl.GenUnitName(host, gen) |
| +} |
| + |
| +// stopRole stops one role's unit: the original template's through Control, |
| +// a generation's through GenControl. A generation stop never touches a |
| +// sibling generation. |
| +func (b *ExecBackend) stopRole(ctx context.Context, host string, role instancectl.Role, gen uint64) error { |
| + if gen == 0 { |
| + return b.Control.Stop(ctx, host, role) |
| + } |
| + gc, err := b.genControl() |
| + if err != nil { |
| + return err |
| + } |
| + return gc.StopGen(ctx, host, gen, false) |
| +} |
| + |
| func (p *unitProc) Exited() bool { return p.exited.Load() } |
| // Stop asks the control service to stop the unit and waits for termination |
| @@ -75,7 +106,7 @@ func (p *unitProc) Stop(ctx context.Context) error { |
| // still fire its own foreign-invocation stop for the same unit. |
| p.stopRequested.Store(true) |
| sctx, cancel := context.WithTimeout(ctx, instancectl.DeadlineControl) |
| - err := p.b.Control.Stop(sctx, p.rt.Host, p.role) |
| + err := p.b.stopRole(sctx, p.rt.Host, p.role, p.gen) |
| cancel() |
| if err != nil { |
| log.Printf("activator: stop %s %s: %v", p.rt.Host, p.role, err) |
| @@ -171,7 +202,7 @@ func (b *ExecBackend) observe(p *unitProc) { |
| if foreign && !foreignStopped && !p.stopRequested.Load() { |
| log.Printf("activator: %s: unit restarted outside the activator (invocation %s, ours %s); stopping it", p.rt.Host, inv, p.invocation) |
| sctx, scancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl) |
| - if err := b.Control.Stop(sctx, p.rt.Host, p.role); err != nil { |
| + if err := b.stopRole(sctx, p.rt.Host, p.role, p.gen); err != nil { |
| log.Printf("activator: %s: stop foreign invocation: %v", p.rt.Host, err) |
| } |
| scancel() |
| @@ -205,6 +236,7 @@ func (b *ExecBackend) observe(p *unitProc) { |
| // clears records left over from EARLIER invocations; |
| // this one is this invocation's own to remove). |
| p.removeRecord() |
| + p.removeLaunch() |
| } else { |
| emitEvent(b.Logs, p.rt, "warn", "instance exited (status unknown: restarted outside the activator)") |
| } |
| @@ -268,8 +300,15 @@ func exitLevel(rec instancectl.ExitRecord, ok bool) string { |
| return "warn" |
| } |
| +func (p *unitProc) recordName() string { |
| + if p.gen == 0 { |
| + return instancectl.ExitRecordName(p.role, p.invocation) |
| + } |
| + return instancectl.GenExitRecordName(p.gen, p.invocation) |
| +} |
| + |
| func (p *unitProc) readRecord() (instancectl.ExitRecord, bool) { |
| - raw, err := instancefs.ReadBeneathLimit(p.runDir, instancectl.ExitRecordName(p.role, p.invocation), 4096) |
| + raw, err := instancefs.ReadBeneathLimit(p.runDir, p.recordName(), 4096) |
| if err != nil { |
| return instancectl.ExitRecord{}, false |
| } |
| @@ -286,7 +325,17 @@ func (p *unitProc) readRecord() (instancectl.ExitRecord, bool) { |
| // the remove, leaves nothing worse than what prepare's own stale-record |
| // sweep would have cleared on the NEXT invocation anyway. |
| func (p *unitProc) removeRecord() { |
| - _ = os.Remove(filepath.Join(p.runDir, instancectl.ExitRecordName(p.role, p.invocation))) |
| + _ = os.Remove(filepath.Join(p.runDir, p.recordName())) |
| +} |
| + |
| +// removeLaunch deletes a generation's launch file once the generation is |
| +// proven gone. Generation numbers are never reused, so nothing else would. |
| +// The original templates' launch file is rewritten by every start and is |
| +// left alone. os.Remove never follows the final component. |
| +func (p *unitProc) removeLaunch() { |
| + if p.gen != 0 && p.dataDir != "" { |
| + _ = os.Remove(filepath.Join(p.dataDir, instancectl.GenLaunchFileName(p.gen))) |
| + } |
| } |
| // cgroupRoot is CgroupRoot with the nil default applied. |
diff --git a/internal/activator/unitproc_gen_test.go b/internal/activator/unitproc_gen_test.go| new file mode 100644 |
| index 000000000..20e4f16c3 |
| --- /dev/null |
| +++ b/internal/activator/unitproc_gen_test.go |
| @@ -0,0 +1,200 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestPrepareAtAGenerationKeysItsFilesByGeneration(t *testing.T) { |
| + ctl := &fakeControl{} |
| + b, rt := nestedBackend(t, ctl, &fakeUnit{}) |
| + b.GenContainment = true |
| + p := b.pathsFor(rt) |
| + if err := os.MkdirAll(p.runDir, 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"exit.run.aaa.json", "exit.gen.7.bbb.json", "exit.gen.8.ccc.json"} { |
| + if err := os.WriteFile(filepath.Join(p.runDir, name), []byte("{}"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + p2, ctlL, logs, _, err := b.prepareAt(rt, instancectl.RoleRun, nestedGenSocketIn(p.runDir, 7), 7) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer logs.Close() |
| + if ctlL != nil { |
| + defer ctlL.Close() |
| + } |
| + for name, want := range map[string]bool{"exit.run.aaa.json": true, "exit.gen.7.bbb.json": false, "exit.gen.8.ccc.json": true} { |
| + if _, err := os.Stat(filepath.Join(p.runDir, name)); (err == nil) != want { |
| + t.Errorf("%s present=%v, want %v", name, err == nil, want) |
| + } |
| + } |
| + wantSock := filepath.Join(b.RunDirOverride, "logs", provision.InstanceID(rt.Host)+"@7.run.sock") |
| + if got := b.logSocketFor(rt.Host, instancectl.RoleRun, 7); got != wantSock { |
| + t.Errorf("log socket = %q, want %q", got, wantSock) |
| + } |
| + if _, err := os.Stat(wantSock); err != nil { |
| + t.Errorf("generation log listener not bound: %v", err) |
| + } |
| + if err := b.writeLaunchFor(p2, instancectl.RoleRun, 7, rt.Host, []string{"/opt/carlos/live/" + rt.Host}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(p2.dataDir, "launch.gen.7.json")); err != nil { |
| + t.Errorf("launch.gen.7.json: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(p2.dataDir, "launch.run.json")); !os.IsNotExist(err) { |
| + t.Errorf("a generation wrote the legacy launch file: %v", err) |
| + } |
| +} |
| + |
| +func TestGenerationUnitProcStopsAndWatchesItsOwnUnit(t *testing.T) { |
| + ctl := &fakeControl{} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + b.GenContainment = true |
| + b.Control = fakeGenControl{ctl} |
| + var mu sync.Mutex |
| + var seen []string |
| + b.UnitState = func(ctx context.Context, name string) (string, string, error) { |
| + mu.Lock() |
| + seen = append(seen, name) |
| + mu.Unlock() |
| + return unit.state(ctx, name) |
| + } |
| + unit.set("active", "inv7", true, true) |
| + ctl.stopHook = func(string, instancectl.Role) { unit.set("inactive", "inv7", false, false) } |
| + up := b.newUnitProcFor(rt, instancectl.RoleRun, 7, b.pathsFor(rt), "inv7", nil, nil) |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + if err := up.Stop(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := ctl.joined(); !strings.Contains(got, "stopgen h.example.com 7 kill=false") || strings.Contains(got, "stop h.example.com run") { |
| + t.Errorf("calls =\n%s", got) |
| + } |
| + want := instancectl.GenUnitName(rt.Host, 7) |
| + if up.unit != want { |
| + t.Errorf("unit = %q, want %q", up.unit, want) |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if len(seen) == 0 { |
| + t.Fatal("the observer never asked about the unit") |
| + } |
| + for _, name := range seen { |
| + if name != want { |
| + t.Errorf("the observer asked about %s, want %s", name, want) |
| + } |
| + } |
| +} |
| + |
| +func TestGenerationUnitProcConsumesOnlyItsOwnRecordAndLaunchFile(t *testing.T) { |
| + ctl := &fakeControl{} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + b.GenContainment = true |
| + b.Control = fakeGenControl{ctl} |
| + p := b.pathsFor(rt) |
| + for _, d := range []string{p.runDir, p.dataDir} { |
| + if err := os.MkdirAll(d, 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + rec, _ := json.Marshal(instancectl.ExitRecord{Invocation: "inv7", Result: "success", Code: "exited", Status: "0"}) |
| + for dir, names := range map[string][]string{ |
| + p.runDir: {"exit.gen.7.inv7.json", "exit.run.inv7.json"}, |
| + p.dataDir: {"launch.gen.7.json", "launch.gen.8.json"}, |
| + } { |
| + for _, name := range names { |
| + if err := os.WriteFile(filepath.Join(dir, name), rec, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + unit.set("active", "inv7", true, true) |
| + up := b.newUnitProcFor(rt, instancectl.RoleRun, 7, p, "inv7", nil, nil) |
| + unit.set("inactive", "inv7", false, false) |
| + waitFor(t, "the generation to exit", up.Exited) |
| + for path, want := range map[string]bool{ |
| + filepath.Join(p.runDir, "exit.gen.7.inv7.json"): false, |
| + filepath.Join(p.runDir, "exit.run.inv7.json"): true, |
| + filepath.Join(p.dataDir, "launch.gen.7.json"): false, |
| + filepath.Join(p.dataDir, "launch.gen.8.json"): true, |
| + } { |
| + if _, err := os.Stat(path); (err == nil) != want { |
| + t.Errorf("%s present=%v, want %v", path, err == nil, want) |
| + } |
| + } |
| +} |
| + |
| +func TestStartRoleDispatchesByGeneration(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv"} |
| + b, rt := nestedBackend(t, ctl, &fakeUnit{}) |
| + b.Control = fakeGenControl{ctl} |
| + if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := "start h.example.com run\nstartgen h.example.com 7 536870912"; ctl.joined() != want { |
| + t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want) |
| + } |
| +} |
| + |
| +func TestUnitGen(t *testing.T) { |
| + b := testExecBackend(t) |
| + if n, err := b.unitGen(7); n != 0 || err != nil { |
| + t.Errorf("containment off: %d %v, want 0", n, err) |
| + } |
| + b.GenContainment = true |
| + if n, err := b.unitGen(7); n != 7 || err != nil { |
| + t.Errorf("containment on: %d %v, want 7", n, err) |
| + } |
| + if _, err := b.unitGen(0); err == nil { |
| + t.Error("generation 0 accepted") |
| + } |
| +} |
| + |
| +// Flag off: a nested wake starts the original template, with the original |
| +// launch file, log socket and unit name. |
| +func TestStartUnitWithContainmentOffKeepsTheOriginalTemplate(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + var stopServing func() |
| + ctl.onStart = func() { stopServing = serveOK(t, rt.Socket) } |
| + defer func() { |
| + if stopServing != nil { |
| + stopServing() |
| + } |
| + }() |
| + unit.set("active", "inv1", true, true) |
| + p, _, err := b.Start(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("Start: %v", err) |
| + } |
| + up := p.(*unitProc) |
| + if up.unit != "carlos-instance@h.example.com.service" || up.gen != 0 { |
| + t.Errorf("unit = %q gen %d", up.unit, up.gen) |
| + } |
| + if strings.Contains(ctl.joined(), "startgen") { |
| + t.Errorf("a flag-off wake used the generation verb:\n%s", ctl.joined()) |
| + } |
| + if _, err := os.Stat(b.logSocket(rt.Host, instancectl.RoleRun)); err != nil { |
| + t.Errorf("legacy log socket: %v", err) |
| + } |
| + unit.set("inactive", "inv1", false, false) |
| + waitFor(t, "the unit to exit", p.Exited) |
| +} |