@celilo/cli 2.3.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +16 -3
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader.ts +49 -1
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +19 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +69 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-business-baseline.ts +17 -0
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +106 -28
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/provider-converge.test.ts +209 -0
- package/src/services/provider-converge.ts +241 -0
- package/src/services/static-content-converge.test.ts +39 -0
- package/src/services/static-content-converge.ts +26 -5
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -85,4 +85,4 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
85
85
|
|
|
86
86
|
## Archived / superseded
|
|
87
87
|
|
|
88
|
-
`modules/
|
|
88
|
+
`modules/__archive__/` holds retired modules — **dns-external** (VPS authoritative DNS + WireGuard, superseded by the `dns_internal`/`dns_registrar` split), **gmail** (email-reading capability), **namecheap-api** (registrar-config via Namecheap API, superseded by the **namecheap** DDNS module). Reference only; not deployed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.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": "^
|
|
61
|
+
"@celilo/capabilities": "^5.1.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.
|
|
63
|
+
"@celilo/core": "^0.13.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
package/src/api/serve.ts
CHANGED
|
@@ -139,7 +139,19 @@ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void)
|
|
|
139
139
|
stderr: 'pipe',
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
// Each stream is pumped separately and the log message names its origin,
|
|
143
|
+
// so a client can keep stderr distinguishable from the command's result
|
|
144
|
+
// (celilo#1362 — the two used to merge into one undifferentiated channel).
|
|
145
|
+
const markStream =
|
|
146
|
+
(stream: 'stdout' | 'stderr') =>
|
|
147
|
+
(msg: ServerMessage): void => {
|
|
148
|
+
forward(msg.type === 'log' ? { ...msg, stream } : msg);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await Promise.all([
|
|
152
|
+
pumpLines(child.stdout, markStream('stdout')),
|
|
153
|
+
pumpLines(child.stderr, markStream('stderr')),
|
|
154
|
+
]);
|
|
143
155
|
const exitCode = await child.exited;
|
|
144
156
|
forward(resultMessage(exitCode === 0, exitCode));
|
|
145
157
|
return exitCode;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { formatResult } from './module-health';
|
|
3
|
+
|
|
4
|
+
describe('module health rendering — the waiver annotation', () => {
|
|
5
|
+
test('a waived no-checks module renders the waiver beside the verdict, not instead of it', () => {
|
|
6
|
+
const output = formatResult({
|
|
7
|
+
moduleId: 'namecheap',
|
|
8
|
+
status: 'no-checks',
|
|
9
|
+
checks: [],
|
|
10
|
+
waiver: {
|
|
11
|
+
reason: 'API-only: a real check is hard and may not be worth forcing',
|
|
12
|
+
by: 'peba',
|
|
13
|
+
at: '2026-09-09',
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// The unmeasured verdict is still stated.
|
|
18
|
+
expect(output).toContain('no health check defined');
|
|
19
|
+
// The annotation names the human's words, who, and when.
|
|
20
|
+
expect(output).toContain(
|
|
21
|
+
'waived: API-only: a real check is hard and may not be worth forcing (by peba at 2026-09-09)',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a no-checks module without a waiver renders no waiver line', () => {
|
|
26
|
+
const output = formatResult({
|
|
27
|
+
moduleId: 'iptables',
|
|
28
|
+
status: 'no-checks',
|
|
29
|
+
checks: [],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(output).toContain('no health check defined');
|
|
33
|
+
expect(output).not.toContain('waived:');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -26,12 +26,20 @@ const CHECK_ICONS: Record<string, string> = {
|
|
|
26
26
|
skip: '○',
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function formatResult(result: HealthCheckResult): string {
|
|
29
|
+
export function formatResult(result: HealthCheckResult): string {
|
|
30
30
|
const icon = STATUS_ICONS[result.status] || '?';
|
|
31
31
|
const lines: string[] = [];
|
|
32
32
|
|
|
33
33
|
if (result.status === 'no-checks') {
|
|
34
34
|
lines.push(` ${result.moduleId} ${icon} no health check defined`);
|
|
35
|
+
// Annotate the waiver beside the verdict, never instead of it: the
|
|
36
|
+
// module is still unmeasured, a human just decided it needs no check
|
|
37
|
+
// (openspec/changes/health-waiver-mechanism, D3).
|
|
38
|
+
if (result.waiver) {
|
|
39
|
+
lines.push(
|
|
40
|
+
` waived: ${result.waiver.reason} (by ${result.waiver.by} at ${result.waiver.at})`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
35
43
|
return lines.join('\n');
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -70,10 +78,10 @@ export async function handleModuleHealth(
|
|
|
70
78
|
let results: HealthCheckResult[];
|
|
71
79
|
|
|
72
80
|
if (moduleId) {
|
|
73
|
-
const result = await runModuleHealthCheck(moduleId, db, { debug });
|
|
81
|
+
const result = await runModuleHealthCheck(moduleId, db, { debug, quiet: jsonOutput });
|
|
74
82
|
results = [result];
|
|
75
83
|
} else {
|
|
76
|
-
results = await runAllHealthChecks(db, { debug });
|
|
84
|
+
results = await runAllHealthChecks(db, { debug, quiet: jsonOutput });
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
if (jsonOutput) {
|
|
@@ -99,6 +99,28 @@ describe('handleModulePublish — manifest reading', () => {
|
|
|
99
99
|
expect(result.success).toBe(false);
|
|
100
100
|
if (!result.success) expect(result.error).toContain('manifest.yml missing id or version');
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test('a failed module does not stop the sweep — every module is attempted and every failure is named', async () => {
|
|
104
|
+
// celilo#1369: the loop bailed at the first failed module, so modules
|
|
105
|
+
// after it were never attempted (release run 6705 left nine modules
|
|
106
|
+
// unpublished). Three dirs that each fail before any registry access,
|
|
107
|
+
// at three different checks, prove the loop reaches all of them: the
|
|
108
|
+
// returned error must name every dir, not only the first.
|
|
109
|
+
const dirA = join(TEST_DIR, 'a-missing-id');
|
|
110
|
+
const dirB = join(TEST_DIR, 'b-missing-manifest');
|
|
111
|
+
const dirC = join(TEST_DIR, 'c-missing-version');
|
|
112
|
+
for (const d of [dirA, dirB, dirC]) await mkdir(d, { recursive: true });
|
|
113
|
+
await writeFile(join(dirA, 'manifest.yml'), 'version: "1.0.0"\n');
|
|
114
|
+
await writeFile(join(dirC, 'manifest.yml'), 'id: c-module\n');
|
|
115
|
+
|
|
116
|
+
const result = await handleModulePublish([dirA, dirB, dirC], { token: 'tok' });
|
|
117
|
+
expect(result.success).toBe(false);
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
expect(result.error).toContain(dirA);
|
|
120
|
+
expect(result.error).toContain(dirB);
|
|
121
|
+
expect(result.error).toContain(dirC);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
102
124
|
});
|
|
103
125
|
|
|
104
126
|
// ── Revision auto-detection algorithm ────────────────────────────────────────
|
|
@@ -385,23 +385,25 @@ export async function handleModulePublish(
|
|
|
385
385
|
for (const moduleDir of args) {
|
|
386
386
|
const outcome = await publishOneModule(moduleDir, opts);
|
|
387
387
|
outcomes.push(outcome);
|
|
388
|
-
if (outcome.status === 'failed') {
|
|
389
|
-
// Print summary so far, then bail. Publishes are non-destructive so
|
|
390
|
-
// re-running after fixing the underlying issue picks up where we left
|
|
391
|
-
// off (already-published modules are skipped at the registry level
|
|
392
|
-
// when --revision is explicit, and auto-revision just picks the next
|
|
393
|
-
// slot — nothing duplicates).
|
|
394
|
-
printSummary(outcomes);
|
|
395
|
-
return {
|
|
396
|
-
success: false,
|
|
397
|
-
error: outcome.message,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
388
|
console.log(outcome.message);
|
|
401
389
|
}
|
|
402
390
|
|
|
391
|
+
// celilo#1369: every module is attempted even after a failure, and every
|
|
392
|
+
// failure is reported together at the end. A publish is non-destructive so
|
|
393
|
+
// re-running after fixing the underlying issue picks up where we left off
|
|
394
|
+
// (already-published modules are skipped at the registry level when
|
|
395
|
+
// --revision is explicit, and auto-revision just picks the next slot —
|
|
396
|
+
// nothing duplicates).
|
|
403
397
|
printSummary(outcomes);
|
|
404
398
|
|
|
399
|
+
const failed = outcomes.filter((o) => o.status === 'failed');
|
|
400
|
+
if (failed.length > 0) {
|
|
401
|
+
return {
|
|
402
|
+
success: false,
|
|
403
|
+
error: failed.map((o) => o.message).join('\n'),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
405
407
|
if (args.length === 1) {
|
|
406
408
|
// Backward-compat single-module shape — keep the existing message/data
|
|
407
409
|
// contract for callers (and tests) that depend on it. By this point no
|
|
@@ -52,7 +52,7 @@ type UpdateOutcome =
|
|
|
52
52
|
| { status: 'failed'; moduleId: string; error: string }
|
|
53
53
|
// `skipped` means the path expanded from a glob but isn't an
|
|
54
54
|
// upgradable target — either no manifest at all (probably a non-
|
|
55
|
-
// module sibling like `modules/
|
|
55
|
+
// module sibling like `modules/__archive__/`) or a real module that
|
|
56
56
|
// isn't installed in this celilo. Treated as a soft pass so
|
|
57
57
|
// `celilo module update modules/*` does what users expect.
|
|
58
58
|
| { status: 'skipped'; moduleId: string; reason: string };
|
|
@@ -115,34 +115,86 @@ export function classifyVersionChange(installed: string, latest: string): Versio
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Download a
|
|
119
|
-
* the
|
|
120
|
-
*
|
|
118
|
+
* Download a registry package into a temp file. Shared by `fetchAndUpdate`
|
|
119
|
+
* (the full update path) and `fetchTargetManifest` (the upgrade gate that
|
|
120
|
+
* must read the TARGET manifest before it mutates anything). The index entry
|
|
121
|
+
* carries the published sha256; absence is tolerated, matching
|
|
122
|
+
* `fetchAndUpdate`'s original tolerance for entries predating cksum.
|
|
121
123
|
*/
|
|
122
|
-
export async function
|
|
124
|
+
export async function downloadRegistryPackage(
|
|
123
125
|
client: RegistryClient,
|
|
124
126
|
moduleId: string,
|
|
125
127
|
version: string,
|
|
126
|
-
|
|
127
|
-
flags: Record<string, string | boolean>,
|
|
128
|
-
): Promise<UpdateOutcome> {
|
|
128
|
+
): Promise<{ ok: true; tmpPath: string } | { ok: false; error: string }> {
|
|
129
129
|
const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
|
|
130
130
|
try {
|
|
131
|
-
// The index entry carries the published sha256. Look it up here rather than
|
|
132
|
-
// thread it through five call sites, so a short download is caught as a bad
|
|
133
|
-
// transfer instead of surfacing later as "zlib: unexpected end of file".
|
|
134
|
-
// Absence is tolerated: an entry predating cksum still downloads.
|
|
135
131
|
const entries = await client.getIndex(moduleId).catch(() => []);
|
|
136
132
|
const cksum = entries.find((entry) => entry.vers === version)?.cksum;
|
|
137
133
|
const pkgData = await client.download(moduleId, version, cksum);
|
|
138
134
|
await Bun.write(tmpPath, pkgData);
|
|
135
|
+
return { ok: true, tmpPath };
|
|
139
136
|
} catch (err) {
|
|
140
137
|
return {
|
|
141
|
-
|
|
142
|
-
moduleId,
|
|
138
|
+
ok: false,
|
|
143
139
|
error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
144
140
|
};
|
|
145
141
|
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read the manifest of a registry version WITHOUT installing it: download,
|
|
146
|
+
* extract to a temp dir, parse manifest.yml, clean up. Used by the upgrade
|
|
147
|
+
* path to gate on the target's capability requirements before any state
|
|
148
|
+
* changes (celilo#1361). Failure to fetch or parse is not itself fatal — the
|
|
149
|
+
* caller proceeds to `fetchAndUpdate`, which reports the same failure through
|
|
150
|
+
* its own channel.
|
|
151
|
+
*/
|
|
152
|
+
export async function fetchTargetManifest(
|
|
153
|
+
client: RegistryClient,
|
|
154
|
+
moduleId: string,
|
|
155
|
+
version: string,
|
|
156
|
+
): Promise<{ ok: true; manifest: ModuleManifest } | { ok: false }> {
|
|
157
|
+
const downloaded = await downloadRegistryPackage(client, moduleId, version);
|
|
158
|
+
if (!downloaded.ok) return { ok: false };
|
|
159
|
+
try {
|
|
160
|
+
const extractResult = await extractPackage(downloaded.tmpPath);
|
|
161
|
+
if (!extractResult.success || !extractResult.tempDir) return { ok: false };
|
|
162
|
+
try {
|
|
163
|
+
const manifestPath = join(extractResult.tempDir, 'manifest.yml');
|
|
164
|
+
if (!existsSync(manifestPath)) return { ok: false };
|
|
165
|
+
const parsed = parseYaml(readFileSync(manifestPath, 'utf-8'));
|
|
166
|
+
return { ok: true, manifest: ModuleManifestSchema.parse(parsed) };
|
|
167
|
+
} finally {
|
|
168
|
+
await cleanupTempDir(extractResult.tempDir);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return { ok: false };
|
|
172
|
+
} finally {
|
|
173
|
+
try {
|
|
174
|
+
await unlink(downloaded.tmpPath);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Download a module package from the registry into a temp file and run
|
|
181
|
+
* the standard updateOne path against it. Cleans the temp file in a
|
|
182
|
+
* finally block so a mid-flight failure doesn't leak a tar.zst on disk.
|
|
183
|
+
*/
|
|
184
|
+
export async function fetchAndUpdate(
|
|
185
|
+
client: RegistryClient,
|
|
186
|
+
moduleId: string,
|
|
187
|
+
version: string,
|
|
188
|
+
db: ReturnType<typeof getDb>,
|
|
189
|
+
flags: Record<string, string | boolean>,
|
|
190
|
+
): Promise<UpdateOutcome> {
|
|
191
|
+
// The download (index lookup, cksum verification, temp write) is shared
|
|
192
|
+
// with `fetchTargetManifest`, which reads the same package before this
|
|
193
|
+
// update path ever runs.
|
|
194
|
+
const downloaded = await downloadRegistryPackage(client, moduleId, version);
|
|
195
|
+
if (!downloaded.ok) {
|
|
196
|
+
return { status: 'failed', moduleId, error: downloaded.error };
|
|
197
|
+
}
|
|
146
198
|
try {
|
|
147
199
|
// Registry packages are pre-verified at publish time; skip the
|
|
148
200
|
// signature check here to match `module import`'s registry path.
|
|
@@ -153,14 +205,14 @@ export async function fetchAndUpdate(
|
|
|
153
205
|
// log line — without it, output would say "v1.0.0 → v1.0.0" because
|
|
154
206
|
// the manifest semver doesn't include the +N revision.
|
|
155
207
|
return await updateOne(
|
|
156
|
-
tmpPath,
|
|
208
|
+
downloaded.tmpPath,
|
|
157
209
|
db,
|
|
158
210
|
{ ...flags, 'skip-verify': true },
|
|
159
211
|
{ quiet: true, displayVersion: version },
|
|
160
212
|
);
|
|
161
213
|
} finally {
|
|
162
214
|
try {
|
|
163
|
-
await unlink(tmpPath);
|
|
215
|
+
await unlink(downloaded.tmpPath);
|
|
164
216
|
} catch {}
|
|
165
217
|
}
|
|
166
218
|
}
|
|
@@ -560,7 +612,7 @@ export async function handleModuleUpdate(
|
|
|
560
612
|
);
|
|
561
613
|
|
|
562
614
|
// Skips that fall under a wildcard expansion (e.g. modules/* picking
|
|
563
|
-
// up `modules/
|
|
615
|
+
// up `modules/__archive__/`) shouldn't even be mentioned — they're not
|
|
564
616
|
// signal. Skips for "module not installed" ARE signal because the
|
|
565
617
|
// user explicitly named the path; surface those.
|
|
566
618
|
const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry-poll capability gate (celilo#1361): `module upgrade` reads the
|
|
3
|
+
* TARGET version's manifest BEFORE updating anything, and defers — without
|
|
4
|
+
* changing any state — when the deployed capability providers cannot serve
|
|
5
|
+
* its requirements.
|
|
6
|
+
*
|
|
7
|
+
* celilo-website burned six release revisions (+1 through +6) on exactly
|
|
8
|
+
* this: every poll attempt updated the stored version, failed in caddy's
|
|
9
|
+
* publish hook ("sourceDir is required" against a provider built before
|
|
10
|
+
* b67c9423), recorded the new version beside the old VERIFIED state, and
|
|
11
|
+
* re-triggered on the next tick. The gate makes that sequence impossible.
|
|
12
|
+
*
|
|
13
|
+
* The RegistryClient is spied (the established pattern in
|
|
14
|
+
* `module-import-registry.test.ts`); the download returns a REAL tar package
|
|
15
|
+
* carrying only a manifest, which is all `fetchTargetManifest` reads.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
|
19
|
+
import type { Mock } from 'bun:test';
|
|
20
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
21
|
+
import { writeFile } from 'node:fs/promises';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { create as tarCreate } from 'tar';
|
|
25
|
+
import { getDb } from '../../db/client';
|
|
26
|
+
import { capabilities, modules } from '../../db/schema';
|
|
27
|
+
import type { IndexEntry } from '../../registry/client';
|
|
28
|
+
import { RegistryClient } from '../../registry/client';
|
|
29
|
+
import { resetTestDbPath } from '../../test-utils/db-path';
|
|
30
|
+
import { handleModuleUpgrade } from './module-upgrade';
|
|
31
|
+
|
|
32
|
+
function entry(vers: string): IndexEntry {
|
|
33
|
+
return { name: 'celilo-website', vers, deps: [], cksum: 'abc', yanked: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A minimal registry package: a tar whose root is manifest.yml. */
|
|
37
|
+
async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-pkg-'));
|
|
39
|
+
await writeFile(join(dir, 'manifest.yml'), manifestYaml);
|
|
40
|
+
const tarPath = join(dir, 'pkg.netapp');
|
|
41
|
+
await tarCreate({ file: tarPath, cwd: dir, portable: true }, ['manifest.yml']);
|
|
42
|
+
const bytes = await Bun.file(tarPath).arrayBuffer();
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MANIFEST = (publicWebVersion: string) => `
|
|
48
|
+
celilo_contract: "1.0"
|
|
49
|
+
id: celilo-website
|
|
50
|
+
name: Celilo Website
|
|
51
|
+
version: 2.0.0
|
|
52
|
+
requires:
|
|
53
|
+
capabilities:
|
|
54
|
+
- name: public_web
|
|
55
|
+
version: ${publicWebVersion}
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
let tempDir: string;
|
|
59
|
+
let getIndexSpy: Mock<(name: string) => Promise<IndexEntry[]>>;
|
|
60
|
+
let downloadSpy: Mock<(name: string, vers: string) => Promise<ArrayBuffer>>;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-test-'));
|
|
64
|
+
process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
|
|
65
|
+
process.env.CELILO_DATA_DIR = tempDir;
|
|
66
|
+
getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([entry('2.0.0')]);
|
|
67
|
+
downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(new ArrayBuffer(0));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
getIndexSpy.mockRestore();
|
|
72
|
+
downloadSpy.mockRestore();
|
|
73
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
74
|
+
resetTestDbPath();
|
|
75
|
+
delete process.env.CELILO_DATA_DIR;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function seedFleet(caddyPublicWebVersion: string): void {
|
|
79
|
+
const db = getDb();
|
|
80
|
+
// sourcePath must be a real writable dir: when the gate does NOT fire, the
|
|
81
|
+
// flow proceeds into updateOne, which stages the new copy beside it.
|
|
82
|
+
const installDir = (id: string): string => {
|
|
83
|
+
const p = join(tempDir, 'modules', id);
|
|
84
|
+
mkdirSync(p, { recursive: true });
|
|
85
|
+
return p;
|
|
86
|
+
};
|
|
87
|
+
db.insert(modules)
|
|
88
|
+
.values({
|
|
89
|
+
id: 'celilo-website',
|
|
90
|
+
name: 'Celilo Website',
|
|
91
|
+
version: '1.0.5',
|
|
92
|
+
manifestData: { id: 'celilo-website', name: 'Celilo Website', version: '1.0.5' },
|
|
93
|
+
sourcePath: installDir('celilo-website'),
|
|
94
|
+
})
|
|
95
|
+
.run();
|
|
96
|
+
db.insert(modules)
|
|
97
|
+
.values({
|
|
98
|
+
id: 'caddy',
|
|
99
|
+
name: 'Caddy',
|
|
100
|
+
version: '2.3.3+1',
|
|
101
|
+
manifestData: { id: 'caddy', name: 'Caddy', version: '2.3.3' },
|
|
102
|
+
sourcePath: installDir('caddy'),
|
|
103
|
+
})
|
|
104
|
+
.run();
|
|
105
|
+
db.insert(capabilities)
|
|
106
|
+
.values({
|
|
107
|
+
moduleId: 'caddy',
|
|
108
|
+
capabilityName: 'public_web',
|
|
109
|
+
version: caddyPublicWebVersion,
|
|
110
|
+
data: {},
|
|
111
|
+
})
|
|
112
|
+
.run();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
|
|
116
|
+
test('defers an upgrade whose requirement no deployed provider serves', async () => {
|
|
117
|
+
seedFleet('3.1.0');
|
|
118
|
+
downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('4.0.0')));
|
|
119
|
+
|
|
120
|
+
const result = await handleModuleUpgrade(['celilo-website'], {});
|
|
121
|
+
|
|
122
|
+
expect(result.success).toBe(false);
|
|
123
|
+
if (!result.success) {
|
|
124
|
+
expect(result.deferred).toBe(true);
|
|
125
|
+
expect(result.error).toContain('deferred');
|
|
126
|
+
expect(result.error).toContain('public_web@4.0.0');
|
|
127
|
+
expect(result.error).toContain('caddy');
|
|
128
|
+
}
|
|
129
|
+
// The point of deferring: NO state changed. The stored version still
|
|
130
|
+
// reads the installed release and will re-read as up-to-date-candidate
|
|
131
|
+
// next tick instead of looking upgraded-but-broken (celilo#1363's shape).
|
|
132
|
+
const row = getDb()
|
|
133
|
+
.select()
|
|
134
|
+
.from(modules)
|
|
135
|
+
.all()
|
|
136
|
+
.find((m) => m.id === 'celilo-website');
|
|
137
|
+
expect(row?.version).toBe('1.0.5');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('does not defer when the deployed provider serves the requirement', async () => {
|
|
141
|
+
seedFleet('3.1.0');
|
|
142
|
+
downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.0.0')));
|
|
143
|
+
|
|
144
|
+
const result = await handleModuleUpgrade(['celilo-website'], {});
|
|
145
|
+
|
|
146
|
+
// Whatever happens downstream (the minimal package has no scripts), the
|
|
147
|
+
// gate itself must not have fired — `deferred` marks a capability wall.
|
|
148
|
+
if (!result.success) {
|
|
149
|
+
expect(result.deferred).toBeUndefined();
|
|
150
|
+
} else {
|
|
151
|
+
expect(result.success).toBe(true);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -22,6 +22,7 @@ import type { ModuleManifest } from '../../manifest/schema';
|
|
|
22
22
|
import { RegistryClient } from '../../registry/client';
|
|
23
23
|
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
24
24
|
import { getDefaultBackupStorage } from '../../services/backup-storage';
|
|
25
|
+
import { capabilityBlockersForManifest } from '../../services/capability-compat';
|
|
25
26
|
import {
|
|
26
27
|
type DeployPosture,
|
|
27
28
|
type UpgradePolicy,
|
|
@@ -33,6 +34,7 @@ import { deployModule } from '../../services/module-deploy';
|
|
|
33
34
|
import { getArg, getFlag } from '../parser';
|
|
34
35
|
import { log } from '../prompts';
|
|
35
36
|
import type { CommandResult } from '../types';
|
|
37
|
+
import { fetchTargetManifest } from './module-update';
|
|
36
38
|
import { classifyVersionChange, fetchAndUpdate } from './module-update';
|
|
37
39
|
|
|
38
40
|
const VALID_POLICIES: readonly UpgradePolicy[] = ['by-semver', 'always-safe', 'always-fast'];
|
|
@@ -181,6 +183,28 @@ export async function upgradeOneModule(
|
|
|
181
183
|
): Promise<CommandResult> {
|
|
182
184
|
const moduleId = mod.id;
|
|
183
185
|
|
|
186
|
+
// GATE (celilo#1361): read the TARGET manifest BEFORE anything mutates, and
|
|
187
|
+
// refuse an upgrade whose capability requirements no deployed provider
|
|
188
|
+
// serves. Deploying first and failing in the module's publish hook is how
|
|
189
|
+
// celilo-website burned six release revisions (+1 through +6): each failure
|
|
190
|
+
// recorded the new version, re-triggered the poll, and changed nothing.
|
|
191
|
+
// A deferral changes no state and retries once the provider lands.
|
|
192
|
+
const targetManifestCheck = await fetchTargetManifest(client, moduleId, targetVersion);
|
|
193
|
+
if (targetManifestCheck.ok) {
|
|
194
|
+
const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
|
|
195
|
+
if (blockers.length > 0) {
|
|
196
|
+
const why = blockers.map((b) => b.message).join('; ');
|
|
197
|
+
return {
|
|
198
|
+
success: false,
|
|
199
|
+
deferred: true,
|
|
200
|
+
error: `${moduleId} upgrade to ${targetVersion} deferred: the deployed providers cannot serve its capability requirements: ${why}. Upgrade the provider module first; the registry poll retries automatically.`,
|
|
201
|
+
details: blockers,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// A failed fetch here is NOT deferred — `fetchAndUpdate` below reports the
|
|
206
|
+
// same failure through its own channel with the real error text.
|
|
207
|
+
|
|
184
208
|
// Update FIRST (refresh stored def). Every downstream decision — the
|
|
185
209
|
// backup gate and the posture policy — must consult the TARGET version's
|
|
186
210
|
// manifest, not the installed one. Gating on the pre-update manifest skipped
|
|
@@ -320,6 +344,7 @@ async function runRegistryPoll(
|
|
|
320
344
|
|
|
321
345
|
log.info(`Registry poll: ${targets.length} module(s) to upgrade.`);
|
|
322
346
|
const upgraded: string[] = [];
|
|
347
|
+
const deferred: string[] = [];
|
|
323
348
|
const failed: string[] = [];
|
|
324
349
|
// Serial, in installed order. (Strict provider-before-consumer ordering via
|
|
325
350
|
// topologicalOrder is a refinement; the poll is idempotent + re-runs.)
|
|
@@ -327,14 +352,29 @@ async function runRegistryPoll(
|
|
|
327
352
|
const mod = rowById.get(t.moduleId);
|
|
328
353
|
if (!mod) continue;
|
|
329
354
|
const result = await upgradeOneModule(mod, t.to, client, db, flags);
|
|
330
|
-
if (result.success)
|
|
331
|
-
|
|
355
|
+
if (result.success) {
|
|
356
|
+
upgraded.push(`${t.moduleId}→${t.to}`);
|
|
357
|
+
} else if (result.deferred) {
|
|
358
|
+
// Expected steady state while a provider upgrade is still pending: the
|
|
359
|
+
// consumer retries next tick. Not a failure — celilo#1361.
|
|
360
|
+
deferred.push(`${t.moduleId}: ${result.error}`);
|
|
361
|
+
} else {
|
|
362
|
+
failed.push(`${t.moduleId}: ${result.error}`);
|
|
363
|
+
}
|
|
332
364
|
}
|
|
333
365
|
|
|
334
366
|
if (failed.length > 0) {
|
|
335
367
|
return {
|
|
336
368
|
success: false,
|
|
337
|
-
error:
|
|
369
|
+
error:
|
|
370
|
+
`Registry poll: upgraded ${upgraded.length}, deferred ${deferred.length}, FAILED ${failed.length}:\n` +
|
|
371
|
+
` ${[...failed, ...(deferred.length > 0 ? [`(deferred) ${deferred.join('\n (deferred) ')}`] : [])].join('\n ')}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (deferred.length > 0) {
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
message: `Registry poll: upgraded ${upgraded.length}, deferred ${deferred.length} (capability requirements not yet served — retries next poll):\n ${deferred.join('\n ')}`,
|
|
338
378
|
};
|
|
339
379
|
}
|
|
340
380
|
return {
|
|
@@ -91,8 +91,9 @@ export function buildWorkspaceVersionMap(): WorkspaceVersionMap {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* List every publishable module under modules/. Excludes `
|
|
95
|
-
* (operator-confirmed: not publishable
|
|
94
|
+
* List every publishable module under modules/. Excludes `__archive__/`
|
|
95
|
+
* (operator-confirmed: not publishable, and renamed with the dunder prefix so
|
|
96
|
+
* nothing mistakes it for a module) and any dir lacking a
|
|
96
97
|
* manifest.yml.
|
|
97
98
|
*/
|
|
98
99
|
export function listModuleDirs(): string[] {
|
|
@@ -100,7 +101,7 @@ export function listModuleDirs(): string[] {
|
|
|
100
101
|
if (!existsSync(modulesRoot)) return [];
|
|
101
102
|
const out: string[] = [];
|
|
102
103
|
for (const name of readdirSync(modulesRoot)) {
|
|
103
|
-
if (name === '
|
|
104
|
+
if (name === '__archive__') continue;
|
|
104
105
|
const dir = join(modulesRoot, name);
|
|
105
106
|
let st: ReturnType<typeof statSync>;
|
|
106
107
|
try {
|
|
@@ -120,6 +120,12 @@ export function parseOptions(argv: string[]): ParsedFlags {
|
|
|
120
120
|
const trackAlphaFlag = argv.includes('--track-alpha');
|
|
121
121
|
const alphaModulesFlag = argv.includes('--alpha-modules');
|
|
122
122
|
const skipChangesets = argv.includes('--skip-changesets');
|
|
123
|
+
const skipModulesFlag = argv.includes('--skip-modules');
|
|
124
|
+
const modulesOnlyFlag = argv.includes('--modules-only');
|
|
125
|
+
if (skipModulesFlag && modulesOnlyFlag) {
|
|
126
|
+
console.error('✗ --skip-modules and --modules-only are mutually exclusive.');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
123
129
|
const promoteArg = takeFlagValue('--promote');
|
|
124
130
|
const skippedModules: string[] = [];
|
|
125
131
|
for (let i = argv.indexOf('--skip-module'); i >= 0; i = argv.indexOf('--skip-module', i + 1)) {
|
|
@@ -162,7 +168,13 @@ export function parseOptions(argv: string[]): ParsedFlags {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
return {
|
|
165
|
-
options: {
|
|
171
|
+
options: {
|
|
172
|
+
allowStale,
|
|
173
|
+
autoYes: yes,
|
|
174
|
+
mode,
|
|
175
|
+
skippedModules,
|
|
176
|
+
modulePhase: modulesOnlyFlag ? 'only' : skipModulesFlag ? 'skip' : 'run',
|
|
177
|
+
},
|
|
166
178
|
dryRun,
|
|
167
179
|
releaseTouch,
|
|
168
180
|
skipChangesets,
|