@celilo/cli 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +5 -4
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/publish/workspace.ts +9 -0
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +19 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +87 -44
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/policy/module-business-baseline.ts +0 -6
- package/src/services/api-access.test.ts +44 -0
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/celilo-events.test.ts +39 -0
- package/src/services/celilo-events.ts +41 -4
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +81 -2
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +48 -13
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/provider-converge.test.ts +16 -0
- package/src/services/provider-converge.ts +106 -4
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/orchestrator.test.ts +164 -0
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/context.ts +14 -1
- package/src/services/static-content-converge.test.ts +0 -516
- package/src/services/static-content-converge.ts +0 -390
|
@@ -19,11 +19,15 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { type DbClient, getDb } from '../db/client';
|
|
22
|
-
import { modules, secrets } from '../db/schema';
|
|
22
|
+
import { capabilities, modules, secrets } from '../db/schema';
|
|
23
23
|
import type { ModuleManifest } from '../manifest/schema';
|
|
24
24
|
import { resetTestDbPath } from '../test-utils/db-path';
|
|
25
25
|
import { findMissingSecrets } from './config-interview';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
findMissingBuildArtifacts,
|
|
28
|
+
findMissingRequiredVariables,
|
|
29
|
+
validateAndPrepareDeployment,
|
|
30
|
+
} from './deploy-validation';
|
|
27
31
|
|
|
28
32
|
function manifestWithStringMapSecret(): ModuleManifest {
|
|
29
33
|
return {
|
|
@@ -366,3 +370,78 @@ describe('findMissingBuildArtifacts (deploy build-gate)', () => {
|
|
|
366
370
|
expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']);
|
|
367
371
|
});
|
|
368
372
|
});
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* celilo#1384. An OPTIONAL capability is optional in its PRESENCE, not in its
|
|
376
|
+
* contract: with a provider installed the module's hooks call it, so a
|
|
377
|
+
* provider on another major fails at runtime — and `on_install` failing is
|
|
378
|
+
* only a warning, so it fails silently. Measured: generic-cpanel 0.4.1+2
|
|
379
|
+
* (external_web 2.0.0) against tango-nexus 0.8.0 (optional external_web
|
|
380
|
+
* 1.0.0); nothing published, the site kept serving, nothing said so.
|
|
381
|
+
*/
|
|
382
|
+
describe('validateAndPrepareDeployment (optional capability version gate)', () => {
|
|
383
|
+
let tempDir: string;
|
|
384
|
+
let db: DbClient;
|
|
385
|
+
|
|
386
|
+
function seed(optionalVersion: string, providerVersion: string | null) {
|
|
387
|
+
db.insert(modules)
|
|
388
|
+
.values({
|
|
389
|
+
id: 'consumer',
|
|
390
|
+
name: 'Consumer',
|
|
391
|
+
sourcePath: tempDir,
|
|
392
|
+
version: '1.0.0',
|
|
393
|
+
manifestData: {
|
|
394
|
+
celilo_contract: '1.0',
|
|
395
|
+
id: 'consumer',
|
|
396
|
+
name: 'Consumer',
|
|
397
|
+
version: '1.0.0',
|
|
398
|
+
description: 'fixture',
|
|
399
|
+
optional: { capabilities: [{ name: 'external_web', version: optionalVersion }] },
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
.run();
|
|
403
|
+
if (providerVersion === null) return;
|
|
404
|
+
db.insert(modules)
|
|
405
|
+
.values({
|
|
406
|
+
id: 'provider',
|
|
407
|
+
name: 'Provider',
|
|
408
|
+
sourcePath: tempDir,
|
|
409
|
+
version: '1.0.0',
|
|
410
|
+
manifestData: {},
|
|
411
|
+
})
|
|
412
|
+
.run();
|
|
413
|
+
db.insert(capabilities)
|
|
414
|
+
.values({
|
|
415
|
+
moduleId: 'provider',
|
|
416
|
+
capabilityName: 'external_web',
|
|
417
|
+
version: providerVersion,
|
|
418
|
+
data: {},
|
|
419
|
+
})
|
|
420
|
+
.run();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
beforeEach(() => {
|
|
424
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-optional-cap-'));
|
|
425
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
426
|
+
db = getDb();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
afterEach(() => {
|
|
430
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
431
|
+
resetTestDbPath();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('refuses the deploy when the installed provider is another major', async () => {
|
|
435
|
+
seed('1.0.0', '2.0.0');
|
|
436
|
+
const result = await validateAndPrepareDeployment('consumer', db);
|
|
437
|
+
expect(result.success).toBe(false);
|
|
438
|
+
expect(result.error).toContain('external_web');
|
|
439
|
+
expect(result.error).toContain('provider');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('does not refuse when nobody provides the optional capability', async () => {
|
|
443
|
+
seed('1.0.0', null);
|
|
444
|
+
const result = await validateAndPrepareDeployment('consumer', db);
|
|
445
|
+
expect(result.error).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
@@ -113,6 +113,25 @@ export async function validateAndPrepareDeployment(
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// An OPTIONAL capability is optional in its PRESENCE, not in its contract.
|
|
117
|
+
// When a provider is installed the module's hooks will call it, so a provider
|
|
118
|
+
// on another major breaks at runtime exactly as a required one would — except
|
|
119
|
+
// an `on_install` failure is only a warning, so it breaks silently
|
|
120
|
+
// (celilo#1384). No provider installed is still fine: the version check below
|
|
121
|
+
// skips a capability nobody provides.
|
|
122
|
+
if (manifest.optional?.capabilities) {
|
|
123
|
+
const optionalMismatches = await findCapabilityVersionMismatches(
|
|
124
|
+
manifest.optional.capabilities,
|
|
125
|
+
db,
|
|
126
|
+
);
|
|
127
|
+
if (optionalMismatches.length > 0) {
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
error: formatVersionMismatchError(moduleId, optionalMismatches),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
// Check for missing required variables BEFORE generating — if any are missing
|
|
117
136
|
// we return them for the caller to interview, then the caller re-invokes deploy
|
|
118
137
|
// after the user has answered. This prevents generation from failing mid-way
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { readFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import { type Bus, describeError } from '@celilo/event-bus';
|
|
22
|
+
import { type Bus, type BusHealth, describeError } from '@celilo/event-bus';
|
|
23
23
|
import { desc, eq, inArray } from 'drizzle-orm';
|
|
24
24
|
import { parse as parseYaml } from 'yaml';
|
|
25
25
|
import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
|
|
@@ -203,6 +203,12 @@ export interface DispatcherCheckOptions {
|
|
|
203
203
|
* Injected so the crash-loop aspect is testable without launchd.
|
|
204
204
|
*/
|
|
205
205
|
launchdProbe?: (scope: SupervisorScope) => LaunchdUnitStatus | null;
|
|
206
|
+
/**
|
|
207
|
+
* A verdict already taken, normally `await bus.healthConfirmed()`. Callers
|
|
208
|
+
* that can await pass it so a load-starved dispatcher reads `lagging` rather
|
|
209
|
+
* than the `wedged` a single sample cannot distinguish it from (celilo#1399).
|
|
210
|
+
*/
|
|
211
|
+
health?: BusHealth;
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/**
|
|
@@ -214,7 +220,7 @@ export interface DispatcherCheckOptions {
|
|
|
214
220
|
*/
|
|
215
221
|
export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): FleetFinding {
|
|
216
222
|
const now = opts.now ?? Date.now();
|
|
217
|
-
const health = bus.health();
|
|
223
|
+
const health = opts.health ?? bus.health();
|
|
218
224
|
const hb = bus.db
|
|
219
225
|
.query<HeartbeatRow, []>(
|
|
220
226
|
'SELECT dispatcher_id, last_heartbeat, started_at, pid, version FROM dispatcher_heartbeat ORDER BY last_heartbeat DESC LIMIT 1',
|
|
@@ -227,14 +233,25 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
227
233
|
|
|
228
234
|
// (1) running — a fresh heartbeat. health() already classifies a
|
|
229
235
|
// stale/absent heartbeat as no_dispatcher.
|
|
230
|
-
if (health.status === 'no_dispatcher' || !hb) {
|
|
236
|
+
if (health.status === 'no_dispatcher' || health.status === 'wedged' || !hb) {
|
|
231
237
|
statuses.push('fail');
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
if (health.status === 'wedged') {
|
|
239
|
+
// Registered, process alive, heartbeat stale, nothing running: it is present
|
|
240
|
+
// but dead, so "start the dispatcher" is the wrong instruction (celilo#1398).
|
|
241
|
+
detail.push(
|
|
242
|
+
`dispatcher WEDGED — pid ${hb?.pid ?? '?'} is alive and registered but its heartbeat is ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s old with nothing running (events are queueing, not delivered)`,
|
|
243
|
+
);
|
|
244
|
+
remediations.push(
|
|
245
|
+
health.remedy ?? 'restart the dispatcher: `celilo events restart-daemon --system`',
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
detail.push(
|
|
249
|
+
'no live dispatcher — heartbeat absent or stale (events are queueing, not delivered)',
|
|
250
|
+
);
|
|
251
|
+
remediations.push(
|
|
252
|
+
'start the dispatcher: `systemctl --user enable --now celilo-events.service` (or `celilo events install-daemon` then enable it)',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
238
255
|
// A unit can be INSTALLED and still dead: the supervisor respawns a
|
|
239
256
|
// program that cannot start in the unit's environment and it dies again,
|
|
240
257
|
// forever. On macOS the classic cause is a PATH-less
|
|
@@ -274,6 +291,15 @@ export function checkDispatcher(bus: Bus, opts: DispatcherCheckOptions = {}): Fl
|
|
|
274
291
|
detail.push(
|
|
275
292
|
`running (pid ${hb.pid}, heartbeat ${Math.round(ageMs / 1000)}s ago, code v${hb.version})`,
|
|
276
293
|
);
|
|
294
|
+
if (health.status === 'lagging') {
|
|
295
|
+
// Confirmed alive by a second beat: slow, not dead. Worth saying — an operator
|
|
296
|
+
// reading a 60s heartbeat wants to know it was checked — but not a failure,
|
|
297
|
+
// and explicitly not a restart (celilo#1399).
|
|
298
|
+
statuses.push('warn');
|
|
299
|
+
detail.push(
|
|
300
|
+
'heartbeat is stale but still advancing — the dispatcher is alive and starved for CPU, not wedged; it recovers when load drops',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
277
303
|
if (health.status === 'stuck') {
|
|
278
304
|
statuses.push('warn');
|
|
279
305
|
detail.push(
|
|
@@ -1395,7 +1421,11 @@ export async function runFleetChecks(
|
|
|
1395
1421
|
const hostLiveness = opts.hostLiveness ?? (() => collectHostLiveness(db));
|
|
1396
1422
|
return [
|
|
1397
1423
|
checkSchemaDrift(db),
|
|
1398
|
-
checkDispatcher(bus, {
|
|
1424
|
+
checkDispatcher(bus, {
|
|
1425
|
+
now: opts.now,
|
|
1426
|
+
installedCodeMtimeMs: opts.installedCodeMtimeMs,
|
|
1427
|
+
health: await bus.healthConfirmed(),
|
|
1428
|
+
}),
|
|
1399
1429
|
checkBuildBusPublishing(bus, db, { now: opts.now }),
|
|
1400
1430
|
checkSubscribers(bus, db),
|
|
1401
1431
|
checkCapabilityProviders(db),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { NetworkZone } from '../db/schema';
|
|
3
|
+
import {
|
|
4
|
+
applyReclassification,
|
|
5
|
+
diffInterfaceZones,
|
|
6
|
+
explainMissingZoneInterface,
|
|
7
|
+
} from './machine-interface-zones';
|
|
8
|
+
|
|
9
|
+
/** The zone model as it stands today: 10.0.120.0/24 is secure-mgmt. */
|
|
10
|
+
const detect = async (ip: string): Promise<NetworkZone | 'unknown'> => {
|
|
11
|
+
if (ip.startsWith('10.0.120.')) return 'secure-mgmt';
|
|
12
|
+
if (ip.startsWith('10.0.10.')) return 'dmz';
|
|
13
|
+
return 'unknown';
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* celilo-mgr as celilo#1382 found it: added before `secure-mgmt` existed,
|
|
18
|
+
* while the detector labelled unmatched subnets `external`.
|
|
19
|
+
*/
|
|
20
|
+
const celiloMgr = [{ name: 'eth0', ipAddress: '10.0.120.10', zone: 'external' }];
|
|
21
|
+
|
|
22
|
+
describe('diffInterfaceZones', () => {
|
|
23
|
+
test('reports a label the current zone model disagrees with', async () => {
|
|
24
|
+
expect(await diffInterfaceZones(celiloMgr, detect)).toEqual([
|
|
25
|
+
{ name: 'eth0', ipAddress: '10.0.120.10', from: 'external', to: 'secure-mgmt' },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('reports nothing when the stored labels already agree', async () => {
|
|
30
|
+
const current = [{ name: 'eth0', ipAddress: '10.0.120.10', zone: 'secure-mgmt' }];
|
|
31
|
+
expect(await diffInterfaceZones(current, detect)).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('reports only the interfaces that changed', async () => {
|
|
35
|
+
const mixed = [
|
|
36
|
+
{ name: 'eth0', ipAddress: '10.0.120.10', zone: 'external' },
|
|
37
|
+
{ name: 'eth1', ipAddress: '10.0.10.5', zone: 'dmz' },
|
|
38
|
+
];
|
|
39
|
+
const changes = await diffInterfaceZones(mixed, detect);
|
|
40
|
+
expect(changes.map((c) => c.name)).toEqual(['eth0']);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('applyReclassification', () => {
|
|
45
|
+
test('writes the new label and leaves the input untouched', async () => {
|
|
46
|
+
const changes = await diffInterfaceZones(celiloMgr, detect);
|
|
47
|
+
expect(applyReclassification(celiloMgr, changes)).toEqual([
|
|
48
|
+
{ name: 'eth0', ipAddress: '10.0.120.10', zone: 'secure-mgmt' },
|
|
49
|
+
]);
|
|
50
|
+
expect(celiloMgr[0].zone).toBe('external');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('explainMissingZoneInterface', () => {
|
|
55
|
+
test('says MISLABELLED, and names the interface, when one would match', async () => {
|
|
56
|
+
const changes = await diffInterfaceZones(celiloMgr, detect);
|
|
57
|
+
const message = explainMissingZoneInterface(celiloMgr, 'secure-mgmt', changes);
|
|
58
|
+
expect(message).toContain('MISLABELLED');
|
|
59
|
+
expect(message).toContain('eth0');
|
|
60
|
+
expect(message).toContain('10.0.120.10');
|
|
61
|
+
expect(message).toContain('machine reclassify');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('says the interface is absent when no relabel would produce one', async () => {
|
|
65
|
+
const dmzOnly = [{ name: 'eth0', ipAddress: '10.0.10.5', zone: 'dmz' }];
|
|
66
|
+
const changes = await diffInterfaceZones(dmzOnly, detect);
|
|
67
|
+
const message = explainMissingZoneInterface(dmzOnly, 'secure-mgmt', changes);
|
|
68
|
+
expect(message).toContain("has no interface in zone 'secure-mgmt'");
|
|
69
|
+
expect(message).not.toContain('MISLABELLED');
|
|
70
|
+
// Names what it DOES hold, so the operator can see why.
|
|
71
|
+
expect(message).toContain('10.0.10.5');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-classify a machine's STORED interface zone labels.
|
|
3
|
+
*
|
|
4
|
+
* Interface zones are decided once, at `machine add`, and never revisited
|
|
5
|
+
* (`machine-add.ts` / `machine-detector.ts` are the only writers). That is fine
|
|
6
|
+
* until the zone model itself changes — a new zone is added, or a subnet is
|
|
7
|
+
* declared for one that had none. Every machine added before that change keeps
|
|
8
|
+
* the label the old model produced, and nothing anywhere says so.
|
|
9
|
+
*
|
|
10
|
+
* Measured (celilo#1382): celilo-mgr was added before `secure-mgmt` existed,
|
|
11
|
+
* while the detector labelled unmatched subnets `external`. Its eth0
|
|
12
|
+
* (10.0.120.10) was stored as zone `external`. `resolveMachineIdentityAddress`
|
|
13
|
+
* matches on the stored label, so celilo-mgmt's upgrade failed with "no
|
|
14
|
+
* non-loopback interface in zone 'secure-mgmt'" — the interface existed, it was
|
|
15
|
+
* mislabelled. The fix was a hand-edit of the live DB, which is the shape of
|
|
16
|
+
* problem this module exists to remove.
|
|
17
|
+
*
|
|
18
|
+
* The diff is pure over an injected detector so it is testable without a DB.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { NetworkZone } from '../db/schema';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A stored interface row. Deliberately looser than `NetworkInterface`: the
|
|
25
|
+
* `machines.interfaces` column types `zone` as a plain string, and a stale
|
|
26
|
+
* label is exactly the case where it may not be a current `NetworkZone`.
|
|
27
|
+
*/
|
|
28
|
+
export interface StoredInterface {
|
|
29
|
+
name: string;
|
|
30
|
+
ipAddress: string;
|
|
31
|
+
zone: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One interface whose stored label disagrees with the current zone model. */
|
|
35
|
+
export interface ZoneReclassification {
|
|
36
|
+
name: string;
|
|
37
|
+
ipAddress: string;
|
|
38
|
+
/** The label on disk. */
|
|
39
|
+
from: string;
|
|
40
|
+
/** What the current zone model says. */
|
|
41
|
+
to: NetworkZone | 'unknown';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ZoneDetector = (ip: string) => Promise<NetworkZone | 'unknown'>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which of `interfaces` the current zone model labels differently.
|
|
48
|
+
*
|
|
49
|
+
* Returns only the CHANGES: an empty array means the stored labels already
|
|
50
|
+
* agree with the model, which is what an operator needs to hear.
|
|
51
|
+
*/
|
|
52
|
+
export async function diffInterfaceZones(
|
|
53
|
+
interfaces: readonly StoredInterface[],
|
|
54
|
+
detect: ZoneDetector,
|
|
55
|
+
): Promise<ZoneReclassification[]> {
|
|
56
|
+
const changes: ZoneReclassification[] = [];
|
|
57
|
+
for (const iface of interfaces) {
|
|
58
|
+
const to = await detect(iface.ipAddress);
|
|
59
|
+
if (to !== iface.zone) {
|
|
60
|
+
changes.push({ name: iface.name, ipAddress: iface.ipAddress, from: iface.zone, to });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return changes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `interfaces` with the changes applied. Does not mutate the input. */
|
|
67
|
+
export function applyReclassification(
|
|
68
|
+
interfaces: readonly StoredInterface[],
|
|
69
|
+
changes: readonly ZoneReclassification[],
|
|
70
|
+
): StoredInterface[] {
|
|
71
|
+
const byName = new Map(changes.map((c) => [c.name, c]));
|
|
72
|
+
return interfaces.map((iface) => {
|
|
73
|
+
const change = byName.get(iface.name);
|
|
74
|
+
return change ? { ...iface, zone: change.to } : { ...iface };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Why `resolveMachineIdentityAddress` found nothing for `zone`.
|
|
80
|
+
*
|
|
81
|
+
* Distinguishes the two cases that previously read identically. "No interface
|
|
82
|
+
* in zone X" is true of a machine that genuinely has none AND of one whose
|
|
83
|
+
* interface is sitting right there under a stale label — and the second is the
|
|
84
|
+
* common case after a zone-model change, where the remediation is a
|
|
85
|
+
* re-classification rather than new hardware.
|
|
86
|
+
*/
|
|
87
|
+
export function explainMissingZoneInterface(
|
|
88
|
+
interfaces: readonly StoredInterface[],
|
|
89
|
+
zone: NetworkZone,
|
|
90
|
+
changes: readonly ZoneReclassification[],
|
|
91
|
+
): string {
|
|
92
|
+
const wouldMatch = changes.filter((c) => c.to === zone);
|
|
93
|
+
if (wouldMatch.length === 0) {
|
|
94
|
+
const held = interfaces.map((i) => `${i.name} (${i.ipAddress}, zone ${i.zone})`).join(', ');
|
|
95
|
+
return `has no interface in zone '${zone}'${held ? ` — it holds ${held}` : ' (no interfaces recorded)'}`;
|
|
96
|
+
}
|
|
97
|
+
const named = wouldMatch
|
|
98
|
+
.map((c) => `${c.name} (${c.ipAddress}, stored as '${c.from}')`)
|
|
99
|
+
.join(', ');
|
|
100
|
+
return `has an interface in zone '${zone}' that is MISLABELLED: ${named}. The stored label predates the current zone model. Fix it with: celilo machine reclassify <hostname> --apply`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
6
|
+
import { staleProviderProjectRefusal } from './module-deploy';
|
|
7
|
+
import { providerConfigVarsPath } from './provider-converge';
|
|
8
|
+
|
|
9
|
+
// The converge copy of this guard has had a refusal test since celilo#1387
|
|
10
|
+
// (provider-converge.test.ts). The module-deploy copy had none, so nobody had
|
|
11
|
+
// watched it fail — and this guard has already failed in both directions:
|
|
12
|
+
// celilo#1248 refused every provider, celilo#1387 read its own output and
|
|
13
|
+
// refused nothing. Both silent. celilo#1408.
|
|
14
|
+
|
|
15
|
+
function manifest(caps: string[]): ModuleManifest {
|
|
16
|
+
return {
|
|
17
|
+
provides: { capabilities: caps.map((name) => ({ name })) },
|
|
18
|
+
} as unknown as ModuleManifest;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function withGenerated(run: (generatedPath: string) => Promise<void>) {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-stale-guard-'));
|
|
23
|
+
try {
|
|
24
|
+
await run(join(dir, 'generated'));
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function plantStaleTree(generatedPath: string) {
|
|
31
|
+
const roles = join(generatedPath, 'ansible', 'roles');
|
|
32
|
+
mkdirSync(roles, { recursive: true });
|
|
33
|
+
writeFileSync(join(roles, 'main.yml'), '- name: does not read the new vars\n', 'utf-8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('staleProviderProjectRefusal — the deploy-side guard, watched failing', () => {
|
|
37
|
+
it('refuses a public_web provider whose generated role predates the converge', async () => {
|
|
38
|
+
await withGenerated(async (generatedPath) => {
|
|
39
|
+
plantStaleTree(generatedPath);
|
|
40
|
+
|
|
41
|
+
const error = await staleProviderProjectRefusal(
|
|
42
|
+
'caddy',
|
|
43
|
+
manifest(['public_web']),
|
|
44
|
+
generatedPath,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(error).toContain('predates the provider converge');
|
|
48
|
+
expect(error).toContain('celilo module upgrade caddy');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does not reach its own output: nothing is written, so the scan cannot self-satisfy', async () => {
|
|
53
|
+
await withGenerated(async (generatedPath) => {
|
|
54
|
+
plantStaleTree(generatedPath);
|
|
55
|
+
|
|
56
|
+
await staleProviderProjectRefusal('caddy', manifest(['public_web']), generatedPath);
|
|
57
|
+
|
|
58
|
+
// celilo#1387's bug was write-order: the vars file lands inside the
|
|
59
|
+
// scanned tree and carries the needle, so a guard that runs after the
|
|
60
|
+
// write always passes. This guard writes nothing at all.
|
|
61
|
+
expect(existsSync(providerConfigVarsPath(generatedPath))).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes a tree whose role reads provider_config_files', async () => {
|
|
66
|
+
await withGenerated(async (generatedPath) => {
|
|
67
|
+
const roles = join(generatedPath, 'ansible', 'roles');
|
|
68
|
+
mkdirSync(roles, { recursive: true });
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(roles, 'main.yml'),
|
|
71
|
+
'- name: place\n loop: "{{ provider_config_files }}"\n',
|
|
72
|
+
'utf-8',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
await staleProviderProjectRefusal('caddy', manifest(['public_web']), generatedPath),
|
|
77
|
+
).toBe(undefined);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('leaves a module that provides no public_web alone, stale tree or not', async () => {
|
|
82
|
+
await withGenerated(async (generatedPath) => {
|
|
83
|
+
plantStaleTree(generatedPath);
|
|
84
|
+
|
|
85
|
+
expect(
|
|
86
|
+
await staleProviderProjectRefusal('homebridge', manifest(['dns_internal']), generatedPath),
|
|
87
|
+
).toBe(undefined);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('passes when there is no generated ansible tree to judge', async () => {
|
|
92
|
+
await withGenerated(async (generatedPath) => {
|
|
93
|
+
expect(
|
|
94
|
+
await staleProviderProjectRefusal('caddy', manifest(['public_web']), generatedPath),
|
|
95
|
+
).toBe(undefined);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -628,6 +628,34 @@ export async function repointDnsPrimaryForProvider(
|
|
|
628
628
|
);
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
/**
|
|
632
|
+
* The refusal above, as its own function so it can be watched failing.
|
|
633
|
+
*
|
|
634
|
+
* Returns the operator-facing error when `moduleId` is a public_web provider
|
|
635
|
+
* whose GENERATED project predates the converge role tasks, and undefined
|
|
636
|
+
* otherwise. Extracted for celilo#1408: the deploy path that calls it is long
|
|
637
|
+
* enough that no test had ever reached it, and this guard has already failed in
|
|
638
|
+
* both directions elsewhere (celilo#1248 refused everything, celilo#1387 read
|
|
639
|
+
* its own output and refused nothing). Both are silent. Rule 7.6.
|
|
640
|
+
*
|
|
641
|
+
* It only READS the tree — nothing is written before or by it, which is what
|
|
642
|
+
* keeps it from finding its own output the way #1387's copy did.
|
|
643
|
+
*/
|
|
644
|
+
export async function staleProviderProjectRefusal(
|
|
645
|
+
moduleId: string,
|
|
646
|
+
manifest: ModuleManifest,
|
|
647
|
+
generatedPath: string,
|
|
648
|
+
): Promise<string | undefined> {
|
|
649
|
+
if (!manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) return undefined;
|
|
650
|
+
|
|
651
|
+
const { ansibleTreeMentions } = await import('./provider-converge');
|
|
652
|
+
const ansiblePath = join(generatedPath, 'ansible');
|
|
653
|
+
if (!existsSync(ansiblePath)) return undefined;
|
|
654
|
+
if (ansibleTreeMentions(ansiblePath, 'provider_config_files')) return undefined;
|
|
655
|
+
|
|
656
|
+
return `The installed version of '${moduleId}' predates the provider converge: its role never reads provider_config_files, so celilo would deploy it and then have no way to place the config or the sites it renders. Upgrade it first (\`celilo module upgrade ${moduleId}\`), then deploy.`;
|
|
657
|
+
}
|
|
658
|
+
|
|
631
659
|
async function deployModuleImpl(
|
|
632
660
|
moduleId: string,
|
|
633
661
|
db: DbClient,
|
|
@@ -1474,19 +1502,26 @@ async function deployModuleImpl(
|
|
|
1474
1502
|
}
|
|
1475
1503
|
}
|
|
1476
1504
|
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
//
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1505
|
+
// A public_web provider's deploy no longer plans the fleet's static content
|
|
1506
|
+
// here. Core used to walk every declared route row, resolve every module's
|
|
1507
|
+
// built site, and throw for the whole deploy when any one of them was
|
|
1508
|
+
// missing — celilo#1383, where two out-of-tree modules made the public
|
|
1509
|
+
// ingress undeployable. The provider now renders its own desired state in
|
|
1510
|
+
// its hook and hands it to the converge, which runs the same role tasks
|
|
1511
|
+
// (providers-converge-declared-state, design D4/D6). on_install runs
|
|
1512
|
+
// immediately after this play, so /srv/www still converges on a rebuilt
|
|
1513
|
+
// host — through one path instead of two.
|
|
1514
|
+
//
|
|
1515
|
+
// A provider whose role predates that converge would deploy cleanly and
|
|
1516
|
+
// then never converge its static content again: core no longer writes the
|
|
1517
|
+
// release set, and the old role has no task that reads what the provider
|
|
1518
|
+
// rendered. Nothing would report it — the deploy succeeds, the publish
|
|
1519
|
+
// succeeds, and /srv/www silently stops tracking what is declared. So the
|
|
1520
|
+
// version skew is refused here, by the same tree scan the converge itself
|
|
1521
|
+
// uses, and the message names the upgrade that fixes it.
|
|
1522
|
+
const staleRefusal = await staleProviderProjectRefusal(moduleId, manifest, generatedPath);
|
|
1523
|
+
if (staleRefusal) {
|
|
1524
|
+
return { success: false, error: staleRefusal, phases };
|
|
1490
1525
|
}
|
|
1491
1526
|
|
|
1492
1527
|
try {
|