@celilo/cli 0.26.0 → 0.27.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 (41) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/package.json +3 -3
  4. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  5. package/src/ansible/dependencies.test.ts +233 -289
  6. package/src/ansible/dependencies.ts +151 -83
  7. package/src/cli/commands/alerts-sweep.ts +14 -3
  8. package/src/cli/commands/machine-add.ts +0 -1
  9. package/src/cli/commands/machine-list.ts +10 -4
  10. package/src/cli/commands/machine-remove.ts +13 -7
  11. package/src/cli/commands/machine-status.ts +9 -11
  12. package/src/db/schema.ts +6 -4
  13. package/src/hooks/capability-loader.ts +6 -0
  14. package/src/hooks/define-hook.test.ts +4 -0
  15. package/src/hooks/types.ts +2 -1
  16. package/src/infrastructure/property-extractor.test.ts +0 -2
  17. package/src/manifest/contracts/v1.ts +19 -0
  18. package/src/manifest/schema.ts +1 -0
  19. package/src/services/alerting/inbound.test.ts +66 -0
  20. package/src/services/alerting/inbound.ts +35 -2
  21. package/src/services/alerting/sweep-runner.test.ts +5 -1
  22. package/src/services/alerting/sweep-runner.ts +14 -8
  23. package/src/services/aspect-runner.test.ts +0 -1
  24. package/src/services/audit/machines-reachable.test.ts +67 -8
  25. package/src/services/audit/machines-reachable.ts +18 -4
  26. package/src/services/deployed-systems.ts +31 -0
  27. package/src/services/fleet-checks.test.ts +232 -0
  28. package/src/services/fleet-checks.ts +275 -3
  29. package/src/services/infrastructure-selector.test.ts +0 -7
  30. package/src/services/infrastructure-selector.ts +24 -25
  31. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  32. package/src/services/infrastructure-variable-resolver.ts +0 -3
  33. package/src/services/machine-pool.test.ts +53 -85
  34. package/src/services/machine-pool.ts +68 -84
  35. package/src/services/machine-probe.test.ts +3 -4
  36. package/src/services/machine-probe.ts +2 -3
  37. package/src/services/module-deploy.ts +17 -39
  38. package/src/services/module-operations.test.ts +72 -1
  39. package/src/services/module-operations.ts +49 -10
  40. package/src/services/ssh-key-manager.test.ts +0 -10
  41. package/src/types/infrastructure.ts +11 -1
@@ -27,7 +27,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
27
27
  - **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
28
28
  - **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
29
29
  - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
30
- - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
30
+ - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
31
31
 
32
32
  ## Public edge (ingress / identity)
33
33
 
@@ -60,6 +60,8 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
60
60
  | `registry_publish` | `packages/capabilities/src/registry-publish.ts` | celilo-registry |
61
61
  | `notification` | `packages/capabilities/src/notification.ts` (`send`, optional `receive`) | signal (planned) — transports are ordinary modules, both self-hosted and credential-only |
62
62
  | `cross_module_read` | `packages/capabilities/src/cross-module-read.ts` | framework (read other modules' capability data) |
63
+ | `control_plane_vpn` | `packages/capabilities/src/control-plane-vpn.ts` (`registerClient`, `revokeClient`, `listClients`, `getEndpoint`). **Its state is NOT in a celilo table** — registrations live in the PROVIDER's own module config under `registered_peers`, kept separate from the operator's `peers` so a machine can never rewrite what an operator typed, and so `revokeClient` structurally cannot reach a declared peer. Do not go looking for a `vpn_clients` table; there isn't one and that is deliberate. Addresses are allocated by the CONSUMER from `client_pool` — celilo's IPAM deliberately does not cover VPN clients. ⚠️ Every client registered here reaches every managed zone: the tunnel's client subnet is a registered trusted source. | wireguard |
64
+ | `private_web` | `packages/capabilities/src/private-web.ts` (`publishStaticSite`, `registerReverseProxy`, `unregisterRoutes`, `getCaCertificate`). **Fleet-only HTTP ingress** — internal DNS, an internally-issued certificate, and no public exposure. A SIBLING of `public_web` rather than a flag on it, because `public_web` treats an unreachable route as a deploy failure and publishes a public record to prevent one. Reuses `public_web`'s request types so a consumer can write `private_web ?? public_web` and call through the union without branching. | caddy-internal (planned) |
63
65
 
64
66
  ### In-fleet (`public_web`) vs off-fleet (`external_web`)
65
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^1.3.0",
61
+ "@celilo/capabilities": "^1.5.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.8.0",
63
+ "@celilo/core": "^0.8.1",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -151,7 +151,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
151
151
  sshUser: 'ubuntu',
152
152
  sshKeyEncrypted: JSON.stringify(sshKey),
153
153
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
154
- assignedModuleIds: [],
155
154
  createdAt: new Date(),
156
155
  updatedAt: new Date(),
157
156
  });
