fix(#1408): mosaic-agent@ condition arms on either home shape (#1410)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
This commit was merged in pull request #1410.
This commit is contained in:
@@ -11,7 +11,17 @@ PartOf=mosaic-tmux-holder.service
|
||||
# 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
|
||||
#
|
||||
# #1408: the reconciler writes projections into the BRAIN home when one is
|
||||
# active (~/.mosaic/fleet/agents, mirroring start-agent-session.sh's brain-home
|
||||
# resolution), and into MOSAIC_HOME on a legacy single-tree host. A single
|
||||
# config-home condition therefore skipped every seat on brain-home estates —
|
||||
# measured on two estates: 27 projections vs 0, and 5 vs 0, gate never fired.
|
||||
# Two TRIGGERING conditions (the `|` prefix ORs same-type conditions, which
|
||||
# otherwise AND): either shape arms the unit; the launcher still resolves the
|
||||
# authoritative copy itself.
|
||||
ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||
ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -32,6 +32,17 @@ if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
||||
fail "holder must not start tmux through a login shell"
|
||||
fi
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
# #1408: the projection condition must arm on EITHER home shape. Both lines must
|
||||
# carry the `|` triggering prefix — same-type conditions without it AND together,
|
||||
# which can never be true (one file cannot exist at two paths), so a bare-spelling
|
||||
# regression would disable autostart everywhere while reading as "has a condition".
|
||||
grep -qF 'ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
|
||||
fail "agent lacks triggering condition for the config home projection"
|
||||
grep -qF 'ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
|
||||
fail "agent lacks triggering condition for the brain home projection (#1408)"
|
||||
if grep -qE '^ConditionPathExists=[^|]' "$AGENT"; then
|
||||
fail "agent has a non-triggering ConditionPathExists — same-type conditions AND, re-arming #1408"
|
||||
fi
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
||||
|
||||
@@ -211,12 +211,23 @@ describe('mosaic fleet install — roster v2', (): void => {
|
||||
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> {
|
||||
/**
|
||||
* Every `ConditionPathExists=` value declared by the unit template, in file
|
||||
* order, `|` triggering prefix included.
|
||||
*
|
||||
* Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not
|
||||
* a content assertion, and the count pin was itself the defect: when #1408
|
||||
* required a second triggering line (the `%h/.mosaic` brain-home shape),
|
||||
* this spec was a second consumer of the unit template that the shell suite
|
||||
* and the enumeration guard could not see, so the fix failed here first
|
||||
* (CI 2651 — the installation-documentation.spec.ts lesson again). Assert
|
||||
* content per line, never count.
|
||||
*/
|
||||
async function conditionPaths(): 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();
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
|
||||
}
|
||||
|
||||
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
||||
@@ -224,7 +235,26 @@ describe('[email protected]', (): void => {
|
||||
// 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');
|
||||
//
|
||||
// Two lines since #1408: the env projection lives under %h/.config/mosaic
|
||||
// on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
|
||||
// carry the `|` triggering prefix — systemd ANDs same-type conditions
|
||||
// unless every line is triggering (then they OR), and a bare spelling
|
||||
// would demand BOTH home shapes on one host, which is never true, so
|
||||
// every seat would silently skip.
|
||||
expect(await conditionPaths()).toEqual([
|
||||
'|%h/.config/mosaic/fleet/agents/%i.env.generated',
|
||||
'|%h/.mosaic/fleet/agents/%i.env.generated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
|
||||
// Invariant ported from test-fleet-units.sh, held separately from the
|
||||
// literal pin above so it survives future edits to the path set: every
|
||||
// ConditionPathExists line must stay triggering (`|`).
|
||||
for (const value of await conditionPaths()) {
|
||||
expect(value.startsWith('|')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -246,10 +276,18 @@ describe('[email protected]', (): void => {
|
||||
*/
|
||||
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 rendered = (await conditionPaths()).map((value) =>
|
||||
value.replace(/^\|/, '').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'));
|
||||
// Per home shape, the guard must render to exactly the file the
|
||||
// reconciler writes there: mosaicHome (%h/.config/mosaic) on
|
||||
// framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
|
||||
// line pinned to its file, not merely present.
|
||||
expect(rendered).toEqual([
|
||||
join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user