carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 8e4d8766ed29f0885ab4c5e18ac067d4863fc223 parent a813586
5 files changed, +338 −31
  • internal/activator/backend_exec.go +17 −9
  • internal/activator/containment.go +14 −0
  • internal/activator/prepare.go +47 −11
  • internal/activator/unitproc.go +60 −11
  • internal/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)
+}