@celilo/cli 2.2.1 → 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 +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- 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 +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- 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 +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- 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 +32 -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/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -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/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- 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 +143 -17
- 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/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -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 +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -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 +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -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) {
|
|
@@ -1048,8 +1067,13 @@ async function deployModuleImpl(
|
|
|
1048
1067
|
}
|
|
1049
1068
|
}
|
|
1050
1069
|
|
|
1051
|
-
// Transition to INSTALLED
|
|
1052
|
-
|
|
1070
|
+
// Transition to INSTALLED. A successful deploy ends any recorded
|
|
1071
|
+
// failure: leaving a stale errorMessage would print `Error:` beside a
|
|
1072
|
+
// healthy state in `module list` / `module status` (celilo#1363).
|
|
1073
|
+
db.update(modules)
|
|
1074
|
+
.set({ state: 'INSTALLED', errorMessage: null })
|
|
1075
|
+
.where(eq(modules.id, moduleId))
|
|
1076
|
+
.run();
|
|
1053
1077
|
|
|
1054
1078
|
// Run health checks for config-only modules too
|
|
1055
1079
|
if (manifest.hooks?.health_check) {
|
|
@@ -1347,45 +1371,11 @@ async function deployModuleImpl(
|
|
|
1347
1371
|
};
|
|
1348
1372
|
}
|
|
1349
1373
|
|
|
1350
|
-
//
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
if (typeof value === 'string') {
|
|
1356
|
-
const mk = await getOrCreateMasterKey();
|
|
1357
|
-
const encrypted = encryptSecret(value, mk);
|
|
1358
|
-
const existing = db
|
|
1359
|
-
.select()
|
|
1360
|
-
.from(st)
|
|
1361
|
-
.where(and(eq(st.moduleId, capRecord.moduleId), eq(st.name, key)))
|
|
1362
|
-
.get();
|
|
1363
|
-
|
|
1364
|
-
if (existing) {
|
|
1365
|
-
db.update(st)
|
|
1366
|
-
.set({
|
|
1367
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1368
|
-
iv: encrypted.iv,
|
|
1369
|
-
authTag: encrypted.authTag,
|
|
1370
|
-
updatedAt: new Date(),
|
|
1371
|
-
})
|
|
1372
|
-
.where(eq(st.id, existing.id))
|
|
1373
|
-
.run();
|
|
1374
|
-
} else {
|
|
1375
|
-
db.insert(st)
|
|
1376
|
-
.values({
|
|
1377
|
-
moduleId: capRecord.moduleId,
|
|
1378
|
-
name: key,
|
|
1379
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1380
|
-
iv: encrypted.iv,
|
|
1381
|
-
authTag: encrypted.authTag,
|
|
1382
|
-
})
|
|
1383
|
-
.run();
|
|
1384
|
-
}
|
|
1385
|
-
log.success(`Stored ${capRecord.moduleId} hook output: ${key}`);
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
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.
|
|
1389
1379
|
|
|
1390
1380
|
gauge.stop(true);
|
|
1391
1381
|
}
|
|
@@ -1673,51 +1663,16 @@ async function deployModuleImpl(
|
|
|
1673
1663
|
};
|
|
1674
1664
|
}
|
|
1675
1665
|
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
//
|
|
1679
|
-
// public_web.register_route can point a new hostname's public A record
|
|
1680
|
-
// at it (M1 / #328).
|
|
1681
|
-
if (Object.keys(installResult.outputs).length > 0) {
|
|
1682
|
-
const { secrets: st } = await import('../db/schema');
|
|
1683
|
-
const { encryptSecret } = await import('../secrets/encryption');
|
|
1684
|
-
const mk = await getOrCreateMasterKey();
|
|
1685
|
-
for (const [key, value] of Object.entries(installResult.outputs)) {
|
|
1686
|
-
if (typeof value !== 'string') continue;
|
|
1687
|
-
const encrypted = encryptSecret(value, mk);
|
|
1688
|
-
const existing = db
|
|
1689
|
-
.select()
|
|
1690
|
-
.from(st)
|
|
1691
|
-
.where(and(eq(st.moduleId, moduleId), eq(st.name, key)))
|
|
1692
|
-
.get();
|
|
1693
|
-
if (existing) {
|
|
1694
|
-
db.update(st)
|
|
1695
|
-
.set({
|
|
1696
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1697
|
-
iv: encrypted.iv,
|
|
1698
|
-
authTag: encrypted.authTag,
|
|
1699
|
-
updatedAt: new Date(),
|
|
1700
|
-
})
|
|
1701
|
-
.where(eq(st.id, existing.id))
|
|
1702
|
-
.run();
|
|
1703
|
-
} else {
|
|
1704
|
-
db.insert(st)
|
|
1705
|
-
.values({
|
|
1706
|
-
moduleId,
|
|
1707
|
-
name: key,
|
|
1708
|
-
encryptedValue: encrypted.encryptedValue,
|
|
1709
|
-
iv: encrypted.iv,
|
|
1710
|
-
authTag: encrypted.authTag,
|
|
1711
|
-
})
|
|
1712
|
-
.run();
|
|
1713
|
-
}
|
|
1714
|
-
log.success(`Stored hook output: ${key}`);
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
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.
|
|
1717
1669
|
}
|
|
1718
1670
|
|
|
1719
|
-
// Transition to INSTALLED
|
|
1720
|
-
db.update(modules)
|
|
1671
|
+
// Transition to INSTALLED; see the first transition for errorMessage (celilo#1363).
|
|
1672
|
+
db.update(modules)
|
|
1673
|
+
.set({ state: 'INSTALLED', errorMessage: null })
|
|
1674
|
+
.where(eq(modules.id, moduleId))
|
|
1675
|
+
.run();
|
|
1721
1676
|
|
|
1722
1677
|
// Run health checks after successful deployment
|
|
1723
1678
|
if (manifest.hooks?.health_check) {
|
|
@@ -155,7 +155,7 @@ describe('register / unregister roundtrip', () => {
|
|
|
155
155
|
process.env.EVENT_BUS_DB = dbPath;
|
|
156
156
|
});
|
|
157
157
|
afterEach(() => {
|
|
158
|
-
process.env.EVENT_BUS_DB
|
|
158
|
+
delete process.env.EVENT_BUS_DB;
|
|
159
159
|
try {
|
|
160
160
|
rmSync(dir, { recursive: true, force: true });
|
|
161
161
|
} catch {
|
|
@@ -343,7 +343,7 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
|
|
|
343
343
|
afterEach(() => {
|
|
344
344
|
closeDb();
|
|
345
345
|
resetTestDbPath();
|
|
346
|
-
process.env.EVENT_BUS_DB
|
|
346
|
+
delete process.env.EVENT_BUS_DB;
|
|
347
347
|
try {
|
|
348
348
|
rmSync(dir, { recursive: true, force: true });
|
|
349
349
|
} catch {
|
|
@@ -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]');
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
|
16
16
|
import { eq } from 'drizzle-orm';
|
|
17
17
|
import type { DbClient } from '../db/client';
|
|
18
|
-
import { systemConfig } from '../db/schema';
|
|
18
|
+
import { modules, systemConfig } from '../db/schema';
|
|
19
19
|
import { setupTestDatabase } from '../test-utils/database';
|
|
20
|
+
import { CONTROL_PLANE_MODULE_ID, upsertDeployedSystem } from './deployed-systems';
|
|
20
21
|
import {
|
|
21
22
|
discoverAndRecordNetwork,
|
|
22
23
|
discoveredNetworkKeys,
|
|
@@ -102,6 +103,33 @@ describe('discoveredNetworkKeys (issue #300 redeploy idempotency)', () => {
|
|
|
102
103
|
});
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
test('first install with control plane in secure-mgmt records there (celilo#1356)', () => {
|
|
107
|
+
// The default topology (ce-8mxz) puts celilo-mgr on secure-mgmt, so a fresh
|
|
108
|
+
// segmented bootstrap must not file the control-plane subnet under
|
|
109
|
+
// `network.internal.*` — celilo would otherwise be blind to its own
|
|
110
|
+
// internal zone.
|
|
111
|
+
expect(discoveredNetworkKeys(internal, false, { controlPlaneZone: 'secure-mgmt' })).toEqual({
|
|
112
|
+
'network.secure-mgmt.subnet': '192.168.1.0/24',
|
|
113
|
+
'network.secure-mgmt.gateway': '192.168.1.1',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('first install with control plane in internal is unchanged', () => {
|
|
118
|
+
expect(discoveredNetworkKeys(internal, false, { controlPlaneZone: 'internal' })).toEqual({
|
|
119
|
+
'network.internal.subnet': '192.168.1.0/24',
|
|
120
|
+
'network.internal.gateway': '192.168.1.1',
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('first install never clobbers an already-set control-plane zone value', () => {
|
|
125
|
+
expect(
|
|
126
|
+
discoveredNetworkKeys(internal, false, {
|
|
127
|
+
controlPlaneZone: 'secure-mgmt',
|
|
128
|
+
secureMgmtAlreadySet: true,
|
|
129
|
+
}),
|
|
130
|
+
).toEqual({});
|
|
131
|
+
});
|
|
132
|
+
|
|
105
133
|
test('preserves an already-set subnet on redeploy (no override)', () => {
|
|
106
134
|
// The regression: a redeploy on a box off the internal network discovers the
|
|
107
135
|
// WRONG subnet — it must not clobber the operator's value.
|
|
@@ -165,6 +193,41 @@ describe('discoverAndRecordNetwork', () => {
|
|
|
165
193
|
expect(read('network.internal.gateway')).toBe('10.31.7.1');
|
|
166
194
|
});
|
|
167
195
|
|
|
196
|
+
test('first install for a control plane deployed in secure-mgmt records secure-mgmt (celilo#1356)', () => {
|
|
197
|
+
// Seed the module_systems row the deploy pipeline writes before
|
|
198
|
+
// bootstrapControlPlane runs, placing celilo-mgmt in secure-mgmt.
|
|
199
|
+
db.insert(modules)
|
|
200
|
+
.values({
|
|
201
|
+
id: CONTROL_PLANE_MODULE_ID,
|
|
202
|
+
name: 'Celilo Management Server',
|
|
203
|
+
version: '0.9.0',
|
|
204
|
+
manifestData: {},
|
|
205
|
+
sourcePath: '/tmp/modules/celilo-mgmt',
|
|
206
|
+
})
|
|
207
|
+
.run();
|
|
208
|
+
upsertDeployedSystem(db, CONTROL_PLANE_MODULE_ID, {
|
|
209
|
+
name: 'main',
|
|
210
|
+
hostname: 'celilo-mgr',
|
|
211
|
+
ipv4Address: '10.226.120.10',
|
|
212
|
+
zone: 'secure-mgmt',
|
|
213
|
+
infraType: 'machine',
|
|
214
|
+
machineId: null,
|
|
215
|
+
serviceId: null,
|
|
216
|
+
vmid: null,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const result = discoverAndRecordNetwork(db, () =>
|
|
220
|
+
ipRouteFor('10.226.120.0', '10.226.120.1', '10.226.120.10'),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(result.applied).toEqual([
|
|
224
|
+
'network.secure-mgmt.subnet = 10.226.120.0/24',
|
|
225
|
+
'network.secure-mgmt.gateway = 10.226.120.1',
|
|
226
|
+
]);
|
|
227
|
+
expect(read('network.internal.subnet')).toBeUndefined();
|
|
228
|
+
expect(read('network.secure-mgmt.subnet')).toBe('10.226.120.0/24');
|
|
229
|
+
});
|
|
230
|
+
|
|
168
231
|
test('a redeploy on the same box changes nothing and says so', () => {
|
|
169
232
|
const routes = () => ipRouteFor('10.31.7.0', '10.31.7.1', '10.31.7.20');
|
|
170
233
|
discoverAndRecordNetwork(db, routes);
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import { execFileSync } from 'node:child_process';
|
|
25
25
|
import { eq } from 'drizzle-orm';
|
|
26
26
|
import type { DbClient } from '../db/client';
|
|
27
|
-
import { systemConfig } from '../db/schema';
|
|
27
|
+
import { type NetworkZone, systemConfig } from '../db/schema';
|
|
28
|
+
import { CONTROL_PLANE_MODULE_ID, asZone, getModuleSystems } from './deployed-systems';
|
|
28
29
|
|
|
29
30
|
export interface DiscoveredNetwork {
|
|
30
31
|
subnet: string;
|
|
@@ -61,7 +62,14 @@ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork |
|
|
|
61
62
|
* LAN, so what it discovered IS `internal`. First install records it there.
|
|
62
63
|
* - Segmented deployment: the box sits on a dedicated control-plane network.
|
|
63
64
|
* `internal` is then someone else's subnet — the semi-trusted LAN — and the
|
|
64
|
-
* discovered value belongs under `secure-mgmt
|
|
65
|
+
* discovered value belongs under the control plane's own zone (`secure-mgmt`
|
|
66
|
+
* in the default topology, celilo#1356).
|
|
67
|
+
*
|
|
68
|
+
* On FIRST INSTALL the zone is decided the same way the rest of celilo decides
|
|
69
|
+
* it: from where the control plane actually landed. `discoverAndRecordNetwork`
|
|
70
|
+
* reads `celilo-mgmt`'s deployed-system row and passes that zone in as
|
|
71
|
+
* `opts.controlPlaneZone`; with no row recorded yet the fallback stays
|
|
72
|
+
* `internal`, the pre-#1356 behaviour.
|
|
65
73
|
*
|
|
66
74
|
* Issue #300 spotted the second case as "discovery returns the WRONG subnet" and
|
|
67
75
|
* mitigated it by DISCARDING the value whenever `internal` was already set. That
|
|
@@ -76,14 +84,21 @@ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork |
|
|
|
76
84
|
export function discoveredNetworkKeys(
|
|
77
85
|
host: DiscoveredNetwork | null,
|
|
78
86
|
internalAlreadySet: boolean,
|
|
79
|
-
opts: {
|
|
87
|
+
opts: {
|
|
88
|
+
internalSubnet?: string;
|
|
89
|
+
secureMgmtAlreadySet?: boolean;
|
|
90
|
+
/** Zone the control plane itself occupies; first install records there, not `internal`. */
|
|
91
|
+
controlPlaneZone?: NetworkZone;
|
|
92
|
+
} = {},
|
|
80
93
|
): Record<string, string> {
|
|
81
94
|
if (!host) return {};
|
|
82
95
|
|
|
83
96
|
if (!internalAlreadySet) {
|
|
97
|
+
const targetZone = opts.controlPlaneZone ?? 'internal';
|
|
98
|
+
if (targetZone !== 'internal' && opts.secureMgmtAlreadySet) return {};
|
|
84
99
|
return {
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
[`network.${targetZone}.subnet`]: host.subnet,
|
|
101
|
+
[`network.${targetZone}.gateway`]: host.gateway,
|
|
87
102
|
};
|
|
88
103
|
}
|
|
89
104
|
|
|
@@ -118,6 +133,15 @@ function readValue(db: DbClient, key: string): string | undefined {
|
|
|
118
133
|
return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined;
|
|
119
134
|
}
|
|
120
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The zone the control plane actually landed in, from its deployed-system row
|
|
138
|
+
* (same source `loadControlPlaneSubnet` reads). Undefined when celilo-mgmt has
|
|
139
|
+
* no system recorded yet, so the caller falls back to `internal`.
|
|
140
|
+
*/
|
|
141
|
+
function controlPlaneZone(db: DbClient): NetworkZone | undefined {
|
|
142
|
+
return asZone(getModuleSystems(CONTROL_PLANE_MODULE_ID, db)[0]?.zone) ?? undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
/**
|
|
122
146
|
* Discover the network this box sits on and record it. Idempotent, and never
|
|
123
147
|
* overwrites a value that is already set.
|
|
@@ -143,6 +167,7 @@ export function discoverAndRecordNetwork(
|
|
|
143
167
|
const keys = discoveredNetworkKeys(host, internalSubnet !== undefined, {
|
|
144
168
|
internalSubnet,
|
|
145
169
|
secureMgmtAlreadySet: readValue(db, 'network.secure-mgmt.subnet') !== undefined,
|
|
170
|
+
controlPlaneZone: controlPlaneZone(db),
|
|
146
171
|
});
|
|
147
172
|
|
|
148
173
|
const applied: string[] = [];
|
|
@@ -32,7 +32,7 @@ describe('ensureResponderForInterview (ISS-0025)', () => {
|
|
|
32
32
|
origTTY = process.stdin.isTTY;
|
|
33
33
|
});
|
|
34
34
|
afterEach(() => {
|
|
35
|
-
if (origEnv === undefined) process.env.EVENT_BUS_DB
|
|
35
|
+
if (origEnv === undefined) delete process.env.EVENT_BUS_DB;
|
|
36
36
|
else process.env.EVENT_BUS_DB = origEnv;
|
|
37
37
|
setTTY(origTTY);
|
|
38
38
|
try {
|
|
@@ -46,8 +46,8 @@ describe('applyStagedSystemFiles', () => {
|
|
|
46
46
|
afterEach(() => {
|
|
47
47
|
closeDb();
|
|
48
48
|
resetTestDbPath();
|
|
49
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
50
|
-
process.env.CELILO_DATA_DIR
|
|
49
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
50
|
+
delete process.env.CELILO_DATA_DIR;
|
|
51
51
|
try {
|
|
52
52
|
rmSync(dir, { recursive: true, force: true });
|
|
53
53
|
} catch {
|
|
@@ -207,8 +207,8 @@ describe('restoreFromArtifactFile error paths', () => {
|
|
|
207
207
|
afterEach(() => {
|
|
208
208
|
closeDb();
|
|
209
209
|
resetTestDbPath();
|
|
210
|
-
process.env.CELILO_DATA_DIR
|
|
211
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
210
|
+
delete process.env.CELILO_DATA_DIR;
|
|
211
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
212
212
|
try {
|
|
213
213
|
rmSync(dir, { recursive: true, force: true });
|
|
214
214
|
} catch {
|
|
@@ -99,8 +99,8 @@ describe('ssh-key-manager', () => {
|
|
|
99
99
|
|
|
100
100
|
// Clear environment variables
|
|
101
101
|
resetTestDbPath();
|
|
102
|
-
process.env.CELILO_DATA_DIR
|
|
103
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
102
|
+
delete process.env.CELILO_DATA_DIR;
|
|
103
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
describe('writeTemporarySshKey', () => {
|
|
@@ -38,9 +38,9 @@ describe('stageSystemState', () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
afterEach(() => {
|
|
41
|
-
process.env.CELILO_DATA_DIR
|
|
41
|
+
delete process.env.CELILO_DATA_DIR;
|
|
42
42
|
resetTestDbPath();
|
|
43
|
-
process.env.CELILO_MASTER_KEY_PATH
|
|
43
|
+
delete process.env.CELILO_MASTER_KEY_PATH;
|
|
44
44
|
rmSync(dataDir, { recursive: true, force: true });
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -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
|
+
});
|