activator: the nested spawn path: prepare, launch file, unitProc and the exit observer (plan 3, task 4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+1051
−58
internal/activator/backend_exec.go+372 −51internal/activator/backend_unit.go+10 −5internal/activator/paths.go+14 −2internal/activator/prepare.go+162 −0internal/activator/unitproc.go+216 −0internal/activator/unitproc_test.go+277 −0
CI passed — run details
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 06c7a4573..7313baace 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -23,6 +23,7 @@ import ( |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -110,6 +111,36 @@ type ExecBackend struct { |
| // Layout supplies the box facts the classification of §8.1 needs beyond |
| // the row: journals and peers. Zero value: never fenced, never conflict. |
| Layout LayoutInputs |
| + // Control is the root instance-control channel a nested wake talks to |
| + // (design §5.3). Nil refuses every nested wake with "instance control |
| + // channel not configured" rather than dialing nothing — a flat or |
| + // foreign host never needs it, and a nil Control does not touch them. |
| + Control Control |
| + // UnitState answers `systemctl show` for the observer (design §6.5). |
| + // Nil: systemctlUnitState, the real systemctl binary. |
| + UnitState UnitState |
| + // CgroupEvents reads a unit's cgroup.events for the observer and for a |
| + // seed's cgroup-empty wait. Nil: cgroupEventsUnder("/sys/fs/cgroup"), |
| + // or CgroupRoot when set. |
| + CgroupEvents CgroupEvents |
| + // CgroupRoot overrides where cgroup v2 is mounted, for the default |
| + // CgroupEvents reader. Nil (empty): /sys/fs/cgroup. |
| + CgroupRoot string |
| + // NestedOK gates a nested wake on the box facts a unit test can't see: |
| + // the isolation mounts, the control service's own /ready. Nil: no |
| + // gate (every unit test and any box that predates the check). Wired in |
| + // production by Task 6. |
| + NestedOK func() error |
| + // ObservePoll is how often the exit observer (unitproc.go) and a |
| + // seed's cgroup-empty wait re-check unit state. 0: one second. A field |
| + // so tests can shrink the wait without a package-global var shared |
| + // across every test in the suite. |
| + ObservePoll time.Duration |
| + // RunDirOverride is test-only: when set, layoutInputs() classifies |
| + // against it instead of provision.SocketDir and pathsFor resolves a |
| + // nested run dir and log socket beneath it, so a test can exercise the |
| + // nested layout without writing to /run/carlos. |
| + RunDirOverride string |
| } |
| // execLogLineMax caps one teed log line (spec §5): longer is truncated with |
| @@ -328,21 +359,61 @@ var restoreReplica = lease.Restore |
| // if your own env file sets them, those two names being the agent's to |
| // author. A non-zero exit fails the wake: |
| // a half-seeded instance must not serve. |
| +// Seed dispatches by layout, the same way Start does: a nested row seeds |
| +// through its own launch file and unit, a fenced or conflicting row |
| +// refuses, and a flat or foreign row seeds through today's exec child. |
| func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| - host := rt.Host |
| + p := b.pathsFor(rt) |
| + switch p.layout { |
| + case LayoutNested: |
| + return b.seedUnit(ctx, rt) |
| + case LayoutFenced, LayoutConflict: |
| + return fmt.Errorf("%s: refusing to seed: %s", rt.Host, p.reason) |
| + } |
| + return b.seedChild(ctx, rt) |
| +} |
| + |
| +// seedArgv parses rt.Seed's JSON argv array and appends the --db flag, the |
| +// composition both spawn paths need identically (design §6.2) — an empty |
| +// declared seed (an empty array, len 0) returns a nil argv, which both |
| +// callers treat as "nothing to run" (doWake only calls Seed for a |
| +// non-empty rt.Seed; this stays defensive rather than assuming that). |
| +func seedArgv(rt registry.Route, dbPath string) ([]string, error) { |
| var seedArgs []string |
| if err := json.Unmarshal([]byte(rt.Seed), &seedArgs); err != nil { |
| // Never a silent skip: a mangled seed column must fail the wake |
| // loudly, not boot an empty instance that looks healthy. |
| - return fmt.Errorf("parse seed argv for %s (%q): %w", host, rt.Seed, err) |
| + return nil, fmt.Errorf("parse seed argv for %s (%q): %w", rt.Host, rt.Seed, err) |
| } |
| if len(seedArgs) == 0 { |
| + return nil, nil |
| + } |
| + return append(seedArgs, "--db", dbPath), nil |
| +} |
| + |
| +// removeSeedDB deletes a failed seed's own half-built database and its |
| +// WAL/SHM siblings — the cleanup both spawn paths run when, and only when, |
| +// seedOwnsDB says the database is the seed's own work (seedCreatedTheDB). |
| +// Shared so the two paths cannot drift on which files "the database" means. |
| +func removeSeedDB(dbPath string) { |
| + for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { |
| + _ = os.Remove(p) |
| + } |
| +} |
| + |
| +func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error { |
| + host := rt.Host |
| + paths := b.pathsFor(rt) |
| + argv, err := seedArgv(rt, paths.dbPath) |
| + if err != nil { |
| + return err |
| + } |
| + if argv == nil { |
| return nil // defensive: doWake only calls us for a non-empty seed |
| } |
| if err := b.ensureLiveLink(ctx, host); err != nil { |
| return err |
| } |
| - paths := b.pathsFor(rt) |
| // Mint before the env is read, exactly as Start does — a seed is the FIRST |
| // thing a fresh instance runs, so this is usually where the token comes |
| // into existence, and instanceEnv's contract is that a seed sees the same |
| @@ -364,7 +435,6 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| ctx, cancel := context.WithTimeout(ctx, seedTimeout) |
| defer cancel() |
| - argv := append(seedArgs, "--db", dbPath) |
| var stderr capBuffer |
| log.Printf("activator: seeding %s: %s %s", host, b.liveLink(host), strings.Join(argv, " ")) |
| // The seed shares the instance's carlos-exec@<host> cgroup: first-boot |
| @@ -432,9 +502,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| // database that wasn't there when we started is ours to remove, and |
| // under the lease nothing else can have written it. |
| if seedOwnsDB { |
| - for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { |
| - _ = os.Remove(p) |
| - } |
| + removeSeedDB(dbPath) |
| } |
| if tail := strings.TrimSpace(stderr.String()); tail != "" { |
| return fmt.Errorf("seeding %s: %w: %s", host, err, tail) |
| @@ -495,6 +563,106 @@ func (c *capBuffer) String() string { |
| return string(c.b) |
| } |
| +// seedUnit is Seed's nested body: the same contract as seedChild — the |
| +// merged env, the CARLOS_ADMIN_TOKEN mint, the cleanup on a failure this |
| +// call itself caused — run through the root instance-control channel |
| +// instead of a direct exec (design §6.2). Argv and env come from the same |
| +// seedArgv and withoutVars calls seedChild uses, so the two cannot drift. |
| +func (b *ExecBackend) seedUnit(ctx context.Context, rt registry.Route) error { |
| + host := rt.Host |
| + if b.Control == nil { |
| + return fmt.Errorf("%s: instance control channel not configured", host) |
| + } |
| + if err := b.ensureLiveLink(ctx, host); err != nil { |
| + return err |
| + } |
| + p, _, logs, fileEnv, err := b.prepare(rt, instancectl.RoleSeed) |
| + if err != nil { |
| + return err |
| + } |
| + defer logs.Close() |
| + dbPath := p.dbPath |
| + seedArgs, err := seedArgv(rt, dbPath) |
| + if err != nil { |
| + return err |
| + } |
| + if seedArgs == nil { |
| + return nil // defensive: doWake only calls us for a non-empty seed |
| + } |
| + argv := append([]string{b.liveLink(host)}, seedArgs...) |
| + env := childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| + if err := b.writeLaunch(p, instancectl.RoleSeed, host, argv, env); err != nil { |
| + return err |
| + } |
| + // Whether the database existed BEFORE the seed ran decides whether a |
| + // failed seed may clean up after itself below — seedChild's rule, |
| + // unchanged for the unit path. |
| + _, statErr := os.Stat(dbPath) |
| + seedOwnsDB := seedCreatedTheDB(statErr) |
| + |
| + sctx, cancel := context.WithTimeout(ctx, seedTimeout) |
| + defer cancel() |
| + log.Printf("activator: seeding %s: %s", host, strings.Join(argv, " ")) |
| + res, err := b.Control.Seed(sctx, host, b.tier(rt).MemoryMax) |
| + if err != nil { |
| + if seedOwnsDB { |
| + removeSeedDB(dbPath) |
| + } |
| + return fmt.Errorf("seed for %s: %w", host, err) |
| + } |
| + // §6.2: the result the control service reports is only trustworthy once |
| + // the seed's cgroup is provably empty — systemd reporting the unit |
| + // inactive is not the same fact as no process from it still running and |
| + // possibly still writing the database this result is about to judge. |
| + if err := b.waitSeedCgroupEmpty(ctx, rt); err != nil { |
| + log.Printf("activator: %s: seed cgroup did not empty: %v", host, err) |
| + } |
| + if res.Result != "success" { |
| + if seedOwnsDB { |
| + removeSeedDB(dbPath) |
| + } |
| + return fmt.Errorf("seed for %s failed: exit %s (%s)", host, res.Status, res.Result) |
| + } |
| + log.Printf("activator: seeded %s", host) |
| + return nil |
| +} |
| + |
| +// waitSeedCgroupEmpty polls the seed unit's cgroup.events until it reports |
| +// empty or actStopTimeout elapses, honouring ctx and b.ObservePoll like the |
| +// exit observer (unitproc.go). A timeout or a read error is returned to the |
| +// caller as a log line, not a failure: it is the deciding result's |
| +// confidence that is at stake here, not the wake itself. |
| +func (b *ExecBackend) waitSeedCgroupEmpty(ctx context.Context, rt registry.Route) error { |
| + events := b.CgroupEvents |
| + if events == nil { |
| + events = cgroupEventsUnder(b.cgroupRoot()) |
| + } |
| + poll := b.ObservePoll |
| + if poll <= 0 { |
| + poll = time.Second |
| + } |
| + slice := instancectl.SliceName(rt.Host) |
| + unit := instancectl.UnitName(instancectl.RoleSeed, rt.Host) |
| + deadline := time.Now().Add(actStopTimeout) |
| + for { |
| + exists, populated, err := events(slice, unit) |
| + if err != nil { |
| + return err |
| + } |
| + if !exists || !populated { |
| + return nil |
| + } |
| + if time.Now().After(deadline) { |
| + return fmt.Errorf("processes remained after %s", actStopTimeout) |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-time.After(poll): |
| + } |
| + } |
| +} |
| + |
| // NextDue asks the (still-running) instance for its earliest pending scheduled |
| // delivery over its socket. The endpoint is admin-gated like every instance |
| // data endpoint, so present the instance's own admin token — read from the |
| @@ -551,7 +719,24 @@ func (b *ExecBackend) ensureLiveLink(ctx context.Context, host string) error { |
| return fmt.Errorf("no live binary for %s (%s missing and no Materialize hook)", host, bin) |
| } |
| +// Start dispatches a wake by layout (design §8.1): a nested row starts as a |
| +// unit through the root instance-control channel, a fenced or conflicting |
| +// row refuses outright, and a flat or foreign row runs today's exec child. |
| func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, string, error) { |
| + p := b.pathsFor(rt) |
| + switch p.layout { |
| + case LayoutNested: |
| + return b.startUnit(ctx, rt) |
| + case LayoutFenced, LayoutConflict: |
| + return nil, "", fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason) |
| + } |
| + return b.startChild(ctx, rt) |
| +} |
| + |
| +// startChild is Start's exec-child body (flat and foreign layouts): |
| +// exec of the versioned binary directly, exactly as before nested layouts |
| +// existed. |
| +func (b *ExecBackend) startChild(ctx context.Context, rt registry.Route) (Proc, string, error) { |
| host := rt.Host |
| paths := b.pathsFor(rt) |
| dbPath := paths.dbPath |
| @@ -650,22 +835,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // the rollout ordering harmless. It does not replace the socket channel; |
| // both are live for exec-backed instances, and converging them is deferred |
| // (spec §10.3). |
| - childVars := fileEnv |
| - if ctl != nil { |
| - childVars = withVar(childVars, "CARLOS_CONTROL_SOCKET", controlSocket) |
| - } else { |
| - childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET") |
| - } |
| - if b.Cache != nil { |
| - childVars = withVar(childVars, "CARLOS_PURGE", "header") |
| - } else { |
| - childVars = withoutVars(childVars, "CARLOS_PURGE") |
| - } |
| - if b.Region != "" { |
| - childVars = withVar(childVars, "CARLOS_REGION", b.Region) |
| - } else { |
| - childVars = withoutVars(childVars, "CARLOS_REGION") |
| - } |
| + childVars := b.agentOwnedVars(rt, fileEnv, ctl != nil) |
| // Record the build we're about to exec — read the symlink immediately before |
| // the start so it names the version the kernel will resolve. Reading it here, |
| // rather than back in wake() after the health wait, closes a real window: a |
| @@ -736,35 +906,136 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| _ = p.Stop(stopCtx) |
| return nil, "", err |
| } |
| - // The instance-socket mode is the PLATFORM's, not the app's (issue #103): |
| - // normalize to 0660 so a deployment's enrolled cross-user consumers (the |
| - // carlos-sockets group — group-inherited from the setgid /run/carlos, |
| - // see host.tmpfilesConf) can dial, no matter what the child's umask or |
| - // its own post-bind chmod produced — titogo tightened its socket to 0600 |
| - // and stranded the sibling router that proxies to it. After the health |
| - // wait, so the app's own bind/chmod has already happened and this wins. |
| - // |
| - // The two guards make "only a socket THIS backend's child bound" true |
| - // rather than merely intended. waitHealthy tolerates a squatter (its own |
| - // contract — it only dials), so a child that lost the bind race with a |
| - // stray supervisor (EADDRINUSE, exits) can still see the health wait |
| - // succeed off the SQUATTER's answers; a dead child means the socket is |
| - // not ours to widen (that route is the #17 external-socket advisory's |
| - // territory). The Lstat keeps the chmod from ever following a |
| - // non-socket — a symlink planted at the route's path must not become a |
| - // mode change somewhere else. On a box with no consumers group the |
| - // socket's group is just `carlos`, so the wider mode grants nobody |
| - // anything. All three branches log and never fail the wake: a box with |
| - // no consumers must not lose a healthy wake to a normalization it |
| - // doesn't need. |
| - if p.Exited() { |
| + b.normalizeSocketMode(rt, p) |
| + return p, spawnVer, nil |
| +} |
| + |
| +// agentOwnedVars composes the child's agent-owned environment variables — |
| +// the control socket, the purge channel, the region — on top of fileEnv, |
| +// exactly as startChild did inline before the unit spawn path needed the |
| +// same composition. Extracted so the two paths cannot drift (design §7.2): |
| +// haveControl is whether this call's control listener actually bound |
| +// (childVars must follow the OUTCOME of the bind, not the intention — see |
| +// the long comment this replaced, still true here). |
| +func (b *ExecBackend) agentOwnedVars(rt registry.Route, fileEnv map[string]string, haveControl bool) map[string]string { |
| + childVars := fileEnv |
| + if haveControl { |
| + childVars = withVar(childVars, "CARLOS_CONTROL_SOCKET", rt.Socket+".control") |
| + } else { |
| + childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET") |
| + } |
| + if b.Cache != nil { |
| + childVars = withVar(childVars, "CARLOS_PURGE", "header") |
| + } else { |
| + childVars = withoutVars(childVars, "CARLOS_PURGE") |
| + } |
| + if b.Region != "" { |
| + childVars = withVar(childVars, "CARLOS_REGION", b.Region) |
| + } else { |
| + childVars = withoutVars(childVars, "CARLOS_REGION") |
| + } |
| + return childVars |
| +} |
| + |
| +// normalizeSocketMode is the PLATFORM's 0660 on a spawned instance's socket |
| +// (issue #103), not the app's — extracted from startChild so the unit path |
| +// (whose own bind is the trampoline's, not this process's exec) applies the |
| +// identical rule rather than a second copy that can drift. So a deployment's |
| +// enrolled cross-user consumers (the carlos-sockets group — group-inherited |
| +// from the setgid /run/carlos, see host.tmpfilesConf) can dial, no matter |
| +// what the child's umask or its own post-bind chmod produced — titogo |
| +// tightened its socket to 0600 and stranded the sibling router that proxies |
| +// to it. Called after the health wait, so the app's own bind/chmod has |
| +// already happened and this wins. |
| +// |
| +// The two guards make "only a socket THIS backend's child bound" true |
| +// rather than merely intended. waitHealthy tolerates a squatter (its own |
| +// contract — it only dials), so a child that lost the bind race with a |
| +// stray supervisor (EADDRINUSE, exits) can still see the health wait |
| +// succeed off the SQUATTER's answers; a dead child means the socket is |
| +// not ours to widen (that route is the #17 external-socket advisory's |
| +// territory). The Lstat keeps the chmod from ever following a |
| +// non-socket — a symlink planted at the route's path must not become a |
| +// mode change somewhere else. On a box with no consumers group the |
| +// socket's group is just `carlos`, so the wider mode grants nobody |
| +// anything. All three branches log and never fail the wake: a box with |
| +// no consumers must not lose a healthy wake to a normalization it |
| +// doesn't need. |
| +func (b *ExecBackend) normalizeSocketMode(rt registry.Route, proc Proc) { |
| + host := rt.Host |
| + if proc.Exited() { |
| log.Printf("activator: %s: child exited during the health wait (external process serving its socket?) — not normalizing the socket mode", host) |
| } else if fi, statErr := os.Lstat(rt.Socket); statErr != nil || fi.Mode()&os.ModeSocket == 0 { |
| log.Printf("activator: %s: %s is not a socket we can normalize (stat: %v)", host, rt.Socket, statErr) |
| } else if err := os.Chmod(rt.Socket, 0o660); err != nil { |
| log.Printf("activator: normalize socket mode for %s: %v (enrolled consumers would get permission denied)", host, err) |
| } |
| - return p, spawnVer, nil |
| +} |
| + |
| +// startUnit is Start's nested body (design §6): the same admin token, env |
| +// composition and health gate as startChild, but the process itself is |
| +// 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) startUnit(ctx context.Context, rt registry.Route) (Proc, string, error) { |
| + if b.Control == nil { |
| + return nil, "", fmt.Errorf("%s: instance control channel not configured", rt.Host) |
| + } |
| + if b.NestedOK != nil { |
| + if err := b.NestedOK(); err != nil { |
| + return nil, "", fmt.Errorf("%s: refusing to wake an isolated instance: %w", rt.Host, err) |
| + } |
| + } |
| + if err := b.ensureLiveLink(ctx, rt.Host); err != nil { |
| + return nil, "", err |
| + } |
| + p, ctl, logs, fileEnv, err := b.prepare(rt, instancectl.RoleRun) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + fail := func(err error) (Proc, string, error) { |
| + if ctl != nil { |
| + ctl.Close() |
| + } |
| + logs.Close() |
| + return nil, "", err |
| + } |
| + childVars := b.agentOwnedVars(rt, fileEnv, ctl != nil) |
| + argv := []string{b.liveLink(rt.Host), "--socket", rt.Socket, "--db", p.dbPath} |
| + if err := b.writeLaunch(p, instancectl.RoleRun, 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) |
| + if err != nil { |
| + return fail(fmt.Errorf("%s: start unit: %w", rt.Host, err)) |
| + } |
| + if inv == "" { |
| + // 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))) |
| + } |
| + 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) |
| + if b.Cache != nil { |
| + // A fresh process for this host is exactly the moment its cached |
| + // content becomes suspect, same rule as startChild. |
| + if n := b.Cache.PurgeHost(rt.Host); n > 0 { |
| + log.Printf("activator: purged %d cached response(s) for %s on spawn", n, rt.Host) |
| + } |
| + } |
| + if err := waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout); err != nil { |
| + stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + defer cancel() |
| + _ = up.Stop(stopCtx) |
| + return nil, "", err |
| + } |
| + b.normalizeSocketMode(rt, up) |
| + return up, spawnVer, nil |
| } |
| // instanceEnv composes the host's two EnvironmentFile layers, both optional |
| @@ -779,11 +1050,20 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // environment" includes the same secret.) |
| func (b *ExecBackend) instanceEnv(rt registry.Route) (map[string]string, error) { |
| paths := b.pathsFor(rt) |
| - cfgEnv, err := readEnvFile(paths.configEnv) |
| + // A nested host's files live under a tenant-writable directory (design |
| + // §3.2): read them without following a symlink. A flat host's files sit |
| + // outside any tenant's reach, so the plain read stays exactly as it was. |
| + read := readEnvFile |
| + if paths.layout == LayoutNested { |
| + read = func(path string) (map[string]string, error) { |
| + return readEnvFileBeneath(filepath.Dir(path), filepath.Base(path)) |
| + } |
| + } |
| + cfgEnv, err := read(paths.configEnv) |
| if err != nil && !os.IsNotExist(err) { |
| return nil, fmt.Errorf("read config env file: %w", err) |
| } |
| - ownEnv, err := readEnvFile(paths.envFile) |
| + ownEnv, err := read(paths.envFile) |
| if err != nil && !os.IsNotExist(err) { |
| return nil, fmt.Errorf("read env file: %w", err) |
| } |
| @@ -888,13 +1168,25 @@ func (b *ExecBackend) HasSidecar(rt registry.Route) bool { |
| return err == nil |
| } |
| -// StartSidecar execs the host's live symlink with `sidecar run` under the |
| -// sidecar's EnvironmentFile, CARLOS_*-clean like the instance spawn. No |
| +// StartSidecar dispatches by layout, the same way Start does. |
| +func (b *ExecBackend) StartSidecar(ctx context.Context, rt registry.Route) (Proc, error) { |
| + p := b.pathsFor(rt) |
| + switch p.layout { |
| + case LayoutNested: |
| + return b.startSidecarUnit(ctx, rt) |
| + case LayoutFenced, LayoutConflict: |
| + return nil, fmt.Errorf("%s: refusing to start sidecar: %s", rt.Host, p.reason) |
| + } |
| + return b.startSidecarChild(ctx, rt) |
| +} |
| + |
| +// startSidecarChild execs the host's live symlink with `sidecar run` under |
| +// the sidecar's EnvironmentFile, CARLOS_*-clean like the instance spawn. No |
| // health wait — the sidecar is a pure outbound client with no socket to |
| // probe; its own connect/backoff loop absorbs a slow instance. The live |
| // symlink exists by now: the instance spawn checked it. The ctx is unused (an |
| // exec doesn't block); it's here for the backend seam's symmetry. |
| -func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, error) { |
| +func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (Proc, error) { |
| fileEnv, err := readEnvFile(b.pathsFor(rt).sidecarEnv) |
| if err != nil { |
| return nil, fmt.Errorf("read sidecar env: %w", err) |
| @@ -923,6 +1215,35 @@ func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, |
| return p, nil |
| } |
| +// startSidecarUnit is StartSidecar's nested body: a sidecar has no health |
| +// wait either way, and no control listener of its own — sidecar env is a |
| +// single layer, read straight from its own file, never merged with the |
| +// instance's two-layer config. |
| +func (b *ExecBackend) startSidecarUnit(ctx context.Context, rt registry.Route) (Proc, error) { |
| + if b.Control == nil { |
| + return nil, fmt.Errorf("%s: instance control channel not configured", rt.Host) |
| + } |
| + p, _, logs, fileEnv, err := b.prepare(rt, instancectl.RoleSidecar) |
| + if err != nil { |
| + return nil, err |
| + } |
| + argv := []string{b.liveLink(rt.Host), "sidecar", "run"} |
| + if err := b.writeLaunch(p, instancectl.RoleSidecar, rt.Host, argv, childEnv(fileEnv)); err != nil { |
| + logs.Close() |
| + return nil, err |
| + } |
| + inv, err := b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax) |
| + if err != nil { |
| + logs.Close() |
| + return nil, fmt.Errorf("%s: start sidecar unit: %w", rt.Host, err) |
| + } |
| + if inv == "" { |
| + logs.Close() |
| + return nil, fmt.Errorf("%s: %s exited before it was observed", rt.Host, instancectl.UnitName(instancectl.RoleSidecar, rt.Host)) |
| + } |
| + return b.newUnitProc(rt, instancectl.RoleSidecar, p, inv, nil, logs), nil |
| +} |
| + |
| // socketClient returns an HTTP client that dials an instance's unix socket |
| // (request URLs use the placeholder host http://carlos). |
| func socketClient(socket string) *http.Client { |
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go| index 992e5423f..4969187c8 100644 |
| --- a/internal/activator/backend_unit.go |
| +++ b/internal/activator/backend_unit.go |
| @@ -155,7 +155,7 @@ func (b *UnitBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| _ = b.systemctl(stopCtx, "stop", unit) |
| return nil, "", err |
| } |
| - return &unitProc{b: b, unit: unit}, spawnVer, nil |
| + return &svcUnitProc{b: b, unit: unit}, spawnVer, nil |
| } |
| func (b *UnitBackend) waitHealthy(ctx context.Context, rt registry.Route) error { |
| @@ -187,8 +187,13 @@ func (b *UnitBackend) StartSidecar(context.Context, registry.Route) (Proc, error |
| return nil, errors.New("unit-backed tenants have no agent-managed sidecars") |
| } |
| -// unitProc adapts the unit to the Proc seam. |
| -type unitProc struct { |
| +// svcUnitProc adapts a UnitBackend-managed systemd unit (carlos-app@<host>.service, |
| +// a whole-app tenant whose process manager stays systemd) to the Proc seam. |
| +// Named apart from the isolated-exec unitProc in unitproc.go, which is a |
| +// different thing entirely: this one wraps a tenant unit the operator |
| +// authored, that one is a nested exec instance the activator itself spawns |
| +// through the root instance-control channel. |
| +type svcUnitProc struct { |
| b *UnitBackend |
| unit string |
| @@ -198,7 +203,7 @@ type unitProc struct { |
| // Stop is `systemctl stop` — systemd runs the unit's own SIGTERM drain and |
| // the ctx bounds how long we wait for the job. |
| -func (p *unitProc) Stop(ctx context.Context) error { |
| +func (p *svcUnitProc) Stop(ctx context.Context) error { |
| err := p.b.systemctl(ctx, "stop", p.unit) |
| p.mu.Lock() |
| p.stopped = true |
| @@ -214,7 +219,7 @@ func (p *unitProc) Stop(ctx context.Context) error { |
| // wedged past its start rate limit looks awake until its idle window stops |
| // it (the edge's dials 502 meanwhile) — operator territory, exactly as it |
| // is for a non-hibernating unit today. |
| -func (p *unitProc) Exited() bool { |
| +func (p *svcUnitProc) Exited() bool { |
| p.mu.Lock() |
| defer p.mu.Unlock() |
| return p.stopped |
diff --git a/internal/activator/paths.go b/internal/activator/paths.go| index e1fe4a40e..00d207288 100644 |
| --- a/internal/activator/paths.go |
| +++ b/internal/activator/paths.go |
| @@ -28,7 +28,13 @@ func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths { |
| p := instancePaths{layout: l, reason: reason, dataDir: b.DataDir, runDir: provision.SocketDir} |
| if l == LayoutNested { |
| p.dataDir = provision.InstanceDir(b.DataDir, rt.Host) |
| - p.runDir = provision.InstanceRunDir(rt.Host) |
| + if b.RunDirOverride != "" { |
| + // Test-only: a test points the nested run dir at a temp dir |
| + // rather than /run/carlos, exactly as it points DataDir at one. |
| + p.runDir = filepath.Join(b.RunDirOverride, "instances", provision.InstanceID(rt.Host)) |
| + } else { |
| + p.runDir = provision.InstanceRunDir(rt.Host) |
| + } |
| } |
| p.dbPath = rt.DBPath |
| if p.dbPath == "" { |
| @@ -40,9 +46,15 @@ func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths { |
| return p |
| } |
| -// layoutInputs is the backend's LayoutInputs with DataDir filled in. |
| +// layoutInputs is the backend's LayoutInputs with DataDir filled in. A |
| +// non-empty RunDirOverride also steers the socket dir Classify tests |
| +// against, so a route built against the override dir still classifies as |
| +// nested through Task 2's sockets() helper. |
| func (b *ExecBackend) layoutInputs() LayoutInputs { |
| in := b.Layout |
| in.DataDir = b.DataDir |
| + if b.RunDirOverride != "" { |
| + in.SocketDir = b.RunDirOverride |
| + } |
| return in |
| } |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| new file mode 100644 |
| index 000000000..5322e0653 |
| --- /dev/null |
| +++ b/internal/activator/prepare.go |
| @@ -0,0 +1,162 @@ |
| +// prepare.go is everything a nested role needs before its unit starts |
| +// (isolated-exec design §6.1): the launch file's directories, the merged |
| +// environment, the control listener (run only) and the role's log |
| +// listener. It is the nested counterpart of what startChild/seedChild do |
| +// inline for a flat host. |
| +package activator |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +// prepare is everything a nested role needs before its unit starts |
| +// (design §6.1): the two directories, the admin token and merged env (for |
| +// run and seed), the control listener (run only), the role's log listener, |
| +// 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) { |
| + p := b.pathsFor(rt) |
| + if err := os.MkdirAll(p.dataDir, 0o700); err != nil { |
| + return p, nil, nil, nil, err |
| + } |
| + 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 { |
| + return p, nil, nil, nil, err |
| + } |
| + var env map[string]string |
| + if role == instancectl.RoleRun || role == instancectl.RoleSeed { |
| + if err := EnsureAdminToken(p.envFile); err != nil { |
| + return p, nil, nil, nil, fmt.Errorf("admin token for %s: %w", rt.Host, err) |
| + } |
| + var err error |
| + env, err = b.instanceEnv(rt) |
| + if err != nil { |
| + return p, nil, nil, nil, err |
| + } |
| + } else { |
| + var err error |
| + env, err = readEnvFileBeneath(p.dataDir, filepath.Base(p.sidecarEnv)) |
| + if err != nil { |
| + return p, nil, nil, nil, err |
| + } |
| + } |
| + // Stale exit records from earlier invocations of this role: a start |
| + // that reads one would mis-word the very first exit line this |
| + // invocation eventually gets, attributing a previous run's status to |
| + // this one. |
| + if entries, err := os.ReadDir(p.runDir); err == nil { |
| + for _, e := range entries { |
| + if strings.HasPrefix(e.Name(), "exit."+string(role)+".") { |
| + _ = os.Remove(filepath.Join(p.runDir, e.Name())) |
| + } |
| + } |
| + } |
| + var ctl *edge.ControlListener |
| + if role == instancectl.RoleRun { |
| + // No pre-emptive removal of a stale rt.Socket here (deviation from |
| + // an earlier draft of this method): startChild does not do this |
| + // for a flat host either — a leftover socket file fails the |
| + // SUBSEQUENT bind with "address already in use", which |
| + // memberSafeWakeReason already recognises as the stale-socket wake |
| + // trap (#129) and reports as a diagnosis rather than a symptom. |
| + // Removing it here would silently paper over exactly the case #129 |
| + // exists to surface: two invocations of the same host actually |
| + // live at once. The two spawn paths stay consistent on this by |
| + // construction rather than by a second copy of the same rule. |
| + ctl = b.listenControl(rt, p.envFile) |
| + } |
| + logs, err := listenLogs(b.logSocket(rt.Host, role), b.logEmitter(rt)) |
| + if err != nil { |
| + if ctl != nil { |
| + ctl.Close() |
| + } |
| + return p, nil, nil, nil, fmt.Errorf("log listener for %s %s: %w", rt.Host, role, err) |
| + } |
| + return p, ctl, logs, env, nil |
| +} |
| + |
| +// 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 |
| +// path — design §3.2); a bind failure is logged and the instance runs |
| +// without its control verbs, as today. |
| +func (b *ExecBackend) listenControl(rt registry.Route, envPath string) *edge.ControlListener { |
| + tokenReader := func() (string, error) { |
| + env, err := readEnvFileBeneath(filepath.Dir(envPath), filepath.Base(envPath)) |
| + if err != nil { |
| + return "", err |
| + } |
| + return env["CARLOS_ADMIN_TOKEN"], nil |
| + } |
| + var mount func(*http.ServeMux) |
| + if b.Timers != nil { |
| + mount = func(mux *http.ServeMux) { schedule.Mount(mux, rt.Host, b.Timers, tokenReader, time.Now) } |
| + } |
| + ctl, err := edge.ListenControl(rt.Socket+".control", rt.Host, b.Cache, mount) |
| + if err != nil { |
| + log.Printf("activator: control listener for %s: %v (continuing without it)", rt.Host, err) |
| + return nil |
| + } |
| + return ctl |
| +} |
| + |
| +// logSocket is the role's log socket path, honouring the test override of |
| +// the run directory (RunDirOverride). |
| +func (b *ExecBackend) logSocket(host string, role instancectl.Role) string { |
| + if b.RunDirOverride != "" { |
| + return filepath.Join(b.RunDirOverride, "logs", provision.InstanceID(host)+"."+string(role)+".sock") |
| + } |
| + return provision.LogSocket(host, string(role)) |
| +} |
| + |
| +// logEmitter is the same sink teeStd feeds, or nil when Logs is unset. |
| +func (b *ExecBackend) logEmitter(rt registry.Route) func(string) { |
| + if b.Logs == nil { |
| + return nil |
| + } |
| + 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. |
| +func (b *ExecBackend) writeLaunch(p instancePaths, role instancectl.Role, 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) |
| +} |
| + |
| +// readEnvFileBeneath is readEnvFile through instancefs: a nested host's env |
| +// files are read without following a symlink (design §3.2). A missing file |
| +// is an empty env, as readEnvFile treats it (the caller's own |
| +// os.IsNotExist check would otherwise have nothing to check against). |
| +func readEnvFileBeneath(dir, name string) (map[string]string, error) { |
| + b, err := instancefs.ReadBeneathLimit(dir, name, 1<<20) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return map[string]string{}, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return parseEnvBytes(b), nil |
| +} |
diff --git a/internal/activator/unitproc.go b/internal/activator/unitproc.go| new file mode 100644 |
| index 000000000..05f81d613 |
| --- /dev/null |
| +++ b/internal/activator/unitproc.go |
| @@ -0,0 +1,216 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "sync/atomic" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// ErrStillRunning is Stop's answer when ctx expired before termination was |
| +// proven (design §6.3). The activator then keeps the lease and the entry |
| +// in stopping, and the sweeper retries; the lease is never released over a |
| +// writer that may still exist. |
| +var ErrStillRunning = errors.New("activator: stop unproven; processes remain") |
| + |
| +// unitProc is a nested host's Proc (design §6.3): the unit the control |
| +// service started, the listeners the agent holds for it, and the observer |
| +// that proves termination. Named apart from backend_unit.go's svcUnitProc, |
| +// a whole-app tenant's own systemd unit — this one is a nested exec |
| +// instance the activator spawns through the root instance-control channel. |
| +type unitProc struct { |
| + b *ExecBackend |
| + rt registry.Route |
| + role instancectl.Role |
| + unit, slice string |
| + invocation string |
| + runDir string |
| + ctl *edge.ControlListener |
| + logs *logListener |
| + exited atomic.Bool |
| + done chan struct{} |
| + closeOnce atomic.Bool |
| +} |
| + |
| +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{})} |
| + go b.observe(up) |
| + return up |
| +} |
| + |
| +func (p *unitProc) Exited() bool { return p.exited.Load() } |
| + |
| +// Stop asks the control service to stop the unit and waits for termination |
| +// to be proven. The control listener and the log listener are closed only |
| +// after that, preserving childProc's ordering (signal first, close the |
| +// listener after the process is gone). |
| +func (p *unitProc) Stop(ctx context.Context) error { |
| + if !p.Exited() { |
| + sctx, cancel := context.WithTimeout(ctx, instancectl.DeadlineControl) |
| + err := p.b.Control.Stop(sctx, p.rt.Host, p.role) |
| + cancel() |
| + if err != nil { |
| + log.Printf("activator: stop %s %s: %v", p.rt.Host, p.role, err) |
| + } |
| + } |
| + select { |
| + case <-p.done: |
| + p.closeListeners() |
| + return nil |
| + case <-ctx.Done(): |
| + return ErrStillRunning |
| + } |
| +} |
| + |
| +func (p *unitProc) closeListeners() { |
| + if p.closeOnce.Swap(true) { |
| + return |
| + } |
| + if p.ctl != nil { |
| + p.ctl.Close() |
| + } |
| + if p.logs != nil { |
| + p.logs.Close() |
| + } |
| +} |
| + |
| +// observe is the goroutine of design §6.5. It proves termination from two |
| +// facts the tenant cannot forge: systemd's unit state and cgroup emptiness. |
| +// The exit record decides the wording of the event line and nothing else. |
| +func (b *ExecBackend) observe(p *unitProc) { |
| + poll := b.ObservePoll |
| + if poll <= 0 { |
| + poll = time.Second |
| + } |
| + state := b.UnitState |
| + if state == nil { |
| + state = systemctlUnitState |
| + } |
| + events := b.CgroupEvents |
| + if events == nil { |
| + events = cgroupEventsUnder(b.cgroupRoot()) |
| + } |
| + foreignStopped := false |
| + lastRemain := time.Time{} |
| + for { |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + active, inv, err := state(ctx, p.unit) |
| + cancel() |
| + if err != nil { |
| + log.Printf("activator: %s: unit state: %v", p.rt.Host, err) |
| + } |
| + terminal := false |
| + foreign := false |
| + switch { |
| + case err != nil: |
| + case inv != "" && inv != p.invocation: |
| + foreign = true |
| + case active == "inactive" || active == "failed": |
| + terminal = true |
| + } |
| + if foreign && !foreignStopped { |
| + 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 { |
| + log.Printf("activator: %s: stop foreign invocation: %v", p.rt.Host, err) |
| + } |
| + scancel() |
| + foreignStopped = true |
| + } |
| + if terminal || (foreign && foreignStopped) { |
| + exists, populated, eerr := events(p.slice, p.unit) |
| + if eerr != nil { |
| + log.Printf("activator: %s: cgroup.events: %v", p.rt.Host, eerr) |
| + } |
| + if !exists || !populated { |
| + if terminal { |
| + emitEvent(b.Logs, p.rt, exitLevel(p), exitLine(p)) |
| + } else { |
| + emitEvent(b.Logs, p.rt, "warn", "instance exited (status unknown: restarted outside the activator)") |
| + } |
| + p.exited.Store(true) |
| + // Stop closes the listeners when it observes done; a crash |
| + // nobody is stopping yet leaves them for the sweeper's |
| + // stand-down, which calls Stop. |
| + close(p.done) |
| + return |
| + } |
| + if time.Since(lastRemain) > 2*time.Second { |
| + log.Printf("activator: %s: processes remain after %s; not releasing", p.rt.Host, active) |
| + lastRemain = time.Now() |
| + } |
| + } |
| + time.Sleep(poll) |
| + } |
| +} |
| + |
| +// exitLine reads the advisory record for this invocation and words the |
| +// event line as childProc does: a non-zero status is a warn line, a clean |
| +// exit is info, and no record is unknown. The record is tenant-writable |
| +// text (design §6.5, final review of plan 2), so every field that reaches |
| +// the line passes through recordToken first. |
| +func exitLine(p *unitProc) string { |
| + rec, ok := p.readRecord() |
| + switch { |
| + case !ok: |
| + return "instance exited (status unknown: no exit record)" |
| + case rec.Result == "success" || (rec.Code == "exited" && rec.Status == "0"): |
| + return "instance exited (exit 0)" |
| + case rec.Code == "killed" || rec.Code == "dumped": |
| + return "instance exited: signal " + recordToken(rec.Status) |
| + default: |
| + return fmt.Sprintf("instance exited: exit %s (%s)", recordToken(rec.Status), recordToken(rec.Result)) |
| + } |
| +} |
| + |
| +// recordToken bounds a field of the exit record to what systemd itself |
| +// would write: at most 32 bytes of [A-Za-z0-9_-]. Anything else becomes |
| +// "?" so a tenant cannot put arbitrary text or control characters into |
| +// the platform's event stream. |
| +func recordToken(s string) string { |
| + if len(s) == 0 || len(s) > 32 { |
| + return "?" |
| + } |
| + for _, c := range s { |
| + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { |
| + return "?" |
| + } |
| + } |
| + return s |
| +} |
| + |
| +func exitLevel(p *unitProc) string { |
| + if rec, ok := p.readRecord(); ok && (rec.Result == "success" || (rec.Code == "exited" && rec.Status == "0")) { |
| + return "info" |
| + } |
| + return "warn" |
| +} |
| + |
| +func (p *unitProc) readRecord() (instancectl.ExitRecord, bool) { |
| + raw, err := instancefs.ReadBeneathLimit(p.runDir, instancectl.ExitRecordName(p.role, p.invocation), 4096) |
| + if err != nil { |
| + return instancectl.ExitRecord{}, false |
| + } |
| + var rec instancectl.ExitRecord |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + return instancectl.ExitRecord{}, false |
| + } |
| + return rec, true |
| +} |
| + |
| +// cgroupRoot is CgroupRoot with the nil default applied. |
| +func (b *ExecBackend) cgroupRoot() string { |
| + if b.CgroupRoot != "" { |
| + return b.CgroupRoot |
| + } |
| + return "/sys/fs/cgroup" |
| +} |
diff --git a/internal/activator/unitproc_test.go b/internal/activator/unitproc_test.go| new file mode 100644 |
| index 000000000..755bcb0e0 |
| --- /dev/null |
| +++ b/internal/activator/unitproc_test.go |
| @@ -0,0 +1,277 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// fakeControl records verbs and lets a test drive the unit's state. |
| +type fakeControl struct { |
| + mu sync.Mutex |
| + calls []string |
| + inv string |
| + seed instancectl.SeedResponse |
| + startErr error |
| + stopHook func(host string, role instancectl.Role) |
| + readyErr error |
| +} |
| + |
| +func (f *fakeControl) rec(s string) { f.mu.Lock(); f.calls = append(f.calls, s); f.mu.Unlock() } |
| +func (f *fakeControl) Start(_ context.Context, host string, role instancectl.Role, max int64) (string, error) { |
| + f.rec("start " + host + " " + string(role)) |
| + return f.inv, f.startErr |
| +} |
| +func (f *fakeControl) Seed(_ context.Context, host string, max int64) (instancectl.SeedResponse, error) { |
| + f.rec("seed " + host) |
| + return f.seed, nil |
| +} |
| +func (f *fakeControl) Stop(_ context.Context, host string, role instancectl.Role) error { |
| + f.rec("stop " + host + " " + string(role)) |
| + if f.stopHook != nil { |
| + f.stopHook(host, role) |
| + } |
| + return nil |
| +} |
| +func (f *fakeControl) Reset(_ context.Context, host string, role instancectl.Role) error { |
| + f.rec("reset") |
| + return nil |
| +} |
| +func (f *fakeControl) Sweep(context.Context) (instancectl.SweepResponse, error) { |
| + f.rec("sweep") |
| + return instancectl.SweepResponse{}, nil |
| +} |
| +func (f *fakeControl) Ready(context.Context) error { f.rec("ready"); return f.readyErr } |
| +func (f *fakeControl) joined() string { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return strings.Join(f.calls, "\n") |
| +} |
| + |
| +// fakeUnit is the unit's observable state: what `systemctl show` and |
| +// cgroup.events would say. |
| +type fakeUnit struct { |
| + mu sync.Mutex |
| + active string |
| + inv string |
| + exists bool |
| + populated bool |
| +} |
| + |
| +func (u *fakeUnit) set(active, inv string, exists, populated bool) { |
| + u.mu.Lock() |
| + u.active, u.inv, u.exists, u.populated = active, inv, exists, populated |
| + u.mu.Unlock() |
| +} |
| +func (u *fakeUnit) state(context.Context, string) (string, string, error) { |
| + u.mu.Lock() |
| + defer u.mu.Unlock() |
| + return u.active, u.inv, nil |
| +} |
| +func (u *fakeUnit) events(string, string) (bool, bool, error) { |
| + u.mu.Lock() |
| + defer u.mu.Unlock() |
| + return u.exists, u.populated, nil |
| +} |
| + |
| +// nestedBackend returns an ExecBackend wired for a nested host with fakes, |
| +// and the route. The data and run dirs are temp dirs standing in for |
| +// /data/instances/<iid> and /run/carlos/instances/<iid>; provision's |
| +// helpers are not used for the temp paths, so the route is built to |
| +// classify as nested by construction through the Layout seam. |
| +func nestedBackend(t *testing.T, ctl *fakeControl, unit *fakeUnit) (*ExecBackend, registry.Route) { |
| + t.Helper() |
| + root, err := os.MkdirTemp("", "nb") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(root) }) |
| + host := "h.example.com" |
| + b := testExecBackend(t) |
| + b.DataDir = filepath.Join(root, "data") |
| + b.Control = ctl |
| + b.UnitState = unit.state |
| + b.CgroupEvents = unit.events |
| + b.ObservePoll = 20 * time.Millisecond |
| + b.RunDirOverride = filepath.Join(root, "run") |
| + stageLive(t, b, host, "v1") |
| + rt := registry.Route{Host: host, Kind: "instance", Hibernate: true, |
| + DBPath: provision.NestedDBPath(b.DataDir, host), Socket: filepath.Join(b.RunDirOverride, "instances", host, "app.sock")} |
| + return b, rt |
| +} |
| + |
| +func TestStartUnitWritesTheLaunchFileAndReturnsAUnitProc(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + // The fake control does not start anything, so health must be faked: |
| + // bind a listener at rt.Socket that answers 200. |
| + stopServing := serveOK(t, rt.Socket) |
| + defer stopServing() |
| + unit.set("active", "inv1", true, true) |
| + p, ver, err := b.Start(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("Start: %v", err) |
| + } |
| + _ = ver |
| + up, ok := p.(*unitProc) |
| + if !ok || up.invocation != "inv1" { |
| + t.Fatalf("proc = %T %+v", p, p) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.run.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var l instancectl.Launch |
| + if err := json.Unmarshal(raw, &l); err != nil { |
| + t.Fatal(err) |
| + } |
| + if l.Schema != 1 || l.Host != rt.Host || len(l.Argv) != 5 || l.Argv[0] != filepath.Join(b.OptDir, "live", rt.Host) || l.Argv[2] != rt.Socket || l.Argv[4] != rt.DBPath { |
| + t.Errorf("launch = %+v", l) |
| + } |
| + seenCtl := false |
| + for _, kv := range l.Env { |
| + if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") { |
| + seenCtl = true |
| + } |
| + if strings.HasPrefix(kv, "CARLOS_ADMIN_TOKEN=") && len(kv) < 30 { |
| + t.Errorf("token missing from env: %s", kv) |
| + } |
| + } |
| + if !seenCtl { |
| + t.Error("CARLOS_CONTROL_SOCKET not in the launch env") |
| + } |
| + if !strings.Contains(ctl.joined(), "start h.example.com run") { |
| + t.Errorf("control calls: %s", ctl.joined()) |
| + } |
| + if p.Exited() { |
| + t.Error("exited before the unit went inactive") |
| + } |
| + // Termination: terminal state plus empty cgroup. |
| + unit.set("inactive", "inv1", false, false) |
| + waitFor(t, "the unit to exit", func() bool { return p.Exited() }) |
| + ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| + defer cancel() |
| + if err := p.Stop(ctx); err != nil { |
| + t.Errorf("Stop after exit: %v", err) |
| + } |
| +} |
| + |
| +func TestUnitProcDoesNotExitOnTerminalStateWhileTheCgroupIsPopulated(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + stopServing := serveOK(t, rt.Socket) |
| + defer stopServing() |
| + unit.set("active", "inv1", true, true) |
| + p, _, err := b.Start(context.Background(), rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + unit.set("failed", "inv1", true, true) // a process survived SIGKILL |
| + time.Sleep(150 * time.Millisecond) |
| + if p.Exited() { |
| + t.Fatal("exited while the cgroup is still populated (codex R2-5)") |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) |
| + defer cancel() |
| + if err := p.Stop(ctx); !errors.Is(err, ErrStillRunning) { |
| + t.Fatalf("Stop = %v, want ErrStillRunning", err) |
| + } |
| + unit.set("failed", "inv1", false, false) |
| + waitFor(t, "the unit to exit", func() bool { return p.Exited() }) |
| +} |
| + |
| +func TestUnitProcStopsAForeignInvocation(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + stopServing := serveOK(t, rt.Socket) |
| + defer stopServing() |
| + unit.set("active", "inv1", true, true) |
| + p, _, err := b.Start(context.Background(), rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctl.stopHook = func(string, instancectl.Role) { unit.set("inactive", "", false, false) } |
| + unit.set("active", "inv2", true, true) // someone restarted it outside the activator |
| + waitFor(t, "the unit to exit", func() bool { return p.Exited() }) |
| + if !strings.Contains(ctl.joined(), "stop h.example.com run") { |
| + t.Errorf("foreign invocation was not stopped: %s", ctl.joined()) |
| + } |
| +} |
| + |
| +func TestStartUnitRefusesWhenTheBoxIsNotReadyOrFenced(t *testing.T) { |
| + ctl := &fakeControl{inv: "inv1"} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + b.NestedOK = func() error { return errors.New("probe: pid1 fail") } |
| + if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "pid1") { |
| + t.Fatalf("not-ready wake = %v", err) |
| + } |
| + if strings.Contains(ctl.joined(), "start") { |
| + t.Error("a start was issued on a box that is not ready") |
| + } |
| + b.NestedOK = nil |
| + b.Layout.Journal = func(string) bool { return true } |
| + if _, _, err := b.Start(context.Background(), rt); err == nil || !strings.Contains(err.Error(), "migration in flight") { |
| + t.Fatalf("fenced wake = %v", err) |
| + } |
| +} |
| + |
| +func TestSeedUnitCleansUpOnlyItsOwnDBOnFailure(t *testing.T) { |
| + ctl := &fakeControl{seed: instancectl.SeedResponse{InvocationID: "s1", Result: "exit-code", Status: "3", Code: "exited"}} |
| + unit := &fakeUnit{} |
| + b, rt := nestedBackend(t, ctl, unit) |
| + rt.Seed = `["seed","--fast"]` |
| + unit.set("inactive", "", false, false) |
| + err := b.Seed(context.Background(), rt) |
| + if err == nil || !strings.Contains(err.Error(), "exit 3") { |
| + t.Fatalf("Seed = %v", err) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), "launch.seed.json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var l instancectl.Launch |
| + _ = json.Unmarshal(raw, &l) |
| + if len(l.Argv) != 5 || l.Argv[1] != "seed" || l.Argv[2] != "--fast" || l.Argv[3] != "--db" || l.Argv[4] != rt.DBPath { |
| + t.Errorf("seed argv = %v", l.Argv) |
| + } |
| + for _, kv := range l.Env { |
| + if strings.HasPrefix(kv, "CARLOS_CONTROL_SOCKET=") || strings.HasPrefix(kv, "CARLOS_PURGE=") { |
| + t.Errorf("seed env carries %s", kv) |
| + } |
| + } |
| + if _, err := os.Stat(rt.DBPath); !os.IsNotExist(err) { |
| + t.Errorf("failed seed left its database: %v", err) |
| + } |
| +} |
| + |
| +// serveOK binds a unix socket at path and answers 200 to every request, so |
| +// waitHealthyCtx passes without a real unit. |
| +func serveOK(t *testing.T, path string) func() { |
| + t.Helper() |
| + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { |
| + t.Fatal(err) |
| + } |
| + ln, err := net.Listen("unix", path) |
| + if err != nil { |
| + t.Skipf("cannot bind here (%v); run with TMPDIR=/tmp/g outside the sandbox", err) |
| + } |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })} |
| + go srv.Serve(ln) //nolint:errcheck |
| + return func() { srv.Close(); ln.Close() } |
| +} |