@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.
Files changed (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +1 -0
  2. package/package.json +5 -4
  3. package/src/cli/command-tree-parser.test.ts +18 -0
  4. package/src/cli/command-tree-parser.ts +12 -0
  5. package/src/cli/commands/api.ts +67 -14
  6. package/src/cli/commands/events.ts +5 -2
  7. package/src/cli/commands/machine-reclassify.ts +79 -0
  8. package/src/cli/commands/system-audit.ts +22 -0
  9. package/src/cli/commands/system-reboot.ts +126 -0
  10. package/src/cli/commands/system-update.ts +5 -0
  11. package/src/cli/completion.ts +3 -2
  12. package/src/cli/index.ts +19 -0
  13. package/src/cli/tui/audit-state.ts +4 -0
  14. package/src/hooks/broker.ts +31 -0
  15. package/src/hooks/capability-loader.ts +8 -2
  16. package/src/hooks/executor.ts +20 -2
  17. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  18. package/src/hooks/timeout-names-provider.test.ts +87 -0
  19. package/src/services/api-access.test.ts +44 -0
  20. package/src/services/audit/index.test.ts +7 -0
  21. package/src/services/audit/index.ts +15 -0
  22. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  23. package/src/services/audit/machine-interface-zones.ts +43 -0
  24. package/src/services/audit/reboot-pending.test.ts +63 -0
  25. package/src/services/audit/reboot-pending.ts +85 -0
  26. package/src/services/audit/recurrence-gate.test.ts +21 -0
  27. package/src/services/audit/server-bun-pin.test.ts +44 -0
  28. package/src/services/audit/server-bun-pin.ts +95 -0
  29. package/src/services/audit/types.ts +2 -0
  30. package/src/services/capability-compat.test.ts +22 -0
  31. package/src/services/capability-compat.ts +15 -2
  32. package/src/services/celilo-events.test.ts +39 -0
  33. package/src/services/celilo-events.ts +41 -4
  34. package/src/services/deploy-preflight.ts +12 -3
  35. package/src/services/deploy-validation.test.ts +81 -2
  36. package/src/services/deploy-validation.ts +19 -0
  37. package/src/services/fleet-checks.ts +40 -10
  38. package/src/services/machine-interface-zones.test.ts +73 -0
  39. package/src/services/machine-interface-zones.ts +101 -0
  40. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  41. package/src/services/module-deploy.ts +31 -10
  42. package/src/services/provider-arrival.test.ts +62 -46
  43. package/src/services/provider-arrival.ts +41 -10
  44. package/src/services/system-reboot.test.ts +231 -0
  45. package/src/services/system-reboot.ts +357 -0
  46. package/src/services/update/orchestrator.test.ts +164 -0
  47. package/src/services/update/orchestrator.ts +43 -12
  48. package/src/services/update/types.ts +10 -1
  49. package/src/templates/generator.test.ts +15 -4
  50. package/src/templates/generator.ts +12 -4
  51. 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
- if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
1495
- const { ansibleTreeMentions } = await import('./provider-converge');
1496
- const ansiblePath = join(generatedPath, 'ansible');
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
- 'iptables',
34
- ['firewall'],
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
- 'technitium',
53
- ['dns_internal'],
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
- 'iptables',
81
- ['firewall'],
82
- [
83
- consumer('imported', { requires: ['firewall'] }, 'IMPORTED'),
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
- 'iptables',
95
- ['firewall'],
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
- 'greenwave',
105
- ['firewall', 'dhcp_server'],
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', [], [consumer('caddy', { requires: ['firewall'] })]),
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
- 'iptables',
122
- ['firewall'],
123
- [consumer('zulu', { requires: ['firewall'] }), consumer('alpha', { requires: ['firewall'] })],
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<string>,
80
+ providedCapabilities: Iterable<ProvidedCapability>,
74
81
  candidates: ConsumerCandidate[],
75
82
  ): BackfillTarget[] {
76
- const provided = new Set(providedCapabilities);
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 Set([
84
- ...(candidate.manifest.requires?.capabilities ?? []).map((c) => c.name),
85
- ...(candidate.manifest.optional?.capabilities ?? []).map((c) => c.name),
86
- ]);
87
- const capabilityNames = [...consumed].filter((name) => provided.has(name)).sort();
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