ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fargo <[email protected]>
178 lines
8.2 KiB
TypeScript
178 lines
8.2 KiB
TypeScript
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { placeUnitFile, resolveLeaseBrokerSocketForPreflight } from './fleet.js';
|
|
|
|
/**
|
|
* Unit-placement regression harness for #1292.
|
|
*
|
|
* The two measured defects this suite pins:
|
|
* 1. `systemctl enable <name>` does NOT rewrite an existing by-path
|
|
* wants-symlink — so placement must remove stale residue explicitly, and
|
|
* acceptance asserts on the RESULTING SYMLINK TARGET, never on the enable
|
|
* call's argument (asserting the call cannot see where the link ended up).
|
|
* 2. Node's copyFile FOLLOWS a by-path symlink at the destination and
|
|
* overwrites the SEED template. Acceptance asserts on the SEED's bytes
|
|
* AND mtime — unchanged — which is the only check that can redden for
|
|
* finding 2. The symlink-target assertion catches finding 1; these are
|
|
* different defects with different failure modes.
|
|
*
|
|
* Fixtures are entirely inside tmpdirs (source template, active systemd dir,
|
|
* wants dir) — no real host paths are touched by this suite.
|
|
*/
|
|
|
|
describe('placeUnitFile (#1292 unit placement)', () => {
|
|
const cleanup: string[] = [];
|
|
afterEach(async () => {
|
|
while (cleanup.length > 0) {
|
|
await rm(cleanup.pop()!, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), 'place-unit-'));
|
|
cleanup.push(root);
|
|
const seedDir = join(root, 'seed');
|
|
const activeDir = join(root, 'active');
|
|
await mkdir(seedDir, { recursive: true });
|
|
await mkdir(activeDir, { recursive: true });
|
|
const seedTemplate = join(seedDir, 'unit-under-test.service');
|
|
await writeFile(
|
|
seedTemplate,
|
|
'[Unit]\nDescription=seed template\n[Service]\nType=oneshot\nExecStart=/bin/true\n[Install]\nWantedBy=default.target\n',
|
|
);
|
|
const activeSource = join(root, 'active-source.service');
|
|
await writeFile(
|
|
activeSource,
|
|
'[Unit]\nDescription=active copy v2\n[Service]\nType=oneshot\nExecStart=/bin/true\n[Install]\nWantedBy=default.target\n',
|
|
);
|
|
return { root, seedDir, activeDir, seedTemplate, activeSource };
|
|
}
|
|
|
|
it('places a regular file on a clean host (negative control: no residue anywhere)', async () => {
|
|
const f = await fixture();
|
|
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
|
|
expect(result.unlinkedDestinationSymlink).toBe(false);
|
|
expect(result.removedStaleWantsSymlink).toBe(false);
|
|
const info = await lstat(join(f.activeDir, 'unit-under-test.service'));
|
|
expect(info.isSymbolicLink()).toBe(false);
|
|
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toContain(
|
|
'active copy v2',
|
|
);
|
|
// Seed untouched by construction — but assert it, so the clean-host case
|
|
// cannot silently regress into seed-mutation.
|
|
expect(await readFile(f.seedTemplate, 'utf8')).toContain('seed template');
|
|
});
|
|
|
|
it('by-path residue: unlinks destination symlink, places the file, seed bytes AND mtime unchanged (finding 2)', async () => {
|
|
const f = await fixture();
|
|
const seedBefore = await readFile(f.seedTemplate, 'utf8');
|
|
const mtimeBefore = (await lstat(f.seedTemplate)).mtimeMs;
|
|
// The fomo-lin convention: by-path enable left a symlink AT the unit name
|
|
// pointing at the seed template, plus a wants-symlink doing the same.
|
|
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
|
|
const wantsDir = join(f.activeDir, 'default.target.wants');
|
|
await mkdir(wantsDir, { recursive: true });
|
|
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
|
|
|
|
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
|
|
expect(result.unlinkedDestinationSymlink).toBe(true);
|
|
expect(result.removedStaleWantsSymlink).toBe(true);
|
|
|
|
// FINDING 2's check: the seed is byte-identical and its mtime did not move.
|
|
expect(await readFile(f.seedTemplate, 'utf8')).toBe(seedBefore);
|
|
expect((await lstat(f.seedTemplate)).mtimeMs).toBe(mtimeBefore);
|
|
|
|
// The destination is now a regular file carrying the ACTIVE content.
|
|
const destInfo = await lstat(join(f.activeDir, 'unit-under-test.service'));
|
|
expect(destInfo.isSymbolicLink()).toBe(false);
|
|
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toContain(
|
|
'active copy v2',
|
|
);
|
|
});
|
|
|
|
it('by-path residue: no wants-symlink remains pointing at the seed (finding 1 residue cleared)', async () => {
|
|
const f = await fixture();
|
|
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
|
|
const wantsDir = join(f.activeDir, 'default.target.wants');
|
|
await mkdir(wantsDir, { recursive: true });
|
|
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
|
|
|
|
await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
|
|
|
|
// After placement the stale wants link is GONE (enable-by-name recreates
|
|
// it correctly). A link still present must not point at the seed.
|
|
try {
|
|
const link = await lstat(join(wantsDir, 'unit-under-test.service'));
|
|
if (link.isSymbolicLink()) {
|
|
const target = await readFile(join(wantsDir, 'unit-under-test.service'), 'utf8').catch(
|
|
async () => '',
|
|
);
|
|
expect(target).not.toContain('seed template');
|
|
}
|
|
} catch {
|
|
// absent wants link — the expected post-placement state
|
|
}
|
|
});
|
|
|
|
it('idempotence: second placement on a reconciled host is a no-op producing the identical final state', async () => {
|
|
const f = await fixture();
|
|
// Reconciled starting state: regular file at the name, wants link to the active copy.
|
|
await writeFile(
|
|
join(f.activeDir, 'unit-under-test.service'),
|
|
await readFile(f.activeSource, 'utf8'),
|
|
);
|
|
const wantsDir = join(f.activeDir, 'default.target.wants');
|
|
await mkdir(wantsDir, { recursive: true });
|
|
await symlink(
|
|
join(f.activeDir, 'unit-under-test.service'),
|
|
join(wantsDir, 'unit-under-test.service'),
|
|
);
|
|
const before = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
|
|
|
|
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
|
|
// No destructive step fired: no unlink, no wants removal.
|
|
expect(result.unlinkedDestinationSymlink).toBe(false);
|
|
expect(result.removedStaleWantsSymlink).toBe(false);
|
|
// Identical final state.
|
|
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toBe(before);
|
|
const link = await lstat(join(wantsDir, 'unit-under-test.service'));
|
|
expect(link.isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it('double install on by-path residue converges to the identical reconciled state', async () => {
|
|
const f = await fixture();
|
|
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
|
|
const wantsDir = join(f.activeDir, 'default.target.wants');
|
|
await mkdir(wantsDir, { recursive: true });
|
|
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
|
|
|
|
await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
|
|
const first = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
|
|
const secondRun = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
|
|
const second = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
|
|
expect(secondRun.unlinkedDestinationSymlink).toBe(false);
|
|
expect(second).toBe(first);
|
|
});
|
|
});
|
|
|
|
describe('resolveLeaseBrokerSocketForPreflight (#1292 preflight resolution)', () => {
|
|
it('explicit MOSAIC_LEASE_BROKER_SOCKET wins', () => {
|
|
expect(
|
|
resolveLeaseBrokerSocketForPreflight({ MOSAIC_LEASE_BROKER_SOCKET: '/custom/sock' }, 1000),
|
|
).toBe('/custom/sock');
|
|
});
|
|
it('XDG_RUNTIME_DIR next', () => {
|
|
expect(resolveLeaseBrokerSocketForPreflight({ XDG_RUNTIME_DIR: '/run/user/1001' }, 1000)).toBe(
|
|
'/run/user/1001/mosaic-lease/broker.sock',
|
|
);
|
|
});
|
|
it('falls back to /run/user/<uid>', () => {
|
|
expect(resolveLeaseBrokerSocketForPreflight({}, 1002)).toBe(
|
|
'/run/user/1002/mosaic-lease/broker.sock',
|
|
);
|
|
});
|
|
});
|