@@ -187,7 +186,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
187
186
  sshUser: 'root',
188
187
  sshKeyEncrypted: JSON.stringify(sshKey),
189
188
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
190
- assignedModuleIds: [],
191
189
  createdAt: new Date(),
192
190
  updatedAt: new Date(),
193
191
  },
@@ -199,7 +197,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
199
197
  sshUser: 'ubuntu',
200
198
  sshKeyEncrypted: JSON.stringify(sshKey),
201
199
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
202
- assignedModuleIds: [],
203
200
  createdAt: new Date(),
204
201
  updatedAt: new Date(),
205
202
  },
@@ -232,7 +229,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
232
229
  sshUser: 'root',
233
230
  sshKeyEncrypted: JSON.stringify(sshKey),
234
231
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
235
- assignedModuleIds: [],
236
232
  createdAt: new Date(),
237
233
  updatedAt: new Date(),
238
234
  });
@@ -1,309 +1,253 @@
1
- import { describe, expect, it } from 'bun:test';
1
+ /**
2
+ * celilo#524. `verifyCollectionIntegrity` is a supply-chain check that could
3
+ * not report a failure: three independent paths returned "integrity verified"
4
+ * for a tampered collection, and no constructible input reached the failure
5
+ * return. The file had no tests at all.
6
+ *
7
+ * These build real broken collections on disk — the same three the issue's
8
+ * break-and-watch constructed — rather than mocking the filesystem. A mock
9
+ * would assert my belief about what `readFile` does; a real directory asserts
10
+ * what the function does.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { createHash } from 'node:crypto';
15
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
2
18
  import {
3
- type CollectionRequirement,
4
- type VersionConstraint,
5
- compareVersions,
6
- formatConstraints,
7
- intersectConstraints,
8
- mergeAnsibleRequirements,
9
- parseSemanticVersion,
10
- parseVersionConstraint,
11
- satisfiesConstraint,
12
- satisfiesConstraints,
19
+ type InstalledCollection,
20
+ reportIntegrity,
21
+ verifyCollectionIntegrity,
13
22
  } from './dependencies';
14
23
 
15
- describe('parseSemanticVersion', () => {
16
- it('parses valid semantic version', () => {
17
- const version = parseSemanticVersion('8.5.2');
18
- expect(version).toEqual({ major: 8, minor: 5, patch: 2 });
19
- });
24
+ const NAME = 'community.general';
25
+ const [NAMESPACE, COLLECTION] = NAME.split('.');
20
26
 
21
- it('throws on invalid version', () => {
22
- expect(() => parseSemanticVersion('8.5')).toThrow('Invalid semantic version');
23
- expect(() => parseSemanticVersion('8')).toThrow('Invalid semantic version');
24
- expect(() => parseSemanticVersion('v8.5.2')).toThrow('Invalid semantic version');
25
- });
26
- });
27
+ const sha256 = (content: string) => createHash('sha256').update(content).digest('hex');
27
28
 
28
- describe('compareVersions', () => {
29
- it('compares major versions', () => {
30
- const v8 = { major: 8, minor: 0, patch: 0 };
31
- const v9 = { major: 9, minor: 0, patch: 0 };
32
- expect(compareVersions(v8, v9)).toBeLessThan(0);
33
- expect(compareVersions(v9, v8)).toBeGreaterThan(0);
34
- expect(compareVersions(v8, v8)).toBe(0);
35
- });
29
+ describe('verifyCollectionIntegrity', () => {
30
+ let root: string;
31
+ let dir: string;
32
+ let info: InstalledCollection;
36
33
 
37
- it('compares minor versions when major equal', () => {
38
- const v8_0 = { major: 8, minor: 0, patch: 0 };
39
- const v8_5 = { major: 8, minor: 5, patch: 0 };
40
- expect(compareVersions(v8_0, v8_5)).toBeLessThan(0);
41
- expect(compareVersions(v8_5, v8_0)).toBeGreaterThan(0);
34
+ beforeEach(() => {
35
+ root = mkdtempSync(join(tmpdir(), 'celilo-galaxy-'));
36
+ dir = join(root, NAMESPACE, COLLECTION);
37
+ mkdirSync(dir, { recursive: true });
38
+ info = { name: NAME, version: { major: 1, minor: 0, patch: 0 }, path: root };
42
39
  });
43
40
 
44
- it('compares patch versions when major and minor equal', () => {
45
- const v8_5_0 = { major: 8, minor: 5, patch: 0 };
46
- const v8_5_2 = { major: 8, minor: 5, patch: 2 };
47
- expect(compareVersions(v8_5_0, v8_5_2)).toBeLessThan(0);
48
- expect(compareVersions(v8_5_2, v8_5_0)).toBeGreaterThan(0);
41
+ afterEach(() => {
42
+ try {
43
+ rmSync(root, { recursive: true, force: true });
44
+ } catch {
45
+ /* ignore */
46
+ }
49
47
  });
