@celilo/cli 0.26.1 → 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/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/infrastructure/property-extractor.test.ts +0 -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/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/module-deploy.ts +17 -39
- package/src/services/ssh-key-manager.test.ts +0 -10
- package/src/types/infrastructure.ts +11 -1
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
|
});
|