Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6c8fe0cb |
@@ -244,12 +244,115 @@ describe('[email protected]', (): void => {
|
||||
* absent env — either of which turns the condition into decoration while the
|
||||
* literal-string assertion above still passes.
|
||||
*/
|
||||
/**
|
||||
* The condition an installed unit actually presents to systemd: the shipped
|
||||
* unit's values with drop-ins applied in lexical order, honouring the list
|
||||
* reset an empty assignment performs.
|
||||
*
|
||||
* Reading the *installed* tree rather than the template is the point. The
|
||||
* template alone cannot show what `install` produced, and #1310 lived in that
|
||||
* gap: the template was correct for the install it was written for, and wrong
|
||||
* for the one the operator had.
|
||||
*/
|
||||
async function effectiveConditionPaths(systemdUserDir: string): Promise<string[]> {
|
||||
const dropInDir = join(systemdUserDir, '[email protected]');
|
||||
const dropIns = (
|
||||
await readdir(dropInDir).catch((error: NodeJS.ErrnoException): string[] => {
|
||||
if (error.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
})
|
||||
)
|
||||
.filter((name): boolean => name.endsWith('.conf'))
|
||||
.sort();
|
||||
const sources = [
|
||||
join(systemdUserDir, '[email protected]'),
|
||||
...dropIns.map((name): string => join(dropInDir, name)),
|
||||
];
|
||||
const conditions: string[] = [];
|
||||
for (const source of sources) {
|
||||
for (const line of (await readFile(source, 'utf8')).split('\n')) {
|
||||
const match = line.match(/^ConditionPathExists=(.*)$/);
|
||||
if (!match) continue;
|
||||
const value = match[1]!.trim();
|
||||
if (value === '') conditions.length = 0;
|
||||
else conditions.push(value);
|
||||
}
|
||||
}
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/** Makes `~/.mosaic` adoptable — the shape that puts a real box in split mode. */
|
||||
async function adoptableBrainHome(): Promise<string> {
|
||||
const brainHome = join(tempHome!, '.mosaic');
|
||||
await mkdir(join(brainHome, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
||||
return brainHome;
|
||||
}
|
||||
|
||||
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');
|
||||
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
||||
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
|
||||
const rendered = (await effectiveConditionPaths(systemdUserDir)).map((value): string =>
|
||||
value.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'));
|
||||
expect(rendered).toEqual([join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')]);
|
||||
});
|
||||
|
||||
/**
|
||||
* #1310. The test above passed throughout the bug, because its fixture never
|
||||
* creates `~/.mosaic/fleet/agents` and so can only ever reach legacy mode,
|
||||
* where brain home and config home are the same directory and the drift it
|
||||
* names is unobservable. A guard that cannot reach the state it guards is
|
||||
* decoration; this is the arm that reaches it.
|
||||
*
|
||||
* Measured consequence on a real box (mosaic-sbx-dev 1124, 0.0.50-next.2507):
|
||||
* the config-home condition can never be met, systemd reports every seat
|
||||
* `ConditionResult=no` with `Result=success` and `ExecMainStatus=0`, and
|
||||
* `fleet start` exits 0 having started nothing.
|
||||
*/
|
||||
it('follows the brain home when one is active, not the config home', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const brainHome = await adoptableBrainHome();
|
||||
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
||||
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
|
||||
const rendered = (await effectiveConditionPaths(systemdUserDir)).map((value): string =>
|
||||
value.replace('%h', tempHome!).replace('%i', 'coder0'),
|
||||
);
|
||||
|
||||
// Exactly one condition: the drop-in must reset the shipped value, not join
|
||||
// it. Two conditions would both have to pass and the seat would stay skipped.
|
||||
expect(rendered).toEqual([join(brainHome, 'fleet', 'agents', 'coder0.env.generated')]);
|
||||
expect(rendered[0]).not.toContain(mosaicHome);
|
||||
});
|
||||
|
||||
/**
|
||||
* The other direction, which fails the same silent way: a box that had a
|
||||
* brain home and no longer does would keep a condition pointing at a path the
|
||||
* reconciler has stopped writing.
|
||||
*/
|
||||
it('drops a stale brain-home override when the brain home goes away', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
||||
await adoptableBrainHome();
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
// Control: the override is present before the brain home is removed, so a
|
||||
// passing assertion below cannot come from it never having been written.
|
||||
expect(await effectiveConditionPaths(systemdUserDir)).toHaveLength(1);
|
||||
expect((await effectiveConditionPaths(systemdUserDir))[0]).toContain('.mosaic/fleet/agents');
|
||||
|
||||
await rm(join(tempHome!, '.mosaic'), { recursive: true, force: true });
|
||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
||||
|
||||
const rendered = (await effectiveConditionPaths(systemdUserDir)).map((value): string =>
|
||||
value.replace('%h', tempHome!).replace('%i', 'coder0'),
|
||||
);
|
||||
expect(rendered).toEqual([join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated')]);
|
||||
});
|
||||
|
||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir, hostname, userInfo } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
||||
import { brainHomeIsActive, fleetAgentEnvDir } from '../fleet/brain-home.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
@@ -2350,6 +2350,80 @@ export function registerFleetAgentCommands(
|
||||
});
|
||||
}
|
||||
|
||||
/** Directory holding the install-managed drop-ins for `[email protected]`. */
|
||||
export function agentUnitDropInDir(systemdUserDir: string): string {
|
||||
return join(systemdUserDir, '[email protected]');
|
||||
}
|
||||
|
||||
/** The one drop-in `mosaic fleet install` owns: the brain-home env condition. */
|
||||
export function agentUnitBrainHomeDropInPath(systemdUserDir: string): string {
|
||||
return join(agentUnitDropInDir(systemdUserDir), '10-brain-home.conf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in re-pointing the agent unit's env condition at the resolved brain home.
|
||||
*
|
||||
* The shipped unit gates on `%h/.config/mosaic/fleet/agents/%i.env.generated`,
|
||||
* which is right for a legacy single-tree install. When a brain home is active
|
||||
* (canon `docs/STRUCTURE-CANON.md` §2) the reconciler writes seat env under the
|
||||
* brain home instead, so that condition can never be met — and an unmet
|
||||
* condition is reported as success (`ConditionResult=no`, `Result=success`,
|
||||
* `ExecMainStatus=0`). `fleet start` therefore returns 0, prints nothing, and
|
||||
* starts no seat, with every status surface agreeing the fleet is healthy
|
||||
* (#1310, measured on mosaic-sbx-dev 1124 against 0.0.50-next.2507).
|
||||
*
|
||||
* An empty `ConditionPathExists=` resets the inherited list before the correct
|
||||
* path is added, so the shipped condition is replaced rather than joined — two
|
||||
* conditions would both have to pass and the seat would stay skipped.
|
||||
*
|
||||
* The boot-window behaviour the shipped unit documents is deliberately kept:
|
||||
* this still gates on the generated env existing, only at the path that
|
||||
* actually receives it. The launcher already resolves the brain home itself,
|
||||
* so nothing downstream of the condition needs to change.
|
||||
*/
|
||||
export function renderAgentUnitBrainHomeDropIn(agentEnvDir: string): string {
|
||||
return [
|
||||
'# Managed by `mosaic fleet install`. Do not edit; re-run install to update.',
|
||||
'#',
|
||||
'# Re-points the seat env condition at the active brain home. Without this the',
|
||||
'# shipped config-home condition can never be met on a split install and every',
|
||||
'# seat is silently skipped while `fleet start` reports success (#1310).',
|
||||
'[Unit]',
|
||||
'ConditionPathExists=',
|
||||
`ConditionPathExists=${join(agentEnvDir, '%i.env.generated')}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the brain-home drop-in when a brain is active, and remove a stale one
|
||||
* when it is not.
|
||||
*
|
||||
* The removal arm is load-bearing, not tidiness: a host that had a brain home
|
||||
* and no longer does would otherwise keep a condition pointing at a path the
|
||||
* reconciler has stopped writing, which fails in exactly the same silent
|
||||
* direction as the bug this fixes.
|
||||
*/
|
||||
export async function syncAgentUnitBrainHomeDropIn(
|
||||
systemdUserDir: string,
|
||||
mosaicHome: string,
|
||||
): Promise<void> {
|
||||
const dropInPath = agentUnitBrainHomeDropInPath(systemdUserDir);
|
||||
if (!brainHomeIsActive(mosaicHome)) {
|
||||
try {
|
||||
await unlink(dropInPath);
|
||||
} catch (error) {
|
||||
// Absent is the expected state on a legacy install; anything else is real.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
await mkdir(agentUnitDropInDir(systemdUserDir), { recursive: true });
|
||||
await writeFile(dropInPath, renderAgentUnitBrainHomeDropIn(fleetAgentEnvDir(mosaicHome)), 'utf8');
|
||||
}
|
||||
|
||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
@@ -2410,6 +2484,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
join(frameworkRoot, 'systemd', 'user', '[email protected]'),
|
||||
join(activePaths.systemdUserDir, '[email protected]'),
|
||||
);
|
||||
// Must follow the copy: the drop-in overrides the unit just written, and on a
|
||||
// split install the unit alone gates every seat on a path nothing writes.
|
||||
await syncAgentUnitBrainHomeDropIn(activePaths.systemdUserDir, activePaths.mosaicHome);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'systemd', 'user', '[email protected]'),
|
||||
join(activePaths.systemdUserDir, '[email protected]'),
|
||||
|
||||
Reference in New Issue
Block a user