Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15561263cc | ||
|
|
44b244f5c0 | ||
|
|
f2661d2c6e | ||
|
|
95d48b02cb | ||
|
|
d6fa67982e | ||
|
|
1237216e63 | ||
|
|
49a8ff73fd | ||
|
|
32be7e547a | ||
|
|
9152bb2b14 | ||
|
|
9c7fb4eda6 | ||
|
|
063de8cd85 | ||
|
|
11ffe65c97 | ||
|
|
dcaf01c789 | ||
|
|
7ddd2f5e1d | ||
|
|
16920c4a6f | ||
|
|
6b3ebce343 | ||
|
|
7fa0f65a60 | ||
|
|
f4faa3f819 | ||
|
|
0692d999f6 | ||
|
|
fa35c6abed | ||
|
|
53d4ea6ec6 | ||
|
|
39987a5b61 | ||
|
|
00bdf8b28c | ||
|
|
631567d5f7 | ||
|
|
9f741874bd | ||
|
|
430b4d5f5d | ||
|
|
404db8cd70 | ||
|
|
c0262e8856 | ||
|
|
306985990c | ||
|
|
2082ac061b | ||
|
|
18ee6eb33d | ||
|
|
76f1f1c8d3 | ||
|
|
0da1deb83f | ||
|
|
4aa67e8dff | ||
|
|
7425edb80f | ||
|
|
571a3d54b5 | ||
|
|
bcd174f89e | ||
|
|
94fc3e55f5 | ||
|
|
46d29d82e9 | ||
|
|
d210c2d7ea | ||
|
|
48531755eb | ||
|
|
9a1cc63383 | ||
|
|
0830e2e3ae | ||
|
|
205cc0d7a1 | ||
|
|
698655d40a | ||
|
|
dcad7de033 | ||
|
|
cd4409abc3 |
@@ -1,71 +0,0 @@
|
|||||||
# REPORT A1207
|
|
||||||
|
|
||||||
Date: 2026-08-13
|
|
||||||
Branch: `fix/869-lease-probe-timeout`
|
|
||||||
Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f`
|
|
||||||
Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
|
|
||||||
|
|
||||||
## What changed
|
|
||||||
|
|
||||||
- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory.
|
|
||||||
- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback.
|
|
||||||
- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`.
|
|
||||||
- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change.
|
|
||||||
- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`.
|
|
||||||
- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport.
|
|
||||||
|
|
||||||
No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam.
|
|
||||||
|
|
||||||
## Hermeticity incident and correction
|
|
||||||
|
|
||||||
An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path.
|
|
||||||
|
|
||||||
## Mutation evidence
|
|
||||||
|
|
||||||
Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run.
|
|
||||||
|
|
||||||
| Mutation | Result | Reddened test name(s) |
|
|
||||||
| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests |
|
|
||||||
| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` |
|
|
||||||
| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` |
|
|
||||||
|
|
||||||
## Final test run
|
|
||||||
|
|
||||||
Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available.
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py
|
|
||||||
...................
|
|
||||||
----------------------------------------------------------------------
|
|
||||||
Ran 19 tests in 0.007s
|
|
||||||
|
|
||||||
OK
|
|
||||||
|
|
||||||
$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts
|
|
||||||
✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms
|
|
||||||
Test Files 1 passed (1)
|
|
||||||
Tests 20 passed (20)
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts
|
|
||||||
Checking formatting...
|
|
||||||
All matched files use Prettier code style!
|
|
||||||
|
|
||||||
$ pnpm --filter @mosaicstack/mosaic lint
|
|
||||||
> eslint src
|
|
||||||
|
|
||||||
$ pnpm --filter @mosaicstack/mosaic typecheck
|
|
||||||
> tsc --noEmit
|
|
||||||
|
|
||||||
$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py
|
|
||||||
|
|
||||||
$ git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
All commands above exited zero.
|
|
||||||
|
|
||||||
## Ambiguities skipped
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -4,14 +4,6 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
|||||||
Requires=mosaic-tmux-holder.service
|
Requires=mosaic-tmux-holder.service
|
||||||
After=mosaic-tmux-holder.service
|
After=mosaic-tmux-holder.service
|
||||||
PartOf=mosaic-tmux-holder.service
|
PartOf=mosaic-tmux-holder.service
|
||||||
# Do not attempt a seat before its generated env exists. `install` enables this
|
|
||||||
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
|
|
||||||
# generated env, so between `install` and the first `apply`/`regen --write` there
|
|
||||||
# is a boot window where ExecStart would run against an absent env file and the
|
|
||||||
# launcher would fail the unit. A skipped unit is the honest state for "enabled
|
|
||||||
# but not yet configured"; systemd re-evaluates the condition on every start, so
|
|
||||||
# the seat comes up on the next start once the reconciler has written env.
|
|
||||||
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -62,14 +62,7 @@ EXPECTED_ACTIVATION_CAPABILITY: Final[ActivationCapability] = {
|
|||||||
# capability as compact JSON.
|
# capability as compact JSON.
|
||||||
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
LEASE_CAPABILITY_PROBE_COMMAND: Final = "__lease-capability"
|
||||||
|
|
||||||
# Budget for the out-of-process `mosaic __lease-capability` probe. The CLI
|
PROBE_TIMEOUT_SECONDS: Final = 2.0
|
||||||
# is a Node program whose cold start alone measures 2.2-2.3s on a mid-range
|
|
||||||
# workstation (sb-it-1-dt, 2026-08-13), so a 2s budget made every launch on
|
|
||||||
# such hosts fail closed with the #869 skew message even though the
|
|
||||||
# capability matched. The timeout only bounds the pathological hang case —
|
|
||||||
# the happy path returns as soon as the probe exits — so a generous budget
|
|
||||||
# costs nothing on healthy hosts.
|
|
||||||
PROBE_TIMEOUT_SECONDS: Final = 10.0
|
|
||||||
|
|
||||||
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
# Override hook: a full shell-style command line (parsed with `shlex.split`)
|
||||||
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
# to run INSTEAD of resolving `mosaic` on PATH and appending the probe
|
||||||
@@ -95,13 +88,7 @@ def _resolve_probe_command(environ: Mapping[str, str]) -> list[str] | None:
|
|||||||
if override:
|
if override:
|
||||||
parsed = shlex.split(override)
|
parsed = shlex.split(override)
|
||||||
return parsed or None
|
return parsed or None
|
||||||
# Resolve against the PROVIDED environment's PATH, not the ambient
|
resolved = shutil.which("mosaic")
|
||||||
# os.environ. Before this, a test passing a hermetic environ still
|
|
||||||
# resolved (and spawned) the host's real `mosaic` — masked only on hosts
|
|
||||||
# where the real probe happened to exceed the old 2s timeout. No PATH in
|
|
||||||
# the provided environment means nothing is resolvable (fail-closed),
|
|
||||||
# matching the probe's overall contract.
|
|
||||||
resolved = shutil.which("mosaic", path=environ.get("PATH", ""))
|
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
return None
|
return None
|
||||||
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
return [resolved, LEASE_CAPABILITY_PROBE_COMMAND]
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
|
|
||||||
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
|
|
||||||
* never get its units placed. These tests pin the three behaviours that fix
|
|
||||||
* gives it, and the two it deliberately does NOT give it.
|
|
||||||
*
|
|
||||||
* The load-bearing negative is that `install` on v2 writes no generated env:
|
|
||||||
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
|
|
||||||
* second writer here — necessarily through the v1 mapping — is exactly the
|
|
||||||
* drift the #791 single-SSOT invariant exists to prevent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const rosterV2 = `
|
|
||||||
version: 2
|
|
||||||
generation: 4
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-fleet
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
runtime: pi
|
|
||||||
runtimes:
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: coder0
|
|
||||||
alias: Coder 0
|
|
||||||
class: code
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: high
|
|
||||||
tool_policy: code
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
persistent_persona: false
|
|
||||||
reset_between_tasks: true
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
- name: coder1
|
|
||||||
alias: Coder 1
|
|
||||||
class: code
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: medium
|
|
||||||
tool_policy: code
|
|
||||||
working_directory: /srv/other
|
|
||||||
persistent_persona: false
|
|
||||||
reset_between_tasks: true
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
`;
|
|
||||||
|
|
||||||
let tempHome: string | undefined;
|
|
||||||
const savedHome = process.env.HOME;
|
|
||||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.exitCode = undefined;
|
|
||||||
if (savedHome === undefined) delete process.env.HOME;
|
|
||||||
else process.env.HOME = savedHome;
|
|
||||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
|
||||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
|
||||||
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
|
||||||
tempHome = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
|
||||||
* anything has been installed, applied or started.
|
|
||||||
*/
|
|
||||||
async function v2Home(): Promise<string> {
|
|
||||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
delete process.env.MOSAIC_HOME;
|
|
||||||
const mosaicHome = join(tempHome, '.config', 'mosaic');
|
|
||||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
|
||||||
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
|
|
||||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
return mosaicHome;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stands in for a box where nothing is running: every systemctl and tmux probe
|
|
||||||
* fails the way it does before the holder has ever started. `ps` must survive
|
|
||||||
* this — it is the command an operator reaches for to find out *why* there is
|
|
||||||
* no seat, so it has to report the emptiness rather than fail on it.
|
|
||||||
*/
|
|
||||||
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
|
|
||||||
if (command === 'tmux') {
|
|
||||||
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 1 };
|
|
||||||
};
|
|
||||||
|
|
||||||
function program(runner: CommandRunner = greenfieldRunner): Command {
|
|
||||||
const result = new Command();
|
|
||||||
result.exitOverride();
|
|
||||||
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function capture(): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
|
||||||
lines.push(value);
|
|
||||||
});
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exists(path: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await stat(path);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mosaic fleet ps — roster v2', (): void => {
|
|
||||||
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
const lines = capture();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
const rows = JSON.parse(lines.join('\n')) as {
|
|
||||||
name: string;
|
|
||||||
runtime: string;
|
|
||||||
alias?: string;
|
|
||||||
paneAlive: boolean;
|
|
||||||
source: string;
|
|
||||||
}[];
|
|
||||||
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
|
|
||||||
// The v2 roster's per-agent fields must survive the read model, not be
|
|
||||||
// flattened into defaults.
|
|
||||||
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
|
|
||||||
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
|
|
||||||
// Nothing is running, and that is a report, not an error.
|
|
||||||
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
|
|
||||||
expect(rows.every((row) => row.source === 'roster')).toBe(true);
|
|
||||||
expect(process.exitCode ?? 0).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic fleet install — roster v2', (): void => {
|
|
||||||
it('places the tool files and unit templates', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
capture();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
// Units live in the systemd user dir, not under the Mosaic home.
|
|
||||||
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
|
||||||
for (const unit of [
|
|
||||||
'mosaic-tmux-holder.service',
|
|
||||||
'[email protected]',
|
|
||||||
'[email protected]',
|
|
||||||
]) {
|
|
||||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
|
||||||
}
|
|
||||||
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
|
||||||
expect(await exists(launcher)).toBe(true);
|
|
||||||
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
capture();
|
|
||||||
|
|
||||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
||||||
|
|
||||||
const agentDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
expect(await readdir(agentDir)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tells the operator which command does own the env', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
const lines = capture();
|
|
||||||
|
|
||||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
||||||
|
|
||||||
expect(lines.join('\n')).toContain('mosaic fleet apply');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('[email protected]', (): void => {
|
|
||||||
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
|
||||||
|
|
||||||
/** The single `ConditionPathExists=` value declared by the unit template. */
|
|
||||||
async function conditionPath(): Promise<string> {
|
|
||||||
const unit = await readFile(unitPath, 'utf8');
|
|
||||||
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
|
||||||
expect(matches).toHaveLength(1);
|
|
||||||
return matches[0]!.slice('ConditionPathExists='.length).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
|
||||||
// The pairing that makes "install writes no env" safe: install enables the
|
|
||||||
// unit (WantedBy=default.target) but does not start it, so without this
|
|
||||||
// condition a reboot between `install` and the first `apply` would run
|
|
||||||
// ExecStart against an absent env file and fail every seat unit.
|
|
||||||
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The two halves of the guard's *effect*, which no assertion on the literal
|
|
||||||
* string can cover on its own.
|
|
||||||
*
|
|
||||||
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
|
|
||||||
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
|
|
||||||
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
|
|
||||||
* and journals "skipped, unmet condition check". With the condition removed by
|
|
||||||
* drop-in and nothing else changed, the same start returns rc=1,
|
|
||||||
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
|
|
||||||
*
|
|
||||||
* systemd is not available in this suite, so these two tests pin the parts
|
|
||||||
* that can drift in code: the condition naming a *different* file than the one
|
|
||||||
* the fleet actually writes, and the launcher quietly becoming tolerant of an
|
|
||||||
* absent env — either of which turns the condition into decoration while the
|
|
||||||
* literal-string assertion above still passes.
|
|
||||||
*/
|
|
||||||
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
|
|
||||||
|
|
||||||
// The path an installed fleet actually places for this agent.
|
|
||||||
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
||||||
|
|
||||||
// Exactly what ExecStart runs, against the state the condition exists to
|
|
||||||
// catch: unit enabled, reconciler has not written env yet.
|
|
||||||
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
|
|
||||||
const child = execFile(
|
|
||||||
'/bin/bash',
|
|
||||||
[
|
|
||||||
'--noprofile',
|
|
||||||
'--norc',
|
|
||||||
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
|
|
||||||
'coder0',
|
|
||||||
],
|
|
||||||
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
|
|
||||||
(_error, _stdout, stderr) => {
|
|
||||||
settle({ code: child.exitCode, stderr });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(launched.code).not.toBe(0);
|
|
||||||
expect(launched.stderr).toContain('missing-file');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic fleet add / remove — roster v2', (): void => {
|
|
||||||
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'add',
|
|
||||||
'coder2',
|
|
||||||
'--runtime',
|
|
||||||
'pi',
|
|
||||||
'--class',
|
|
||||||
'code',
|
|
||||||
]),
|
|
||||||
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
|
||||||
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: this one passes on the unmodified tree too — there `remove` throws in
|
|
||||||
// the v1 parser, before it can touch anything. It is a regression guard on the
|
|
||||||
// ordering of the new guard clause, not evidence that the fix works.
|
|
||||||
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
||||||
const before = await readFile(rosterPath, 'utf8');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,7 +34,6 @@ export {
|
|||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
} from '../fleet/fleet-roster-v1.js';
|
} from '../fleet/fleet-roster-v1.js';
|
||||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
|
||||||
import {
|
import {
|
||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
@@ -821,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export async function enableFleetUnits(
|
export async function enableFleetUnits(
|
||||||
runner: CommandRunner,
|
runner: CommandRunner,
|
||||||
roster: { readonly agents: readonly { readonly name: string }[] },
|
roster: FleetRoster,
|
||||||
opts: { enable?: boolean },
|
opts: { enable?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (opts.enable === false) {
|
if (opts.enable === false) {
|
||||||
@@ -1528,8 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1539,8 +1537,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1691,9 +1688,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.action(async (opts: { json?: boolean }) => {
|
.action(async (opts: { json?: boolean }) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
// ps only reads, so it takes the version-agnostic read model rather than
|
const roster = await loadRosterForCommand(cmd);
|
||||||
// the v1 parser, which rejects a v2 roster outright.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
const { tenant_id, host } = getDefaultTenantAndHost();
|
const { tenant_id, host } = getDefaultTenantAndHost();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
@@ -1913,16 +1908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
start: boolean;
|
start: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (await usesRosterV2ControlPlane(cmd)) {
|
|
||||||
// command.error, not a bare throw: this is operator guidance, and a
|
|
||||||
// bare throw reaches the top level uncaught and prints it under a Node
|
|
||||||
// stack trace. Measured on canary — the message is the whole point of
|
|
||||||
// the refusal, so it has to arrive readable.
|
|
||||||
cmd.error(rosterV2MutationGuidance('add', 'create', name), {
|
|
||||||
code: 'fleet.roster-v2',
|
|
||||||
exitCode: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
||||||
@@ -1988,12 +1973,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Remove an agent from the fleet roster')
|
.description('Remove an agent from the fleet roster')
|
||||||
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
||||||
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
||||||
if (await usesRosterV2ControlPlane(cmd)) {
|
|
||||||
cmd.error(rosterV2MutationGuidance('remove', 'delete', name), {
|
|
||||||
code: 'fleet.roster-v2',
|
|
||||||
exitCode: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
@@ -2352,9 +2331,7 @@ export function registerFleetAgentCommands(
|
|||||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||||
// Read model first: every file this function places is roster-independent, and
|
const roster = await loadRosterForCommand(cmd);
|
||||||
// the v1 parser would reject a v2 roster before any of them were written.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||||
@@ -2414,30 +2391,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(activePaths.systemdUserDir, '[email protected]'),
|
join(activePaths.systemdUserDir, '[email protected]'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// On roster v2 the reconciler owns the generated env: `apply` writes it and
|
for (const agent of roster.agents) {
|
||||||
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
|
|
||||||
// here too — necessarily through the v1 mapping — would be the third writer of
|
|
||||||
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
|
|
||||||
// files and the units, and nothing else.
|
|
||||||
if (roster.version === 2) {
|
|
||||||
console.log(
|
|
||||||
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
|
|
||||||
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const v1Roster = await loadRosterForCommand(cmd);
|
|
||||||
for (const agent of v1Roster.agents) {
|
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
agentEnvDir: activePaths.agentEnvDir,
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
generated: generateAgentEnvValues(v1Roster, agent),
|
generated: generateAgentEnvValues(roster, agent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`);
|
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
@@ -2464,77 +2427,6 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `add`/`remove` and `create`/`delete` are not two spellings of one operation.
|
|
||||||
* The v1 pair edits the roster *and* drives systemd; the v2 pair is documented
|
|
||||||
* as changing desired state "without runtime actions", leaving convergence to
|
|
||||||
* `apply`. `add` also collects four fields where a v2 agent requires eleven, so
|
|
||||||
* routing it to `create` would mean inventing provider, alias, reasoning and
|
|
||||||
* tool-policy defaults on the operator's behalf. Refusing with the real command
|
|
||||||
* is honest; silently guessing an agent's provider is not.
|
|
||||||
*/
|
|
||||||
function rosterV2MutationGuidance(
|
|
||||||
v1Command: 'add' | 'remove',
|
|
||||||
v2Command: 'create' | 'delete',
|
|
||||||
name: string,
|
|
||||||
): string {
|
|
||||||
const target = v2Command === 'delete' ? ` ${name}` : '';
|
|
||||||
return (
|
|
||||||
`mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` +
|
|
||||||
`Roster v2 separates desired state from convergence:\n` +
|
|
||||||
` 1. mosaic fleet ${v2Command}${target} --expected-generation <current> ` +
|
|
||||||
`${v2Command === 'create' ? "--agent '<json>' " : ''}` +
|
|
||||||
`(edits the roster only)\n` +
|
|
||||||
` 2. mosaic fleet apply --expected-generation <new> (converges systemd and tmux)\n` +
|
|
||||||
`Read the current generation with: mosaic fleet status`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The read-only fields shared by roster v1 and v2, for the commands that only
|
|
||||||
* ever *read* the roster (`ps`, and unit enablement inside `install`).
|
|
||||||
*
|
|
||||||
* This is deliberately NOT a v2→v1 downshift. A downshifted `FleetRoster` would
|
|
||||||
* be accepted by `generateAgentEnvValues`, and that would make a third writer of
|
|
||||||
* `fleet/agents/<name>.env.generated` — through the v1 mapping — breaking the
|
|
||||||
* #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is
|
|
||||||
* documented to hold. Keeping the read model this small makes that misuse
|
|
||||||
* impossible: there is nothing here to write a roster or an env file back from.
|
|
||||||
*/
|
|
||||||
interface FleetRosterReadModel {
|
|
||||||
readonly version: 1 | 2;
|
|
||||||
readonly tmux: { readonly socketName: string; readonly holderSession: string };
|
|
||||||
readonly agents: readonly {
|
|
||||||
readonly name: string;
|
|
||||||
readonly alias?: string;
|
|
||||||
readonly runtime: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads either roster version into the shared read-only view. */
|
|
||||||
async function loadRosterReadModel(cmd: Command): Promise<FleetRosterReadModel> {
|
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
|
||||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
|
||||||
if (!(await usesRosterV2ControlPlane(cmd))) {
|
|
||||||
const v1 = await loadRosterAtPath(cmd, path);
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession },
|
|
||||||
agents: v1.agents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml');
|
|
||||||
return {
|
|
||||||
version: 2,
|
|
||||||
tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession },
|
|
||||||
agents: v2.agents,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
reportFleetRosterConfigurationError(cmd, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRosterFromAgentCommand(
|
async function loadRosterFromAgentCommand(
|
||||||
command: Command,
|
command: Command,
|
||||||
mosaicHomeOverride?: string,
|
mosaicHomeOverride?: string,
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import {
|
import {
|
||||||
LEASE_ACTIVATION_CAPABILITY,
|
LEASE_ACTIVATION_CAPABILITY,
|
||||||
LEASE_CAPABILITY_PROBE_COMMAND,
|
LEASE_CAPABILITY_PROBE_COMMAND,
|
||||||
LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
|
||||||
defaultCapabilityProbe,
|
defaultCapabilityProbe,
|
||||||
defaultResolveCliEntry,
|
defaultResolveCliEntry,
|
||||||
defaultSupervisorProbe,
|
defaultSupervisorProbe,
|
||||||
leaseEnforcementActivatable,
|
leaseEnforcementActivatable,
|
||||||
registerLeaseCapabilityProbe,
|
registerLeaseCapabilityProbe,
|
||||||
type CapabilityProbeExecFile,
|
|
||||||
type LeaseActivationCapability,
|
type LeaseActivationCapability,
|
||||||
type SupervisorProbeResult,
|
type SupervisorProbeResult,
|
||||||
} from './lease-activation-probe.js';
|
} from './lease-activation-probe.js';
|
||||||
@@ -37,17 +35,6 @@ const presentSupervisor: SupervisorProbeResult = {
|
|||||||
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
socketPath: '/run/user/1000/mosaic-lease/broker.sock',
|
||||||
};
|
};
|
||||||
|
|
||||||
function withScratchCli<T>(run: (cliPath: string) => T): T {
|
|
||||||
const scratchDir = mkdtempSync(join(tmpdir(), 'mosaic-lease-capability-probe-'));
|
|
||||||
try {
|
|
||||||
const cliPath = join(scratchDir, 'cli.js');
|
|
||||||
writeFileSync(cliPath, '// isolated fake; injected execFile means this is never executed\n');
|
|
||||||
return run(cliPath);
|
|
||||||
} finally {
|
|
||||||
rmSync(scratchDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('leaseEnforcementActivatable', () => {
|
describe('leaseEnforcementActivatable', () => {
|
||||||
it('is false when the activation capability is absent (null)', () => {
|
it('is false when the activation capability is absent (null)', () => {
|
||||||
const result = leaseEnforcementActivatable({
|
const result = leaseEnforcementActivatable({
|
||||||
@@ -113,6 +100,15 @@ describe('leaseEnforcementActivatable', () => {
|
|||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the real default probes when no deps are injected (does not throw)', () => {
|
||||||
|
// No live broker / built CLI is guaranteed in a test environment, so this
|
||||||
|
// only asserts the predicate degrades to a safe boolean rather than
|
||||||
|
// throwing — the fail-closed behavior itself is covered by the injected
|
||||||
|
// cases above.
|
||||||
|
expect(() => leaseEnforcementActivatable()).not.toThrow();
|
||||||
|
expect(typeof leaseEnforcementActivatable()).toBe('boolean');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('defaultCapabilityProbe', () => {
|
describe('defaultCapabilityProbe', () => {
|
||||||
@@ -131,61 +127,6 @@ describe('defaultCapabilityProbe', () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes the exact ten-second timeout to the injected child-process transport', () => {
|
|
||||||
withScratchCli((cliPath) => {
|
|
||||||
let captured:
|
|
||||||
| {
|
|
||||||
file: string;
|
|
||||||
args: string[];
|
|
||||||
options: Parameters<CapabilityProbeExecFile>[2];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const execFile: CapabilityProbeExecFile = (file, args, options) => {
|
|
||||||
captured = { file, args, options };
|
|
||||||
return JSON.stringify(LEASE_ACTIVATION_CAPABILITY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile });
|
|
||||||
|
|
||||||
expect(result).toEqual(LEASE_ACTIVATION_CAPABILITY);
|
|
||||||
expect(captured).toEqual({
|
|
||||||
file: process.execPath,
|
|
||||||
args: [cliPath, LEASE_CAPABILITY_PROBE_COMMAND],
|
|
||||||
options: {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 10_000,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(captured?.options.timeout).toBe(LEASE_CAPABILITY_PROBE_TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['timeout', Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' })],
|
|
||||||
['spawn error', Object.assign(new Error('spawn failed'), { code: 'ENOENT' })],
|
|
||||||
['nonzero exit', Object.assign(new Error('child exited 1'), { status: 1 })],
|
|
||||||
])('returns null (fail-closed) on child-process %s', (_failure, error) => {
|
|
||||||
withScratchCli((cliPath) => {
|
|
||||||
const execFile: CapabilityProbeExecFile = () => {
|
|
||||||
throw error;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['unparseable JSON', 'not-json'],
|
|
||||||
['malformed object', JSON.stringify({ name: LEASE_ACTIVATION_CAPABILITY.name })],
|
|
||||||
])('returns null (fail-closed) on %s output', (_failure, output) => {
|
|
||||||
withScratchCli((cliPath) => {
|
|
||||||
const execFile: CapabilityProbeExecFile = () => output;
|
|
||||||
|
|
||||||
expect(defaultCapabilityProbe({ resolveCliEntry: () => cliPath, execFile })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
|
describe('positive path — injected resolver, isolated scratch dir (never the real dist/)', () => {
|
||||||
// A prior version of this test staged the stub cli.js at the package's
|
// A prior version of this test staged the stub cli.js at the package's
|
||||||
// REAL resolved dist/ path and relied on afterEach to clean up "only
|
// REAL resolved dist/ path and relied on afterEach to clean up "only
|
||||||
|
|||||||
@@ -55,19 +55,6 @@ export const LEASE_ACTIVATION_CAPABILITY: LeaseActivationCapability = {
|
|||||||
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
|
/** Hidden CLI probe subcommand name — wired via {@link registerLeaseCapabilityProbe}. */
|
||||||
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
|
export const LEASE_CAPABILITY_PROBE_COMMAND = '__lease-capability';
|
||||||
|
|
||||||
/**
|
|
||||||
* Budget for the out-of-process capability probe. The probe launches a fresh
|
|
||||||
* Node process on the built CLI entrypoint, whose cold start alone measures
|
|
||||||
* 2.2-2.3s on a mid-range workstation (sb-it-1-dt, 2026-08-13) — so the
|
|
||||||
* previous 2s budget made the probe time out and report NO capability on
|
|
||||||
* such hosts, failing every launch with the #869 skew message even though
|
|
||||||
* the capability matched. The timeout only bounds the pathological hang
|
|
||||||
* case; the happy path returns as soon as the probe exits. Mirrors
|
|
||||||
* PROBE_TIMEOUT_SECONDS in the enforcement half
|
|
||||||
* (framework/tools/lease-broker/activation_version_gate.py).
|
|
||||||
*/
|
|
||||||
export const LEASE_CAPABILITY_PROBE_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
|
function capabilityMatches(candidate: LeaseActivationCapability | null): boolean {
|
||||||
return (
|
return (
|
||||||
candidate !== null &&
|
candidate !== null &&
|
||||||
@@ -123,28 +110,12 @@ export function defaultResolveCliEntry(
|
|||||||
return join(dirname(mainEntry), 'cli.js');
|
return join(dirname(mainEntry), 'cli.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Narrow injectable seam for the synchronous child process used by the
|
|
||||||
* capability probe. */
|
|
||||||
export type CapabilityProbeExecFile = (
|
|
||||||
file: string,
|
|
||||||
args: string[],
|
|
||||||
options: {
|
|
||||||
encoding: BufferEncoding;
|
|
||||||
timeout: number;
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'];
|
|
||||||
},
|
|
||||||
) => string;
|
|
||||||
|
|
||||||
/** Injectable inputs for {@link defaultCapabilityProbe}. */
|
/** Injectable inputs for {@link defaultCapabilityProbe}. */
|
||||||
export interface CapabilityProbeDeps {
|
export interface CapabilityProbeDeps {
|
||||||
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
|
/** Resolve the CLI entrypoint (`cli.js`) to probe. Defaults to
|
||||||
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
|
* {@link defaultResolveCliEntry}. Inject to point at an isolated scratch
|
||||||
* location in tests — never at the real package's `dist/`. */
|
* location in tests — never at the real package's `dist/`. */
|
||||||
resolveCliEntry?: () => string;
|
resolveCliEntry?: () => string;
|
||||||
/** Execute the resolved CLI entrypoint. Defaults to the real
|
|
||||||
* `execFileSync`. Inject so transport behavior and options can be tested
|
|
||||||
* without spawning a process. */
|
|
||||||
execFile?: CapabilityProbeExecFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,10 +139,9 @@ export function defaultCapabilityProbe(
|
|||||||
const cliEntry = resolveCliEntry();
|
const cliEntry = resolveCliEntry();
|
||||||
if (!existsSync(cliEntry)) return null;
|
if (!existsSync(cliEntry)) return null;
|
||||||
|
|
||||||
const execFile: CapabilityProbeExecFile = deps.execFile ?? execFileSync;
|
const output = execFileSync(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
||||||
const output = execFile(process.execPath, [cliEntry, LEASE_CAPABILITY_PROBE_COMMAND], {
|
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: LEASE_CAPABILITY_PROBE_TIMEOUT_MS,
|
timeout: 2000,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stderr
|
from contextlib import redirect_stderr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
|
|
||||||
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
TOOLS_DIR = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||||
@@ -61,20 +57,6 @@ def matching_capability() -> dict[str, object]:
|
|||||||
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
return dict(VERSION_GATE.EXPECTED_ACTIVATION_CAPABILITY)
|
||||||
|
|
||||||
|
|
||||||
def write_fake_mosaic(directory: Path, marker: Path) -> Path:
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
executable = directory / "mosaic"
|
|
||||||
executable.write_text(
|
|
||||||
"#!/bin/sh\n"
|
|
||||||
f"printf '%s\\n' executed >> {shlex.quote(str(marker))}\n"
|
|
||||||
"printf '%s\\n' "
|
|
||||||
"'{\"name\":\"lease-runtime-activation\",\"version\":1}'\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
executable.chmod(0o755)
|
|
||||||
return executable
|
|
||||||
|
|
||||||
|
|
||||||
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
class AssertActivationCapabilityMatchesTest(unittest.TestCase):
|
||||||
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
"""Unit-level coverage of `activation_version_gate.py`'s own assertion,
|
||||||
isolated from the launch-runtime.py seam it is wired into below."""
|
isolated from the launch-runtime.py seam it is wired into below."""
|
||||||
@@ -128,102 +110,11 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
|
|||||||
handling — never spawns a real `mosaic` process."""
|
handling — never spawns a real `mosaic` process."""
|
||||||
|
|
||||||
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
def test_returns_none_when_mosaic_is_not_resolvable_on_path(self) -> None:
|
||||||
# Keep even a deliberate ambient-lookup mutation away from any host
|
result = VERSION_GATE.default_probe_activation_capability(
|
||||||
# installation. The dedicated hermeticity tests below provide fake
|
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
||||||
# ambient executables and markers.
|
)
|
||||||
with mock.patch.dict(
|
|
||||||
os.environ, {"PATH": "/nonexistent-ambient-bin-dir-for-869-c4-test"}
|
|
||||||
):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{"PATH": "/nonexistent-bin-dir-for-869-c4-test"}
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|
||||||
def test_supplied_path_wins_over_ambient_process_path(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
supplied_marker = root / "supplied.marker"
|
|
||||||
ambient_marker = root / "ambient.marker"
|
|
||||||
supplied_bin = root / "supplied-bin"
|
|
||||||
ambient_bin = root / "ambient-bin"
|
|
||||||
write_fake_mosaic(supplied_bin, supplied_marker)
|
|
||||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
|
||||||
|
|
||||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{"PATH": str(supplied_bin)}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, matching_capability())
|
|
||||||
self.assertTrue(supplied_marker.exists())
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
|
|
||||||
def test_absent_or_empty_supplied_path_never_falls_back_or_executes(self) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
ambient_marker = root / "ambient.marker"
|
|
||||||
current_directory_marker = root / "current-directory.marker"
|
|
||||||
ambient_bin = root / "ambient-bin"
|
|
||||||
current_directory = root / "current-directory"
|
|
||||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
|
||||||
write_fake_mosaic(current_directory, current_directory_marker)
|
|
||||||
original_directory = Path.cwd()
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.chdir(current_directory)
|
|
||||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
|
||||||
for supplied_environment in ({}, {"PATH": ""}):
|
|
||||||
with self.subTest(environ=supplied_environment):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
supplied_environment
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
self.assertFalse(current_directory_marker.exists())
|
|
||||||
finally:
|
|
||||||
os.chdir(original_directory)
|
|
||||||
|
|
||||||
def test_valid_override_wins_and_invalid_override_does_not_fall_back_to_path(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
root = Path(temporary)
|
|
||||||
supplied_marker = root / "supplied.marker"
|
|
||||||
ambient_marker = root / "ambient.marker"
|
|
||||||
override_marker = root / "override.marker"
|
|
||||||
supplied_bin = root / "supplied-bin"
|
|
||||||
ambient_bin = root / "ambient-bin"
|
|
||||||
override_bin = root / "override-bin"
|
|
||||||
write_fake_mosaic(supplied_bin, supplied_marker)
|
|
||||||
write_fake_mosaic(ambient_bin, ambient_marker)
|
|
||||||
override_executable = write_fake_mosaic(override_bin, override_marker)
|
|
||||||
|
|
||||||
with mock.patch.dict(os.environ, {"PATH": str(ambient_bin)}):
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{
|
|
||||||
"PATH": str(supplied_bin),
|
|
||||||
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(override_executable),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.assertEqual(result, matching_capability())
|
|
||||||
self.assertTrue(override_marker.exists())
|
|
||||||
self.assertFalse(supplied_marker.exists())
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
|
|
||||||
override_marker.unlink()
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{
|
|
||||||
"PATH": str(supplied_bin),
|
|
||||||
VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: str(
|
|
||||||
root / "invalid-override" / "mosaic"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.assertIsNone(result)
|
|
||||||
self.assertFalse(override_marker.exists())
|
|
||||||
self.assertFalse(supplied_marker.exists())
|
|
||||||
self.assertFalse(ambient_marker.exists())
|
|
||||||
|
|
||||||
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
def test_override_command_is_parsed_and_the_probe_subcommand_is_not_double_appended(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -244,29 +135,6 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
|
|||||||
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
self.assertEqual(result, {"name": "lease-runtime-activation", "version": 1})
|
||||||
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
self.assertEqual(captured, [["/fake/mosaic", "__lease-capability"]])
|
||||||
|
|
||||||
def test_probe_passes_ten_second_timeout_to_runner(self) -> None:
|
|
||||||
captured_argv: list[str] = []
|
|
||||||
captured_kwargs: dict[str, object] = {}
|
|
||||||
|
|
||||||
class FakeCompleted:
|
|
||||||
returncode = 0
|
|
||||||
stdout = '{"name": "lease-runtime-activation", "version": 1}'
|
|
||||||
|
|
||||||
def fake_run(argv: list[str], **kwargs: object) -> FakeCompleted:
|
|
||||||
captured_argv.extend(argv)
|
|
||||||
captured_kwargs.update(kwargs)
|
|
||||||
return FakeCompleted()
|
|
||||||
|
|
||||||
result = VERSION_GATE.default_probe_activation_capability(
|
|
||||||
{VERSION_GATE.MOSAIC_COMMAND_OVERRIDE_VAR: "/fake/mosaic"},
|
|
||||||
run=fake_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result, matching_capability())
|
|
||||||
self.assertEqual(captured_argv, ["/fake/mosaic"])
|
|
||||||
self.assertEqual(captured_kwargs["timeout"], 10.0)
|
|
||||||
self.assertEqual(captured_kwargs["check"], False)
|
|
||||||
|
|
||||||
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
def test_fails_closed_on_nonzero_exit_malformed_json_and_missing_fields(self) -> None:
|
||||||
class NonZeroExit:
|
class NonZeroExit:
|
||||||
returncode = 1
|
returncode = 1
|
||||||
@@ -306,7 +174,7 @@ class ProbeActivationCapabilityTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
def test_fails_closed_on_timeout_and_transport_error(self) -> None:
|
||||||
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
def timeout_run(*_args: object, **_kwargs: object) -> None:
|
||||||
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=10.0)
|
raise subprocess.TimeoutExpired(cmd="mosaic", timeout=2.0)
|
||||||
|
|
||||||
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
def oserror_run(*_args: object, **_kwargs: object) -> None:
|
||||||
raise OSError("no such file or directory")
|
raise OSError("no such file or directory")
|
||||||
|
|||||||
Reference in New Issue
Block a user