@celilo/cli 4.0.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/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 +8 -2
- 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/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 +31 -10
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- 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
|
@@ -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,
|
|
@@ -1491,16 +1519,9 @@ async function deployModuleImpl(
|
|
|
1491
1519
|
// succeeds, and /srv/www silently stops tracking what is declared. So the
|
|
1492
1520
|
// version skew is refused here, by the same tree scan the converge itself
|
|
1493
1521
|
// uses, and the message names the upgrade that fixes it.
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
if (existsSync(ansiblePath) && !ansibleTreeMentions(ansiblePath, 'provider_config_files')) {
|
|
1498
|
-
return {
|
|
1499
|
-
success: false,
|
|
1500
|
-
error: `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.`,
|
|
1501
|
-
phases,
|
|
1502
|
-
};
|
|
1503
|
-
}
|
|
1522
|
+
const staleRefusal = await staleProviderProjectRefusal(moduleId, manifest, generatedPath);
|
|
1523
|
+
if (staleRefusal) {
|
|
1524
|
+
return { success: false, error: staleRefusal, phases };
|
|
1504
1525
|
}
|
|
1505
1526
|
|
|
1506
1527
|
try {
|
|
@@ -21,6 +21,9 @@ function manifest(input: { requires?: string[]; optional?: string[] }): ModuleMa
|
|
|
21
21
|
} as unknown as ModuleManifest;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** What the arriving provider registered. Consumers in these tests declare 1.0.0. */
|
|
25
|
+
const provides = (...names: string[]) => names.map((name) => ({ name, version: '1.0.0' }));
|
|
26
|
+
|
|
24
27
|
const consumer = (
|
|
25
28
|
moduleId: string,
|
|
26
29
|
caps: { requires?: string[]; optional?: string[] },
|
|
@@ -29,14 +32,10 @@ const consumer = (
|
|
|
29
32
|
|
|
30
33
|
describe('planProviderBackfill', () => {
|
|
31
34
|
it('re-runs consumers of the capabilities the arriving provider actually provides', () => {
|
|
32
|
-
const plan = planProviderBackfill(
|
|
33
|
-
'
|
|
34
|
-
['
|
|
35
|
-
|
|
36
|
-
consumer('caddy', { requires: ['firewall'] }),
|
|
37
|
-
consumer('forgejo', { requires: ['public_web'] }),
|
|
38
|
-
],
|
|
39
|
-
);
|
|
35
|
+
const plan = planProviderBackfill('iptables', provides('firewall'), [
|
|
36
|
+
consumer('caddy', { requires: ['firewall'] }),
|
|
37
|
+
consumer('forgejo', { requires: ['public_web'] }),
|
|
38
|
+
]);
|
|
40
39
|
|
|
41
40
|
expect(plan.map((t) => t.consumerId)).toEqual(['caddy']);
|
|
42
41
|
});
|
|
@@ -48,11 +47,9 @@ describe('planProviderBackfill', () => {
|
|
|
48
47
|
* provider appears. Counting only `requires` would leave it unregistered.
|
|
49
48
|
*/
|
|
50
49
|
it('includes an OPTIONAL dependent, not only a required one', () => {
|
|
51
|
-
const plan = planProviderBackfill(
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
[consumer('caddy-internal', { optional: ['dns_internal'] })],
|
|
55
|
-
);
|
|
50
|
+
const plan = planProviderBackfill('technitium', provides('dns_internal'), [
|
|
51
|
+
consumer('caddy-internal', { optional: ['dns_internal'] }),
|
|
52
|
+
]);
|
|
56
53
|
|
|
57
54
|
expect(plan.map((t) => t.consumerId)).toEqual(['caddy-internal']);
|
|
58
55
|
expect(plan[0]?.skip).toBeUndefined();
|
|
@@ -61,14 +58,10 @@ describe('planProviderBackfill', () => {
|
|
|
61
58
|
it('never treats the provider as its own consumer', () => {
|
|
62
59
|
// A module that both provides and requires a capability would otherwise
|
|
63
60
|
// have its on_install run a second time inside the deploy that just ran it.
|
|
64
|
-
const plan = planProviderBackfill(
|
|
65
|
-
'caddy',
|
|
66
|
-
['public_web'],
|
|
67
|
-
|
|
68
|
-
consumer('caddy', { requires: ['public_web', 'firewall'] }),
|
|
69
|
-
consumer('forgejo', { requires: ['public_web'] }),
|
|
70
|
-
],
|
|
71
|
-
);
|
|
61
|
+
const plan = planProviderBackfill('caddy', provides('public_web'), [
|
|
62
|
+
consumer('caddy', { requires: ['public_web', 'firewall'] }),
|
|
63
|
+
consumer('forgejo', { requires: ['public_web'] }),
|
|
64
|
+
]);
|
|
72
65
|
|
|
73
66
|
expect(plan.map((t) => t.consumerId)).toEqual(['forgejo']);
|
|
74
67
|
});
|
|
@@ -76,52 +69,75 @@ describe('planProviderBackfill', () => {
|
|
|
76
69
|
it('skips a consumer in a pre-deploy state, and says so rather than dropping it', () => {
|
|
77
70
|
// Capabilities are registered at IMPORT, not deploy, so an imported module
|
|
78
71
|
// has never resolved anything and has nothing to re-register.
|
|
79
|
-
const plan = planProviderBackfill(
|
|
80
|
-
'
|
|
81
|
-
['firewall'],
|
|
82
|
-
[
|
|
83
|
-
|
|
84
|
-
consumer('validated', { requires: ['firewall'] }, 'VALIDATED'),
|
|
85
|
-
consumer('configured', { requires: ['firewall'] }, 'CONFIGURED'),
|
|
86
|
-
],
|
|
87
|
-
);
|
|
72
|
+
const plan = planProviderBackfill('iptables', provides('firewall'), [
|
|
73
|
+
consumer('imported', { requires: ['firewall'] }, 'IMPORTED'),
|
|
74
|
+
consumer('validated', { requires: ['firewall'] }, 'VALIDATED'),
|
|
75
|
+
consumer('configured', { requires: ['firewall'] }, 'CONFIGURED'),
|
|
76
|
+
]);
|
|
88
77
|
|
|
89
78
|
expect(plan.map((t) => t.skip)).toEqual(['not-deployed', 'not-deployed', 'not-deployed']);
|
|
90
79
|
});
|
|
91
80
|
|
|
92
81
|
it('skips a PAUSED consumer separately from a never-deployed one', () => {
|
|
93
|
-
const plan = planProviderBackfill(
|
|
94
|
-
'
|
|
95
|
-
|
|
96
|
-
[consumer('paused', { requires: ['firewall'] }, 'PAUSED')],
|
|
97
|
-
);
|
|
82
|
+
const plan = planProviderBackfill('iptables', provides('firewall'), [
|
|
83
|
+
consumer('paused', { requires: ['firewall'] }, 'PAUSED'),
|
|
84
|
+
]);
|
|
98
85
|
|
|
99
86
|
expect(plan[0]?.skip).toBe('paused');
|
|
100
87
|
});
|
|
101
88
|
|
|
102
89
|
it('names every capability the consumer takes from this provider, once', () => {
|
|
103
|
-
const plan = planProviderBackfill(
|
|
104
|
-
'
|
|
105
|
-
|
|
106
|
-
[consumer('caddy', { requires: ['firewall'], optional: ['dhcp_server'] })],
|
|
107
|
-
);
|
|
90
|
+
const plan = planProviderBackfill('greenwave', provides('firewall', 'dhcp_server'), [
|
|
91
|
+
consumer('caddy', { requires: ['firewall'], optional: ['dhcp_server'] }),
|
|
92
|
+
]);
|
|
108
93
|
|
|
109
94
|
expect(plan).toHaveLength(1);
|
|
110
95
|
expect(plan[0]?.capabilityNames).toEqual(['dhcp_server', 'firewall']);
|
|
111
96
|
});
|
|
112
97
|
|
|
98
|
+
/**
|
|
99
|
+
* celilo#1384. An OPTIONAL consumer built against external_web@1.0.0 was
|
|
100
|
+
* re-run against a provider serving 2.0.0; the hook failed inside
|
|
101
|
+
* `on_install`, which is only a warning, so nothing published and no surface
|
|
102
|
+
* said so. A consumer on another major is skipped and REPORTED instead.
|
|
103
|
+
*/
|
|
104
|
+
it('skips a consumer whose declared version the arriving provider no longer serves', () => {
|
|
105
|
+
const plan = planProviderBackfill(
|
|
106
|
+
'generic-cpanel',
|
|
107
|
+
[{ name: 'external_web', version: '2.0.0' }],
|
|
108
|
+
[
|
|
109
|
+
consumer('tango-nexus', { optional: ['external_web'] }),
|
|
110
|
+
consumer('required-too', { requires: ['external_web'] }),
|
|
111
|
+
],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(plan.map((t) => [t.consumerId, t.skip])).toEqual([
|
|
115
|
+
['required-too', 'incompatible'],
|
|
116
|
+
['tango-nexus', 'incompatible'],
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('re-runs a consumer the provider is merely NEWER in minor than', () => {
|
|
121
|
+
const plan = planProviderBackfill(
|
|
122
|
+
'generic-cpanel',
|
|
123
|
+
[{ name: 'external_web', version: '1.4.0' }],
|
|
124
|
+
[consumer('tango-nexus', { optional: ['external_web'] })],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(plan[0]?.skip).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
113
130
|
it('is empty for a module that provides nothing', () => {
|
|
114
131
|
expect(
|
|
115
|
-
planProviderBackfill('forgejo',
|
|
132
|
+
planProviderBackfill('forgejo', provides(), [consumer('caddy', { requires: ['firewall'] })]),
|
|
116
133
|
).toEqual([]);
|
|
117
134
|
});
|
|
118
135
|
|
|
119
136
|
it('is ordered by consumer id, so a failure is reproducible', () => {
|
|
120
|
-
const plan = planProviderBackfill(
|
|
121
|
-
'
|
|
122
|
-
['firewall'],
|
|
123
|
-
|
|
124
|
-
);
|
|
137
|
+
const plan = planProviderBackfill('iptables', provides('firewall'), [
|
|
138
|
+
consumer('zulu', { requires: ['firewall'] }),
|
|
139
|
+
consumer('alpha', { requires: ['firewall'] }),
|
|
140
|
+
]);
|
|
125
141
|
|
|
126
142
|
expect(plan.map((t) => t.consumerId)).toEqual(['alpha', 'zulu']);
|
|
127
143
|
});
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* happened".
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { compareConsumerToProvider } from '@celilo/capabilities';
|
|
30
31
|
import { eq } from 'drizzle-orm';
|
|
31
32
|
import type { DbClient } from '../db/client';
|
|
32
33
|
import { capabilities, modules } from '../db/schema';
|
|
@@ -36,7 +37,13 @@ import type { HookLogger } from '../hooks/types';
|
|
|
36
37
|
import { type ModuleManifest, ModuleManifestSchema } from '../manifest/schema';
|
|
37
38
|
import { PRE_DEPLOY_STATES } from './consumer-cleanup';
|
|
38
39
|
|
|
39
|
-
export type BackfillSkipReason = 'paused' | 'not-deployed';
|
|
40
|
+
export type BackfillSkipReason = 'paused' | 'not-deployed' | 'incompatible';
|
|
41
|
+
|
|
42
|
+
/** A capability as the arriving provider registered it. */
|
|
43
|
+
export interface ProvidedCapability {
|
|
44
|
+
name: string;
|
|
45
|
+
version: string;
|
|
46
|
+
}
|
|
40
47
|
|
|
41
48
|
export interface BackfillTarget {
|
|
42
49
|
/** The consumer module to re-run. */
|
|
@@ -70,23 +77,42 @@ export interface ConsumerCandidate {
|
|
|
70
77
|
*/
|
|
71
78
|
export function planProviderBackfill(
|
|
72
79
|
provider: string,
|
|
73
|
-
providedCapabilities: Iterable<
|
|
80
|
+
providedCapabilities: Iterable<ProvidedCapability>,
|
|
74
81
|
candidates: ConsumerCandidate[],
|
|
75
82
|
): BackfillTarget[] {
|
|
76
|
-
const provided = new
|
|
83
|
+
const provided = new Map([...providedCapabilities].map((c) => [c.name, c.version]));
|
|
77
84
|
if (provided.size === 0) return [];
|
|
78
85
|
|
|
79
86
|
const targets: BackfillTarget[] = [];
|
|
80
87
|
for (const candidate of candidates) {
|
|
81
88
|
if (candidate.moduleId === provider) continue;
|
|
82
89
|
|
|
83
|
-
const consumed = new
|
|
84
|
-
|
|
85
|
-
...(candidate.manifest.optional?.capabilities ?? [])
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const consumed = new Map<string, string>();
|
|
91
|
+
for (const c of [
|
|
92
|
+
...(candidate.manifest.optional?.capabilities ?? []),
|
|
93
|
+
...(candidate.manifest.requires?.capabilities ?? []),
|
|
94
|
+
]) {
|
|
95
|
+
consumed.set(c.name, c.version);
|
|
96
|
+
}
|
|
97
|
+
const capabilityNames = [...consumed.keys()].filter((name) => provided.has(name)).sort();
|
|
88
98
|
if (capabilityNames.length === 0) continue;
|
|
89
99
|
|
|
100
|
+
// The consumer was built against a contract the arriving provider no
|
|
101
|
+
// longer speaks. Re-running its `on_install` would call methods that are
|
|
102
|
+
// gone (celilo#1384: tango-nexus 0.8.0's optional external_web@1.0.0 was
|
|
103
|
+
// re-run against generic-cpanel's 2.0.0 and failed inside the hook, which
|
|
104
|
+
// is only a warning — so nothing published and nothing said so).
|
|
105
|
+
if (
|
|
106
|
+
capabilityNames.some(
|
|
107
|
+
(name) =>
|
|
108
|
+
// biome-ignore lint/style/noNonNullAssertion: name came from `consumed`/`provided`.
|
|
109
|
+
!compareConsumerToProvider(consumed.get(name)!, provided.get(name)!).compatible,
|
|
110
|
+
)
|
|
111
|
+
) {
|
|
112
|
+
targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'incompatible' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
if (candidate.state === 'PAUSED') {
|
|
91
117
|
targets.push({ consumerId: candidate.moduleId, capabilityNames, skip: 'paused' });
|
|
92
118
|
continue;
|
|
@@ -111,11 +137,11 @@ export function planProviderBackfill(
|
|
|
111
137
|
*/
|
|
112
138
|
export function loadProviderBackfillPlan(provider: string, db: DbClient): BackfillTarget[] {
|
|
113
139
|
const provided = db
|
|
114
|
-
.select({ capabilityName: capabilities.capabilityName })
|
|
140
|
+
.select({ capabilityName: capabilities.capabilityName, version: capabilities.version })
|
|
115
141
|
.from(capabilities)
|
|
116
142
|
.where(eq(capabilities.moduleId, provider))
|
|
117
143
|
.all()
|
|
118
|
-
.map((row) => row.capabilityName);
|
|
144
|
+
.map((row) => ({ name: row.capabilityName, version: row.version }));
|
|
119
145
|
if (provided.length === 0) return [];
|
|
120
146
|
|
|
121
147
|
const candidates: ConsumerCandidate[] = [];
|
|
@@ -174,6 +200,11 @@ export async function runProviderBackfill(
|
|
|
174
200
|
`${target.consumerId} is paused, so it was not re-run against the new '${provider}' — it consumes ${target.capabilityNames.join(', ')} and will rebind on its next deploy.`,
|
|
175
201
|
);
|
|
176
202
|
}
|
|
203
|
+
if (target.skip === 'incompatible') {
|
|
204
|
+
logger.warn(
|
|
205
|
+
`${target.consumerId} was NOT re-run against the new '${provider}': it declares ${target.capabilityNames.join(', ')} on a version '${provider}' no longer serves. Rebuild ${target.consumerId} against the provider's current major, then redeploy it.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
177
208
|
continue;
|
|
178
209
|
}
|
|
179
210
|
|