@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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/db/schema.ts +6 -4
- package/src/hooks/capability-loader.ts +6 -0
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +0 -2
- package/src/manifest/contracts/v1.ts +19 -0
- package/src/manifest/schema.ts +1 -0
- package/src/services/alerting/inbound.test.ts +66 -0
- package/src/services/alerting/inbound.ts +35 -2
- package/src/services/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- package/src/services/audit/machines-reachable.test.ts +67 -8
- package/src/services/audit/machines-reachable.ts +18 -4
- package/src/services/deployed-systems.ts +31 -0
- package/src/services/fleet-checks.test.ts +232 -0
- package/src/services/fleet-checks.ts +275 -3
- package/src/services/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/machine-probe.test.ts +3 -4
- package/src/services/machine-probe.ts +2 -3
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-operations.test.ts +72 -1
- package/src/services/module-operations.ts +49 -10
- package/src/services/ssh-key-manager.test.ts +0 -10
- package/src/types/infrastructure.ts +11 -1
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -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
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -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.
|
|
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.
|
|
61
|
+
"@celilo/capabilities": "^1.5.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.8.
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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('
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
try {
|
|
43
|
+
rmSync(root, { recursive: true, force: true });
|
|
44
|
+
} catch {
|
|
45
|
+
/* ignore */
|
|
46
|
+
}
|
|
49
47
|
});
|
|
50
|
-
});
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
expect(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
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
|
});
|