50
- });
51
48
 
52
- describe('parseVersionConstraint', () => {
53
- it('parses >= constraint', () => {
54
- const constraints = parseVersionConstraint('>=8.0.0');
55
- expect(constraints).toEqual([
56
- {
57
- operator: '>=',
58
- version: { major: 8, minor: 0, patch: 0 },
59
- },
60
- ]);
61
- });
62
-
63
- it('parses == constraint', () => {
64
- const constraints = parseVersionConstraint('==8.5.0');
65
- expect(constraints).toEqual([
66
- {
67
- operator: '==',
68
- version: { major: 8, minor: 5, patch: 0 },
69
- },
70
- ]);
71
- });
49
+ /** Write a coherent, genuinely intact collection. */
50
+ function writeGoodCollection(): void {
51
+ const payload = '- name: do a thing\n';
52
+ writeFileSync(join(dir, 'plugins.yml'), payload);
72
53
 
73
- it('parses range constraint', () => {
74
- const constraints = parseVersionConstraint('>=8.0.0,<10.0.0');
75
- expect(constraints).toHaveLength(2);
76
- expect(constraints[0]).toEqual({
77
- operator: '>=',
78
- version: { major: 8, minor: 0, patch: 0 },
79
- });
80
- expect(constraints[1]).toEqual({
81
- operator: '<',
82
- version: { major: 10, minor: 0, patch: 0 },
54
+ const filesJson = JSON.stringify({
55
+ files: [{ name: 'plugins.yml', ftype: 'file', chksum_sha256: sha256(payload) }],
83
56
  });
84
- });
85
-
86
- it('throws on invalid constraint', () => {
87
- expect(() => parseVersionConstraint('invalid')).toThrow('Invalid version constraint');
88
- expect(() => parseVersionConstraint('>8')).toThrow('Invalid version constraint');
89
- });
90
- });
91
-
92
- describe('satisfiesConstraint', () => {
93
- it('checks >= constraint', () => {
94
- const constraint: VersionConstraint = {
95
- operator: '>=',
96
- version: { major: 8, minor: 0, patch: 0 },
97
- };
98
-
99
- expect(satisfiesConstraint({ major: 9, minor: 0, patch: 0 }, constraint)).toBe(true);
100
- expect(satisfiesConstraint({ major: 8, minor: 0, patch: 0 }, constraint)).toBe(true);
101
- expect(satisfiesConstraint({ major: 7, minor: 9, patch: 9 }, constraint)).toBe(false);
102
- });
103
-
104
- it('checks == constraint', () => {
105
- const constraint: VersionConstraint = {
106
- operator: '==',
107
- version: { major: 8, minor: 5, patch: 0 },
108
- };
109
-
110
- expect(satisfiesConstraint({ major: 8, minor: 5, patch: 0 }, constraint)).toBe(true);
111
- expect(satisfiesConstraint({ major: 8, minor: 5, patch: 1 }, constraint)).toBe(false);
112
- expect(satisfiesConstraint({ major: 9, minor: 0, patch: 0 }, constraint)).toBe(false);
113
- });
114
-
115
- it('checks < constraint', () => {
116
- const constraint: VersionConstraint = {
117
- operator: '<',
118
- version: { major: 10, minor: 0, patch: 0 },
119
- };
120
-
121
- expect(satisfiesConstraint({ major: 9, minor: 9, patch: 9 }, constraint)).toBe(true);
122
- expect(satisfiesConstraint({ major: 10, minor: 0, patch: 0 }, constraint)).toBe(false);
123
- expect(satisfiesConstraint({ major: 11, minor: 0, patch: 0 }, constraint)).toBe(false);
124
- });
125
- });
126
-
127
- describe('satisfiesConstraints', () => {
128
- it('checks multiple constraints', () => {
129
- const constraints = parseVersionConstraint('>=8.0.0,<10.0.0');
130
-
131
- expect(satisfiesConstraints({ major: 8, minor: 0, patch: 0 }, constraints)).toBe(true);
132
- expect(satisfiesConstraints({ major: 9, minor: 5, patch: 2 }, constraints)).toBe(true);
133
- expect(satisfiesConstraints({ major: 7, minor: 9, patch: 9 }, constraints)).toBe(false);
134
- expect(satisfiesConstraints({ major: 10, minor: 0, patch: 0 }, constraints)).toBe(false);
135
- });
136
- });
137
-
138
- describe('intersectConstraints', () => {
139
- it('returns empty array for empty input', () => {
140
- expect(intersectConstraints([])).toEqual([]);
141
- });
142
-
143
- it('returns single constraint set unchanged', () => {
144
- const constraints = parseVersionConstraint('>=8.0.0');
145
- expect(intersectConstraints([constraints])).toEqual(constraints);
146
- });
147
-
148
- it('merges compatible minimums (takes highest)', () => {
149
- const c1 = parseVersionConstraint('>=8.0.0');
150
- const c2 = parseVersionConstraint('>=9.0.0');
151
-
152
- const result = intersectConstraints([c1, c2]);
153
- expect(result).toEqual([{ operator: '>=', version: { major: 9, minor: 0, patch: 0 } }]);
154
- });
155
-
156
- it('finds range overlap', () => {
157
- const c1 = parseVersionConstraint('>=8.0.0,<10.0.0');
158
- const c2 = parseVersionConstraint('>=9.0.0,<11.0.0');
159
-
160
- const result = intersectConstraints([c1, c2]);
161
- expect(result).toHaveLength(2);
162
- expect(result?.[0]).toEqual({ operator: '>=', version: { major: 9, minor: 0, patch: 0 } });
163
- expect(result?.[1]).toEqual({ operator: '<', version: { major: 10, minor: 0, patch: 0 } });
164
- });
165
-
166
- it('detects conflicting ranges', () => {
167
- const c1 = parseVersionConstraint('>=8.0.0,<9.0.0');
168
- const c2 = parseVersionConstraint('>=9.0.0,<10.0.0');
169
-
170
- const result = intersectConstraints([c1, c2]);
171
- expect(result).toBeNull(); // No overlap
172
- });
173
-
174
- it('handles exact pins matching', () => {
175
- const c1 = parseVersionConstraint('==8.5.0');
176
- const c2 = parseVersionConstraint('==8.5.0');
177
-
178
- const result = intersectConstraints([c1, c2]);
179
- expect(result).toEqual([{ operator: '==', version: { major: 8, minor: 5, patch: 0 } }]);
180
- });
181
-
182
- it('detects conflicting exact pins', () => {
183
- const c1 = parseVersionConstraint('==8.5.0');
184
- const c2 = parseVersionConstraint('==9.0.0');
185
-
186
- const result = intersectConstraints([c1, c2]);
187
- expect(result).toBeNull();
188
- });
189
-
190
- it('checks exact pin against bounds', () => {
191
- const c1 = parseVersionConstraint('==8.5.0');
192
- const c2 = parseVersionConstraint('>=8.0.0,<9.0.0');
193
-
194
- const result = intersectConstraints([c1, c2]);
195
- expect(result).toEqual([{ operator: '==', version: { major: 8, minor: 5, patch: 0 } }]);
196
- });
197
-
198
- it('detects exact pin outside bounds', () => {
199
- const c1 = parseVersionConstraint('==10.0.0');
200
- const c2 = parseVersionConstraint('>=8.0.0,<9.0.0');
201
-
202
- const result = intersectConstraints([c1, c2]);
203
- expect(result).toBeNull();
204
- });
205
- });
206
-
207
- describe('mergeAnsibleRequirements', () => {
208
- it('merges compatible requirements', () => {
209
- const requirements: CollectionRequirement[] = [
210
- {
211
- name: 'community.general',
212
- constraints: parseVersionConstraint('>=8.0.0'),
213
- moduleId: 'module-a',
214
- },
215
- {
216
- name: 'community.general',
217
- constraints: parseVersionConstraint('>=9.0.0'),
218
- moduleId: 'module-b',
219
- },
57
+ writeFileSync(join(dir, 'FILES.json'), filesJson);
58
+ writeFileSync(
59
+ join(dir, 'MANIFEST.json'),
60
+ JSON.stringify({
61
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
62
+ file_manifest_file: {
63
+ name: 'FILES.json',
64
+ ftype: 'file',
65
+ chksum_type: 'sha256',
66
+ chksum_sha256: sha256(filesJson),
67
+ },
68
+ }),
69
+ );
70
+ }
71
+
72
+ test('an intact collection verifies, and says how many files it checked', async () => {
73
+ writeGoodCollection();
74
+ const outcome = await verifyCollectionIntegrity(info);
75
+ expect(outcome.status).toBe('verified');
76
+ expect(outcome.status === 'verified' && outcome.filesChecked).toBeGreaterThan(0);
77
+ });
78
+
79
+ // ── The three cases from the issue. Each returned `true` before. ──────────
80
+
81
+ test('A: an unparseable MANIFEST.json is unverifiable, not verified', async () => {
82
+ writeGoodCollection();
83
+ writeFileSync(join(dir, 'MANIFEST.json'), '{ this is not json');
84
+
85
+ const outcome = await verifyCollectionIntegrity(info);
86
+ expect(outcome.status).toBe('unverifiable');
87
+ // Rule 6.2: the exception path used to claim it logged and did not.
88
+ expect(outcome.status === 'unverifiable' && outcome.reason.length).toBeGreaterThan(0);
89
+ });
90
+
91
+ test('B: a MANIFEST.json with no FILES.json checksum is unverifiable', async () => {
92
+ writeGoodCollection();
93
+ writeFileSync(
94
+ join(dir, 'MANIFEST.json'),
95
+ JSON.stringify({
96
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
97
+ // `file_manifest_file` stripped — the one field an attacker shipping a
98
+ // tampered collection fully controls. It used to switch the check off.
99
+ }),
100
+ );
101
+
102
+ const outcome = await verifyCollectionIntegrity(info);
103
+ expect(outcome.status).toBe('unverifiable');
104
+ expect(outcome.status === 'unverifiable' && outcome.reason).toContain('no FILES.json checksum');
105
+ });
106
+
107
+ test('C: every listed file missing from disk is unverifiable, not a pass', async () => {
108
+ writeGoodCollection();
109
+ rmSync(join(dir, 'plugins.yml'));
110
+
111
+ const outcome = await verifyCollectionIntegrity(info);
112
+ expect(outcome.status).toBe('unverifiable');
113
+ // Verifying zero files is not verifying.
114
+ expect(outcome.status === 'unverifiable' && outcome.reason).toContain('present on disk');
115
+ });
116
+
117
+ // ── Refutation is distinct from ignorance ────────────────────────────────
118
+
119
+ test('a modified file is a MISMATCH, not merely unverifiable', async () => {
120
+ writeGoodCollection();
121
+ writeFileSync(join(dir, 'plugins.yml'), '- name: do something ELSE\n');
122
+
123
+ const outcome = await verifyCollectionIntegrity(info);
124
+ expect(outcome.status).toBe('mismatch');
125
+ expect(outcome.status === 'mismatch' && outcome.detail).toContain('plugins.yml');
126
+ });
127
+
128
+ test('a tampered FILES.json is a MISMATCH', async () => {
129
+ writeGoodCollection();
130
+ // Rewrite FILES.json so it no longer matches the checksum in MANIFEST.json —
131
+ // what re-pointing a file at different content looks like.
132
+ writeFileSync(
133
+ join(dir, 'FILES.json'),
134
+ JSON.stringify({
135
+ files: [{ name: 'plugins.yml', ftype: 'file', chksum_sha256: sha256('something else') }],
136
+ }),
137
+ );
138
+
139
+ const outcome = await verifyCollectionIntegrity(info);
140
+ expect(outcome.status).toBe('mismatch');
141
+ expect(outcome.status === 'mismatch' && outcome.detail).toContain('FILES.json');
142
+ });
143
+
144
+ test('a missing FILES.json is unverifiable, and is not confused with a mismatch', async () => {
145
+ writeGoodCollection();
146
+ rmSync(join(dir, 'FILES.json'));
147
+
148
+ const outcome = await verifyCollectionIntegrity(info);
149
+ expect(outcome.status).toBe('unverifiable');
150
+ });
151
+
152
+ test('a collection with no checksummed files is unverifiable', async () => {
153
+ writeGoodCollection();
154
+ const filesJson = JSON.stringify({ files: [{ name: 'roles', ftype: 'dir' }] });
155
+ writeFileSync(join(dir, 'FILES.json'), filesJson);
156
+ writeFileSync(
157
+ join(dir, 'MANIFEST.json'),
158
+ JSON.stringify({
159
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
160
+ file_manifest_file: {
161
+ name: 'FILES.json',
162
+ ftype: 'file',
163
+ chksum_type: 'sha256',
164
+ chksum_sha256: sha256(filesJson),
165
+ },
166
+ }),
167
+ );
168
+
169
+ const outcome = await verifyCollectionIntegrity(info);
170
+ expect(outcome.status).toBe('unverifiable');
171
+ });
172
+
173
+ /**
174
+ * The recurrence gate proper. Every way this function can fail to establish
175
+ * integrity must be distinguishable from success — a single `status` compared
176
+ * against 'verified' is what a caller will actually write, and it must be
177
+ * false for all of them.
178
+ */
179
+ test('no broken collection is EVER reported as verified', async () => {
180
+ const breakages: Array<[string, () => void]> = [
181
+ ['unparseable manifest', () => writeFileSync(join(dir, 'MANIFEST.json'), 'nope')],
182
+ [
183
+ 'no files checksum',
184
+ () =>
185
+ writeFileSync(
186
+ join(dir, 'MANIFEST.json'),
187
+ JSON.stringify({
188
+ collection_info: { namespace: NAMESPACE, name: COLLECTION, version: '1.0.0' },
189
+ }),
190
+ ),
191
+ ],
192
+ ['files missing', () => rmSync(join(dir, 'plugins.yml'))],
193
+ ['manifest missing', () => rmSync(join(dir, 'MANIFEST.json'))],
194
+ ['files.json missing', () => rmSync(join(dir, 'FILES.json'))],
195
+ ['content modified', () => writeFileSync(join(dir, 'plugins.yml'), 'tampered\n')],
220
196
  ];
221
197
 
222
- const result = mergeAnsibleRequirements(requirements);
198
+ for (const [label, breakIt] of breakages) {
199
+ rmSync(dir, { recursive: true, force: true });
200
+ mkdirSync(dir, { recursive: true });
201
+ writeGoodCollection();
202
+ breakIt();
223
203
 
224
- expect(result.success).toBe(true);
225
- expect(result.resolved).toHaveLength(1);
226
- expect(result.resolved[0].name).toBe('community.general');
227
- expect(result.resolved[0].satisfiesAll).toBe(true);
228
- expect(formatConstraints(result.resolved[0].constraints)).toBe('>=9.0.0');
229
- });
230
-
231
- it('detects conflicts', () => {
232
- const requirements: CollectionRequirement[] = [
233
- {
234
- name: 'community.general',
235
- constraints: parseVersionConstraint('>=8.0.0,<9.0.0'),
236
- moduleId: 'module-a',
237
- reason: 'Uses old API',
238
- },
239
- {
240
- name: 'community.general',
241
- constraints: parseVersionConstraint('>=9.0.0'),
242
- moduleId: 'module-b',
243
- reason: 'Requires new features',
244
- },
245
- ];
246
-
247
- const result = mergeAnsibleRequirements(requirements);
248
-
249
- expect(result.success).toBe(false);
250
- expect(result.conflicts).toHaveLength(1);
251
- expect(result.conflicts?.[0].collection).toBe('community.general');
252
- expect(result.conflicts?.[0].requirements).toHaveLength(2);
253
- expect(result.conflicts?.[0].requirements[0].moduleId).toBe('module-a');
254
- expect(result.conflicts?.[0].requirements[1].moduleId).toBe('module-b');
255
- });
256
-
257
- it('handles multiple collections', () => {
258
- const requirements: CollectionRequirement[] = [
259
- {
260
- name: 'community.general',
261
- constraints: parseVersionConstraint('>=8.0.0'),
262
- moduleId: 'module-a',
263
- },
264
- {
265
- name: 'ansible.posix',
266
- constraints: parseVersionConstraint('>=1.5.0'),
267
- moduleId: 'module-a',
268
- },
269
- {
270
- name: 'community.general',
271
- constraints: parseVersionConstraint('>=9.0.0'),
272
- moduleId: 'module-b',
273
- },
274
- ];
275
-
276
- const result = mergeAnsibleRequirements(requirements);
277
-
278
- expect(result.success).toBe(true);
279
- expect(result.resolved).toHaveLength(2);
280
-
281
- const general = result.resolved.find((r) => r.name === 'community.general');
282
- const posix = result.resolved.find((r) => r.name === 'ansible.posix');
283
-
284
- expect(general).toBeDefined();
285
- // biome-ignore lint/style/noNonNullAssertion: checked with toBeDefined() above
286
- expect(formatConstraints(general!.constraints)).toBe('>=9.0.0');
287
-
288
- expect(posix).toBeDefined();
289
- // biome-ignore lint/style/noNonNullAssertion: checked with toBeDefined() above
290
- expect(formatConstraints(posix!.constraints)).toBe('>=1.5.0');
204
+ const outcome = await verifyCollectionIntegrity(info);
205
+ expect(`${label}: ${outcome.status}`).not.toBe(`${label}: verified`);
206
+ }
291
207
  });
292
208
  });
293
209
 
294
- describe('formatConstraints', () => {
295
- it('formats single constraint', () => {
296
- const constraints = parseVersionConstraint('>=8.0.0');
297
- expect(formatConstraints(constraints)).toBe('>=8.0.0');
298
- });
299
-
300
- it('formats range constraint', () => {
301
- const constraints = parseVersionConstraint('>=8.0.0,<10.0.0');
302
- expect(formatConstraints(constraints)).toBe('>=8.0.0,<10.0.0');
303
- });
304
-
305
- it('formats exact pin', () => {
306
- const constraints = parseVersionConstraint('==8.5.0');
307
- expect(formatConstraints(constraints)).toBe('==8.5.0');
210
+ /**
211
+ * celilo#524, policy half. A refuted collection now REFUSES the import; an
212
+ * uncheckable one warns.
213
+ *
214
+ * Refusing is cheap exactly here: `installCollectionsForModule` is called only
215
+ * from `module/import.ts`, and nothing installs collections in the deploy path,
216
+ * so a false positive means "this module did not import" rather than "the fleet
217
+ * stopped deploying". No module row exists yet either, so there is nothing left
218
+ * half-created — which is why this is an import refusal and not an ERROR state.
219
+ */
220
+ describe('reportIntegrity — what each outcome does to the import', () => {
221
+ test('a MISMATCH refuses, and the message says what and why', () => {
222
+ const refusal = reportIntegrity('community.general', {
223
+ status: 'mismatch',
224
+ detail: 'plugins.yml does not match its checksum',
225
+ });
226
+ expect(refusal).toBeTruthy();
227
+ expect(refusal).toContain('community.general');
228
+ expect(refusal).toContain('plugins.yml does not match its checksum');
229
+ // The operator needs to know this is refutation, not a missing checksum.
230
+ expect(refusal).toContain('corruption or tampering');
231
+ });
232
+
233
+ /**
234
+ * The distinction that keeps this from becoming a false-positive machine. A
235
+ * collection shipping no `file_manifest_file` is telling you about its
236
+ * publisher, not about tampering — blocking on it would refuse ordinary
237
+ * collections forever, which is how a check gets disabled wholesale.
238
+ */
239
+ test('an UNVERIFIABLE outcome does NOT refuse', () => {
240
+ expect(
241
+ reportIntegrity('community.general', {
242
+ status: 'unverifiable',
243
+ reason: 'MANIFEST.json declares no FILES.json checksum',
244
+ }),
245
+ ).toBeNull();
246
+ });
247
+
248
+ test('a verified collection refuses nothing', () => {
249
+ expect(
250
+ reportIntegrity('community.general', { status: 'verified', filesChecked: 5 }),
251
+ ).toBeNull();
308
252
  });
309
253
  });