@celilo/cli 2.3.0 → 3.0.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/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 +63 -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/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/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
|
@@ -15,7 +15,7 @@ import { createHookStores } from '../hooks/hook-store';
|
|
|
15
15
|
import { loadHookConfigMap } from '../hooks/load-hook-config';
|
|
16
16
|
import { createCapturingLogger, createConsoleLogger, createGaugeLogger } from '../hooks/logger';
|
|
17
17
|
import type { HookLogger, HookResult } from '../hooks/types';
|
|
18
|
-
import type { ModuleManifest } from '../manifest/schema';
|
|
18
|
+
import type { HealthWaiver, ModuleManifest } from '../manifest/schema';
|
|
19
19
|
import { decryptSecret } from '../secrets/encryption';
|
|
20
20
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
21
21
|
import { controlPlaneHealthChecks } from './control-plane-health';
|
|
@@ -51,12 +51,26 @@ export interface HealthCheckResult {
|
|
|
51
51
|
* watching to re-run the check by hand.
|
|
52
52
|
*/
|
|
53
53
|
artifactPaths?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* The module's recorded health waiver, if its manifest declares one
|
|
56
|
+
* (openspec/changes/health-waiver-mechanism). It does not change this
|
|
57
|
+
* result's severity — an unmeasured module stays unmeasured — it
|
|
58
|
+
* annotates the finding with who decided and why.
|
|
59
|
+
*/
|
|
60
|
+
waiver?: HealthWaiver;
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
export interface HealthCheckOptions {
|
|
57
64
|
debug?: boolean;
|
|
58
65
|
noInteractive?: boolean;
|
|
59
66
|
onProgress?: (msg: string) => void;
|
|
67
|
+
/**
|
|
68
|
+
* Presentation-only silence: no gauge and no plain stdout lines, hook
|
|
69
|
+
* output captured instead. Unlike `unattended` this does NOT touch the
|
|
70
|
+
* state-transition guard — a `--json` command still runs real checks
|
|
71
|
+
* and still moves lifecycle state (celilo#1362).
|
|
72
|
+
*/
|
|
73
|
+
quiet?: boolean;
|
|
60
74
|
/**
|
|
61
75
|
* Scheduled/monitor-driven run: no FuelGauge, no stdout, and — the part
|
|
62
76
|
* that matters beyond cosmetics — the module's lifecycle state is NOT
|
|
@@ -105,6 +119,15 @@ function createHealthCheckPresentation(
|
|
|
105
119
|
return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
|
|
106
120
|
}
|
|
107
121
|
|
|
122
|
+
// Non-interactive stdout (redirected, piped, or an explicit --json
|
|
123
|
+
// caller): stdout is a data channel here (celilo#699), so neither the
|
|
124
|
+
// gauge's frames nor the gauge logger's plain lines may reach it.
|
|
125
|
+
// Capture everything instead (celilo#1362). State transitions still
|
|
126
|
+
// happen — only the presentation goes quiet.
|
|
127
|
+
if (options.quiet || !process.stdout.isTTY) {
|
|
128
|
+
return { logger: createCapturingLogger().logger, finish: NO_OP_FINISH };
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
const gauge = new FuelGauge('Testing app', { skipAnimation: options.noInteractive });
|
|
109
132
|
gauge.start();
|
|
110
133
|
return {
|
|
@@ -178,7 +201,7 @@ export async function runModuleHealthCheck(
|
|
|
178
201
|
const hookDef = manifest.hooks?.health_check;
|
|
179
202
|
|
|
180
203
|
if (!hookDef) {
|
|
181
|
-
return { moduleId, status: 'no-checks', checks: [] };
|
|
204
|
+
return { moduleId, status: 'no-checks', checks: [], waiver: manifest.health_waiver };
|
|
182
205
|
}
|
|
183
206
|
|
|
184
207
|
// Build config + secrets for hook context. The config map shape
|
|
@@ -245,7 +268,15 @@ export async function runModuleHealthCheck(
|
|
|
245
268
|
};
|
|
246
269
|
|
|
247
270
|
const checks = outputs.checks || [];
|
|
248
|
-
return finishHealthCheck(
|
|
271
|
+
return finishHealthCheck(
|
|
272
|
+
moduleId,
|
|
273
|
+
module.state,
|
|
274
|
+
checks,
|
|
275
|
+
db,
|
|
276
|
+
options,
|
|
277
|
+
hookResult.artifactPaths,
|
|
278
|
+
manifest.health_waiver,
|
|
279
|
+
);
|
|
249
280
|
}
|
|
250
281
|
|
|
251
282
|
/**
|
|
@@ -277,6 +308,7 @@ function finishHealthCheck(
|
|
|
277
308
|
db: DbClient,
|
|
278
309
|
options: HealthCheckOptions,
|
|
279
310
|
artifactPaths?: string[],
|
|
311
|
+
waiver?: HealthWaiver,
|
|
280
312
|
): HealthCheckResult {
|
|
281
313
|
const status = deriveHealthStatus(checks);
|
|
282
314
|
|
|
@@ -298,7 +330,13 @@ function finishHealthCheck(
|
|
|
298
330
|
.run();
|
|
299
331
|
}
|
|
300
332
|
|
|
301
|
-
return {
|
|
333
|
+
return {
|
|
334
|
+
moduleId,
|
|
335
|
+
status,
|
|
336
|
+
checks,
|
|
337
|
+
...(artifactPaths ? { artifactPaths } : {}),
|
|
338
|
+
...(waiver ? { waiver } : {}),
|
|
339
|
+
};
|
|
302
340
|
}
|
|
303
341
|
|
|
304
342
|
/**
|
|
@@ -343,7 +381,7 @@ async function mapConcurrent<T, R>(
|
|
|
343
381
|
*/
|
|
344
382
|
export async function runAllHealthChecks(
|
|
345
383
|
db: DbClient,
|
|
346
|
-
options: { debug?: boolean; onProgress?: (msg: string) => void } = {},
|
|
384
|
+
options: { debug?: boolean; onProgress?: (msg: string) => void; quiet?: boolean } = {},
|
|
347
385
|
): Promise<HealthCheckResult[]> {
|
|
348
386
|
const eligible = db
|
|
349
387
|
.select()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isPreservedGeneratedPath, pruneGeneratedProject } from './module-deploy';
|
|
5
|
+
|
|
6
|
+
const ROOT = '/tmp/celilo-prune-test';
|
|
7
|
+
const GENERATED = join(ROOT, 'generated');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mirrors the layout observed on celilo-mgr at 2026-09-16T00:51Z:
|
|
11
|
+
* `generated/{ansible,deploy.log,terraform}` with state inside terraform/.
|
|
12
|
+
* Derived from the real tree rather than invented — a hand-written mirror gives
|
|
13
|
+
* confident results about a world that does not exist.
|
|
14
|
+
*/
|
|
15
|
+
function buildGeneratedTree(): void {
|
|
16
|
+
mkdirSync(join(GENERATED, 'ansible', 'roles'), { recursive: true });
|
|
17
|
+
mkdirSync(join(GENERATED, 'terraform', '.terraform', 'providers'), { recursive: true });
|
|
18
|
+
|
|
19
|
+
writeFileSync(join(GENERATED, 'deploy.log'), 'deploy output');
|
|
20
|
+
writeFileSync(join(GENERATED, 'ansible', 'secrets.yml'), 'vault-encrypted secrets');
|
|
21
|
+
writeFileSync(join(GENERATED, 'ansible', 'roles', 'task.yml'), 'rendered task');
|
|
22
|
+
writeFileSync(join(GENERATED, 'terraform', 'main.tf'), 'rendered terraform');
|
|
23
|
+
writeFileSync(join(GENERATED, 'terraform', 'terraform.tfvars'), 'rendered vars');
|
|
24
|
+
writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), '{"lxc":207}');
|
|
25
|
+
writeFileSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'), '{"lxc":207,"old":1}');
|
|
26
|
+
writeFileSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'), 'provider lock');
|
|
27
|
+
writeFileSync(join(GENERATED, 'terraform', '.terraform', 'providers', 'bin'), 'huge binary');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
32
|
+
buildGeneratedTree();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('pruneGeneratedProject', () => {
|
|
40
|
+
// The recurrence gate for celilo#1374's root cause. Deleting this file is
|
|
41
|
+
// what armed every container module for destruction on its next deploy.
|
|
42
|
+
test('keeps terraform state and its lock', () => {
|
|
43
|
+
pruneGeneratedProject(GENERATED);
|
|
44
|
+
|
|
45
|
+
expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate'))).toBe(true);
|
|
46
|
+
expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfstate.backup'))).toBe(true);
|
|
47
|
+
expect(existsSync(join(GENERATED, 'terraform', '.terraform.lock.hcl'))).toBe(true);
|
|
48
|
+
// Intact, not merely present.
|
|
49
|
+
expect(readFileSync(join(GENERATED, 'terraform', 'terraform.tfstate'), 'utf-8')).toBe(
|
|
50
|
+
'{"lxc":207}',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// D4's actual concern. If this ever passes, the fix has quietly become
|
|
55
|
+
// "persist the rendered project", which D4 refused for good reasons.
|
|
56
|
+
test('still deletes every rendered template and secret', () => {
|
|
57
|
+
pruneGeneratedProject(GENERATED);
|
|
58
|
+
|
|
59
|
+
expect(existsSync(join(GENERATED, 'ansible'))).toBe(false);
|
|
60
|
+
expect(existsSync(join(GENERATED, 'deploy.log'))).toBe(false);
|
|
61
|
+
expect(existsSync(join(GENERATED, 'terraform', 'main.tf'))).toBe(false);
|
|
62
|
+
expect(existsSync(join(GENERATED, 'terraform', 'terraform.tfvars'))).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ~19 MB per provider, rebuilt by `terraform init`; excluded from the backup
|
|
66
|
+
// envelope for the same reason (cross-module-read.ts).
|
|
67
|
+
test('drops the provider cache', () => {
|
|
68
|
+
pruneGeneratedProject(GENERATED);
|
|
69
|
+
|
|
70
|
+
expect(existsSync(join(GENERATED, 'terraform', '.terraform'))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// A deploy that failed before rendering never made one.
|
|
74
|
+
test('is a no-op when generated/ is absent', () => {
|
|
75
|
+
rmSync(GENERATED, { recursive: true, force: true });
|
|
76
|
+
|
|
77
|
+
expect(() => pruneGeneratedProject(GENERATED)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('isPreservedGeneratedPath', () => {
|
|
82
|
+
test('preserves only state files directly under terraform/', () => {
|
|
83
|
+
expect(isPreservedGeneratedPath('terraform/terraform.tfstate')).toBe(true);
|
|
84
|
+
expect(isPreservedGeneratedPath('terraform/main.tf')).toBe(false);
|
|
85
|
+
expect(isPreservedGeneratedPath('ansible/terraform.tfstate')).toBe(false);
|
|
86
|
+
// A nested lookalike must not smuggle a whole tree through.
|
|
87
|
+
expect(isPreservedGeneratedPath('terraform/nested/terraform.tfstate')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Coordinates full module deployment workflow across all phases
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { rmSync } from 'node:fs';
|
|
7
|
+
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { ProgressDisplay } from '@celilo/cli-display';
|
|
10
10
|
import { and, eq } from 'drizzle-orm';
|
|
@@ -337,6 +337,61 @@ async function backfillArrivedProvider(moduleId: string, db: DbClient): Promise<
|
|
|
337
337
|
* alerting, etc. — can react. Bus emit failures are best-effort and
|
|
338
338
|
* never affect the deploy outcome.
|
|
339
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* Files under `generated/terraform/` that outlive the deploy.
|
|
342
|
+
*
|
|
343
|
+
* D4 (control-plane-stops-building-modules) deletes `generated/` because it
|
|
344
|
+
* holds RENDERED templates and secrets — a tree nothing can validate and what a
|
|
345
|
+
* later deploy would wrongly ship. Terraform state is neither. It is the record
|
|
346
|
+
* of what celilo actually BUILT, and `templates/generator.ts` calls it
|
|
347
|
+
* "celilo's authoritative record of placement (ISS-0090)".
|
|
348
|
+
*
|
|
349
|
+
* Deleting it meant the next deploy started from empty state, failed to create
|
|
350
|
+
* a container that already existed, auto-imported it, and planned a REPLACE —
|
|
351
|
+
* destroying the container it was asked to converge (celilo#1374, which
|
|
352
|
+
* destroyed signal's LXC and its signal-cli device identity).
|
|
353
|
+
*
|
|
354
|
+
* `.terraform/` is NOT preserved: it is the provider binary cache, ~19 MB per
|
|
355
|
+
* provider, rebuilt by `terraform init`. `cross-module-read.ts` excludes it from
|
|
356
|
+
* the backup envelope for the same reason.
|
|
357
|
+
*/
|
|
358
|
+
export const PRESERVED_GENERATED_FILES = [
|
|
359
|
+
'terraform.tfstate',
|
|
360
|
+
'terraform.tfstate.backup',
|
|
361
|
+
'.terraform.lock.hcl',
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
/** True for the paths, relative to `generated/`, that survive the cleanup. */
|
|
365
|
+
export function isPreservedGeneratedPath(relativePath: string): boolean {
|
|
366
|
+
const [dir, file, ...rest] = relativePath.split('/');
|
|
367
|
+
if (dir !== 'terraform' || rest.length > 0) return false;
|
|
368
|
+
return file !== undefined && PRESERVED_GENERATED_FILES.includes(file);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Delete the rendered project but keep terraform's record of what exists.
|
|
373
|
+
* Absent `generated/` is not an error — a deploy that failed early never made
|
|
374
|
+
* one.
|
|
375
|
+
*/
|
|
376
|
+
export function pruneGeneratedProject(generatedPath: string): void {
|
|
377
|
+
if (!existsSync(generatedPath)) return;
|
|
378
|
+
|
|
379
|
+
for (const entry of readdirSync(generatedPath, { withFileTypes: true })) {
|
|
380
|
+
if (entry.name !== 'terraform') {
|
|
381
|
+
rmSync(join(generatedPath, entry.name), { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const terraformDir = join(generatedPath, 'terraform');
|
|
386
|
+
if (!existsSync(terraformDir)) return;
|
|
387
|
+
|
|
388
|
+
for (const entry of readdirSync(terraformDir, { withFileTypes: true })) {
|
|
389
|
+
if (!isPreservedGeneratedPath(`terraform/${entry.name}`)) {
|
|
390
|
+
rmSync(join(terraformDir, entry.name), { recursive: true, force: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
340
395
|
export async function deployModule(
|
|
341
396
|
moduleId: string,
|
|
342
397
|
db: DbClient,
|
|
@@ -392,8 +447,10 @@ export async function deployModule(
|
|
|
392
447
|
if (deployed) {
|
|
393
448
|
const generatedPath = `${deployed.sourcePath}/generated`;
|
|
394
449
|
try {
|
|
395
|
-
|
|
396
|
-
log.info(
|
|
450
|
+
pruneGeneratedProject(generatedPath);
|
|
451
|
+
log.info(
|
|
452
|
+
`Removed generated/ for '${moduleId}' (terraform state preserved; --keep retains all)`,
|
|
453
|
+
);
|
|
397
454
|
} catch (cleanupError) {
|
|
398
455
|
const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
399
456
|
log.warn(`Failed to remove generated/ for '${moduleId}': ${msg}`);
|
|
@@ -907,48 +964,10 @@ async function deployModuleImpl(
|
|
|
907
964
|
};
|
|
908
965
|
}
|
|
909
966
|
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const { and: andOp, eq: eqOp } = await import('drizzle-orm');
|
|
915
|
-
for (const [key, value] of Object.entries(hookResult.outputs)) {
|
|
916
|
-
if (typeof value === 'string') {
|
|
917
|
-
const masterKeyForOutputs = await getOrCreateMasterKey();
|
|
918
|
-
const encrypted = encryptSecret(value, masterKeyForOutputs);
|
|
919
|
-
|
|
920
|
-
// Check if secret already exists (no unique constraint on module_id+name)
|
|
921
|
-
const existing = db
|
|
922
|
-
.select()
|
|
923
|
-
.from(secretsTable)
|
|
924
|
-
.where(andOp(eqOp(secretsTable.moduleId, moduleId), eqOp(secretsTable.name, key)))
|
|
925
|
-
.get();
|
|
926
|
-
|
|
927
|
-
if (existing) {
|
|
928
|
-
db.update(secretsTable)
|
|
929
|
-
.set({
|
|
930
|
-
encryptedValue: encrypted.encryptedValue,
|
|
931
|
-
iv: encrypted.iv,
|
|
932
|
-
authTag: encrypted.authTag,
|
|
933
|
-
updatedAt: new Date(),
|
|
934
|
-
})
|
|
935
|
-
.where(eqOp(secretsTable.id, existing.id))
|
|
936
|
-
.run();
|
|
937
|
-
} else {
|
|
938
|
-
db.insert(secretsTable)
|
|
939
|
-
.values({
|
|
940
|
-
moduleId,
|
|
941
|
-
name: key,
|
|
942
|
-
encryptedValue: encrypted.encryptedValue,
|
|
943
|
-
iv: encrypted.iv,
|
|
944
|
-
authTag: encrypted.authTag,
|
|
945
|
-
})
|
|
946
|
-
.run();
|
|
947
|
-
}
|
|
948
|
-
log.success(`Stored hook output: ${key}`);
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
967
|
+
// Hook return values are no longer persisted (hook-owned-state D5):
|
|
968
|
+
// a hook that needs to persist state does so explicitly through the
|
|
969
|
+
// `context.config` / `context.secrets` accessors inside its own
|
|
970
|
+
// process. Nothing here sweeps `hookResult.outputs` into secrets.
|
|
952
971
|
|
|
953
972
|
gauge.stop(true);
|
|
954
973
|
} catch (error) {
|
|
@@ -1352,45 +1371,11 @@ async function deployModuleImpl(
|
|
|
1352
1371
|
};
|
|
1353
1372
|
}
|
|
1354
1373
|
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
if (typeof value === 'string') {
|
|
1361
|
-
const mk = await getOrCreateMasterKey();
|
|
1362
|
-
const encrypted = encryptSecret(value, mk);
|
|
1363
|
-
const existing = db
|
|
1364
|
-
.select()
|
|
1365
|
-
.from(st)
|
|
1366
|
-
.where(and(eq(st.moduleId, capRecord.moduleId), eq(st.name, key)))
|
|
1367
|
-
.get();
|
|
1368
|
-
|
|
1369
|
-
if (existing) {
|
|
1370
|
-
db.update(st)
|
|
1371
|
-
.set({
|
|
1372
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1373
|
-
iv: encrypted.iv,
|
|
1374
|
-
authTag: encrypted.authTag,
|
|
1375
|
-
updatedAt: new Date(),
|
|
1376
|
-
})
|
|
1377
|
-
.where(eq(st.id, existing.id))
|
|
1378
|
-
.run();
|
|
1379
|
-
} else {
|
|
1380
|
-
db.insert(st)
|
|
1381
|
-
.values({
|
|
1382
|
-
moduleId: capRecord.moduleId,
|
|
1383
|
-
name: key,
|
|
1384
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1385
|
-
iv: encrypted.iv,
|
|
1386
|
-
authTag: encrypted.authTag,
|
|
1387
|
-
})
|
|
1388
|
-
.run();
|
|
1389
|
-
}
|
|
1390
|
-
log.success(`Stored ${capRecord.moduleId} hook output: ${key}`);
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1374
|
+
// The return-value channel is gone (hook-owned-state D5/D3): hook
|
|
1375
|
+
// outputs are no longer persisted anywhere. A provider secret the
|
|
1376
|
+
// framework itself must record goes through the named
|
|
1377
|
+
// `recordProviderSecret` path (secrets/storage.ts) — never through a
|
|
1378
|
+
// hook return value.
|
|
1394
1379
|
|
|
1395
1380
|
gauge.stop(true);
|
|
1396
1381
|
}
|
|
@@ -1678,47 +1663,9 @@ async function deployModuleImpl(
|
|
|
1678
1663
|
};
|
|
1679
1664
|
}
|
|
1680
1665
|
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
// public_web.register_route can point a new hostname's public A record
|
|
1685
|
-
// at it (M1 / #328).
|
|
1686
|
-
if (Object.keys(installResult.outputs).length > 0) {
|
|
1687
|
-
const { secrets: st } = await import('../db/schema');
|
|
1688
|
-
const { encryptSecret } = await import('../secrets/encryption');
|
|
1689
|
-
const mk = await getOrCreateMasterKey();
|
|
1690
|
-
for (const [key, value] of Object.entries(installResult.outputs)) {
|
|
1691
|
-
if (typeof value !== 'string') continue;
|
|
1692
|
-
const encrypted = encryptSecret(value, mk);
|
|
1693
|
-
const existing = db
|
|
1694
|
-
.select()
|
|
1695
|
-
.from(st)
|
|
1696
|
-
.where(and(eq(st.moduleId, moduleId), eq(st.name, key)))
|
|
1697
|
-
.get();
|
|
1698
|
-
if (existing) {
|
|
1699
|
-
db.update(st)
|
|
1700
|
-
.set({
|
|
1701
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1702
|
-
iv: encrypted.iv,
|
|
1703
|
-
authTag: encrypted.authTag,
|
|
1704
|
-
updatedAt: new Date(),
|
|
1705
|
-
})
|
|
1706
|
-
.where(eq(st.id, existing.id))
|
|
1707
|
-
.run();
|
|
1708
|
-
} else {
|
|
1709
|
-
db.insert(st)
|
|
1710
|
-
.values({
|
|
1711
|
-
moduleId,
|
|
1712
|
-
name: key,
|
|
1713
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1714
|
-
iv: encrypted.iv,
|
|
1715
|
-
authTag: encrypted.authTag,
|
|
1716
|
-
})
|
|
1717
|
-
.run();
|
|
1718
|
-
}
|
|
1719
|
-
log.success(`Stored hook output: ${key}`);
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1666
|
+
// Hook return values are no longer persisted (hook-owned-state D5).
|
|
1667
|
+
// caddy's `public_ip` moved to `context.config.set` + a config read
|
|
1668
|
+
// (task 5.1); no other hook may rely on its return reaching the store.
|
|
1722
1669
|
}
|
|
1723
1670
|
|
|
1724
1671
|
// Transition to INSTALLED; see the first transition for errorMessage (celilo#1363).
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* committed types file is not regenerated, this test fails.
|
|
9
9
|
*
|
|
10
10
|
* Scope: `modules/<id>/manifest.yml` at the repo root. Archived
|
|
11
|
-
* modules (`modules/
|
|
11
|
+
* modules (`modules/__archive__/**`) and scratch extract dirs
|
|
12
12
|
* (`modules/.tmp-*`) are excluded — they are not active modules.
|
|
13
13
|
*/
|
|
14
14
|
|
|
@@ -14,11 +14,15 @@ describe('validateCapabilityVersions', () => {
|
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
test('no errors when provides matches the runtime registry exactly', () => {
|
|
17
|
+
// Read from the registry, not pinned to a literal (see the comment on the
|
|
18
|
+
// "differs from runtime" test below — the registry moves, the test is
|
|
19
|
+
// about matching it).
|
|
20
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS.public_web;
|
|
17
21
|
expect(
|
|
18
22
|
validateCapabilityVersions({
|
|
19
23
|
id: 'caddy',
|
|
20
24
|
version: '3.0.0',
|
|
21
|
-
provides: { capabilities: [{ name: 'public_web', version:
|
|
25
|
+
provides: { capabilities: [{ name: 'public_web', version: runtime }] },
|
|
22
26
|
}),
|
|
23
27
|
).toEqual([]);
|
|
24
28
|
});
|
|
@@ -40,10 +44,17 @@ describe('validateCapabilityVersions', () => {
|
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
test('error when provides[X].version is newer than runtime', () => {
|
|
47
|
+
// One minor above the registry is newer without being a different major,
|
|
48
|
+
// which is what this case tests. Computed from the registry so the test
|
|
49
|
+
// stays a behaviour test as the registry moves.
|
|
50
|
+
const [maj, min, pat] = CAPABILITY_CONTRACT_VERSIONS.public_web
|
|
51
|
+
.split('.')
|
|
52
|
+
.map((n) => Number.parseInt(n, 10));
|
|
53
|
+
const tooNew = `${maj}.${(min ?? 0) + 1}.${pat ?? 0}`;
|
|
43
54
|
const errors = validateCapabilityVersions({
|
|
44
55
|
id: 'caddy',
|
|
45
56
|
version: '4.0.0',
|
|
46
|
-
provides: { capabilities: [{ name: 'public_web', version:
|
|
57
|
+
provides: { capabilities: [{ name: 'public_web', version: tooNew }] },
|
|
47
58
|
});
|
|
48
59
|
expect(errors).toHaveLength(1);
|
|
49
60
|
expect(errors[0]).toContain('provides[public_web]');
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { planAfterImportVerdict, validateTerraformPlanSafety } from './terraform-safety';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The plan terraform actually produced on celilo-mgr at 2026-09-16T00:04Z,
|
|
6
|
+
* after `module deploy signal` imported LXC 207 and re-planned. Abridged to the
|
|
7
|
+
* lines the parser reads, and kept verbatim otherwise: a fixture written from
|
|
8
|
+
* memory of the format would pass while the product still destroyed the box.
|
|
9
|
+
*/
|
|
10
|
+
const POST_IMPORT_REPLACEMENT_PLAN = `
|
|
11
|
+
proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
|
|
12
|
+
|
|
13
|
+
Terraform used the selected providers to generate the following execution
|
|
14
|
+
plan. Resource actions are indicated with the following symbols:
|
|
15
|
+
-/+ destroy and then create replacement
|
|
16
|
+
|
|
17
|
+
Terraform will perform the following actions:
|
|
18
|
+
|
|
19
|
+
# proxmox_lxc.signal must be replaced
|
|
20
|
+
-/+ resource "proxmox_lxc" "signal" {
|
|
21
|
+
+ ostemplate = "datacenter:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst" # forces replacement
|
|
22
|
+
~ rootfs {
|
|
23
|
+
+ storage = "datacenter" # forces replacement
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Plan: 1 to add, 0 to change, 1 to destroy.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const CLEAN_PLAN = `
|
|
31
|
+
proxmox_lxc.signal: Refreshing state... [id=node3/lxc/207]
|
|
32
|
+
|
|
33
|
+
No changes. Your infrastructure matches the configuration.
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
const IN_PLACE_UPDATE_PLAN = `
|
|
37
|
+
# proxmox_lxc.signal will be updated in-place
|
|
38
|
+
~ resource "proxmox_lxc" "signal" {
|
|
39
|
+
~ memory = 1024 -> 2048
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Plan: 0 to add, 1 to change, 0 to destroy.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
describe('planAfterImportVerdict', () => {
|
|
46
|
+
// THE recurrence gate for celilo#1374. This exact plan was applied without
|
|
47
|
+
// validation and destroyed signal's container on a converged fleet. If this
|
|
48
|
+
// test ever passes the plan through, the defect is back.
|
|
49
|
+
test('refuses the replacement plan an import produces', () => {
|
|
50
|
+
const verdict = planAfterImportVerdict(2, POST_IMPORT_REPLACEMENT_PLAN);
|
|
51
|
+
|
|
52
|
+
expect(verdict.action).toBe('refuse');
|
|
53
|
+
if (verdict.action !== 'refuse') throw new Error('unreachable');
|
|
54
|
+
expect(verdict.error).toContain('after importing an existing resource');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The outcome the recovery path is FOR: the import reconciled state with
|
|
58
|
+
// reality and there is nothing left to apply. Adopt, don't apply.
|
|
59
|
+
test('applies nothing when the post-import plan is clean', () => {
|
|
60
|
+
expect(planAfterImportVerdict(0, CLEAN_PLAN)).toEqual({ action: 'done' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// A resize must still go through — refusing everything would be a different
|
|
64
|
+
// bug wearing the same fix (terraform-safety.ts rule 2, ISS-0055).
|
|
65
|
+
test('lets an in-place update through', () => {
|
|
66
|
+
expect(planAfterImportVerdict(2, IN_PLACE_UPDATE_PLAN)).toEqual({ action: 'apply' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// An unreadable plan is not evidence of safety (D7).
|
|
70
|
+
test('refuses when the plan itself failed', () => {
|
|
71
|
+
const verdict = planAfterImportVerdict(1, 'Error: failed to refresh state');
|
|
72
|
+
|
|
73
|
+
expect(verdict.action).toBe('refuse');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('validateTerraformPlanSafety', () => {
|
|
78
|
+
// Characterises what was already true: the validator would have caught this.
|
|
79
|
+
// The defect was never a missing rule, it was a plan that never reached one.
|
|
80
|
+
test('already refuses a replacement — it was simply never asked', () => {
|
|
81
|
+
expect(validateTerraformPlanSafety(POST_IMPORT_REPLACEMENT_PLAN).safe).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -178,3 +178,56 @@ function formatUnsafeOperationError(action: TerraformAction): string {
|
|
|
178
178
|
|
|
179
179
|
return lines.join('\n');
|
|
180
180
|
}
|
|
181
|
+
|
|
182
|
+
/** What the deploy should do with the plan computed AFTER an auto-import. */
|
|
183
|
+
export type ImportRetryVerdict =
|
|
184
|
+
| { action: 'done' }
|
|
185
|
+
| { action: 'apply' }
|
|
186
|
+
| { action: 'refuse'; error: string };
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Decide whether the apply retried after an auto-import may proceed.
|
|
190
|
+
*
|
|
191
|
+
* An import REPLACES the plan that `deployTerraform` validated before its first
|
|
192
|
+
* apply. That plan described a create; the plan computed after the import can
|
|
193
|
+
* describe a REPLACE, because `ostemplate` and `rootfs[0].storage` do not
|
|
194
|
+
* round-trip through an import and each forces one. Nothing re-validated it, so
|
|
195
|
+
* the retry applied a destroy that `validateTerraformPlanSafety` exists to
|
|
196
|
+
* forbid — and a redeploy destroyed signal's LXC on a converged fleet
|
|
197
|
+
* (celilo#1374). The gate was present; the import walked around it.
|
|
198
|
+
*
|
|
199
|
+
* Pure (Rule 10) so the refusal is testable without terraform: the caller does
|
|
200
|
+
* the I/O and hands the exit code and output here.
|
|
201
|
+
*/
|
|
202
|
+
export function planAfterImportVerdict(exitCode: number, planOutput: string): ImportRetryVerdict {
|
|
203
|
+
// 0 = no changes. The import alone reconciled state with reality, which is
|
|
204
|
+
// the outcome we want: adopt the resource and apply nothing.
|
|
205
|
+
if (exitCode === 0) {
|
|
206
|
+
return { action: 'done' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 2 = changes pending. Anything else is an error, and an unreadable plan is
|
|
210
|
+
// never a licence to apply (D7: no measurement is not "no drift").
|
|
211
|
+
if (exitCode !== 2) {
|
|
212
|
+
return {
|
|
213
|
+
action: 'refuse',
|
|
214
|
+
error: `Refusing to apply after import: terraform plan failed (exit ${exitCode}).\n${planOutput.slice(0, 500)}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const safety = validateTerraformPlanSafety(planOutput);
|
|
219
|
+
if (!safety.safe) {
|
|
220
|
+
return {
|
|
221
|
+
action: 'refuse',
|
|
222
|
+
error: [
|
|
223
|
+
'Refusing to apply the plan computed after importing an existing resource.',
|
|
224
|
+
'The import adopted infrastructure that already exists, and applying this',
|
|
225
|
+
'plan would destroy it rather than adopt it.',
|
|
226
|
+
'',
|
|
227
|
+
safety.error ?? 'unsafe plan',
|
|
228
|
+
].join('\n'),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { action: 'apply' };
|
|
233
|
+
}
|
|
@@ -90,7 +90,7 @@ const cleanAudit: AuditDeps = {
|
|
|
90
90
|
machinesReachable: { results: [] },
|
|
91
91
|
diskSpace: { results: [] },
|
|
92
92
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
93
|
-
trustedSources: { firewalls: [] },
|
|
93
|
+
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
94
94
|
publicDns: { records: [], probe: unusedPublicDnsProbe },
|
|
95
95
|
};
|
|
96
96
|
|
package/tsconfig.json
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
2
3
|
"compilerOptions": {
|
|
3
|
-
"target": "ESNext",
|
|
4
|
-
"module": "ESNext",
|
|
5
4
|
"lib": ["ESNext"],
|
|
6
|
-
"moduleResolution": "bundler",
|
|
7
5
|
"jsx": "react-jsx",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"forceConsistentCasingInFileNames": true,
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"types": ["bun-types"],
|
|
15
6
|
"paths": {
|
|
16
7
|
"@/*": ["./src/*"]
|
|
17
8
|
}
|
|
18
|
-
}
|
|
19
|
-
"include": ["src/**/*", "test-integration/**/*"],
|
|
20
|
-
"exclude": ["node_modules"]
|
|
9
|
+
}
|
|
21
10
|
}
|