@celilo/cli 4.0.0 → 5.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/AGENTS.md +2 -2
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +41 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +185 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/api-access.test.ts +44 -0
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/module-configs.test.ts +1 -1
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +119 -6
- package/src/services/celilo-events.ts +156 -16
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +86 -7
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +165 -1
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/context.ts +14 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -3,10 +3,12 @@ import type { DbClient } from '../../db/client';
|
|
|
3
3
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
4
4
|
import type { AuditDeps } from '../audit';
|
|
5
5
|
import { unusedPublicDnsProbe } from '../audit/public-dns';
|
|
6
|
+
import type { DriftFinding } from '../audit/types';
|
|
6
7
|
import { buildModuleGraph } from './dep-graph';
|
|
7
8
|
import {
|
|
8
9
|
type ModuleSnapshot,
|
|
9
10
|
type OrchestratorOps,
|
|
11
|
+
blockersOutOfReach,
|
|
10
12
|
consumerSkipReason,
|
|
11
13
|
runSystemUpdate,
|
|
12
14
|
} from './orchestrator';
|
|
@@ -22,7 +24,7 @@ function makeManifest(
|
|
|
22
24
|
id,
|
|
23
25
|
name: id,
|
|
24
26
|
version: '1.0.0',
|
|
25
|
-
celilo_contract: '
|
|
27
|
+
celilo_contract: '2.0',
|
|
26
28
|
provides: opts.provides
|
|
27
29
|
? {
|
|
28
30
|
capabilities: opts.provides.map((name) => ({
|
|
@@ -73,6 +75,12 @@ const cleanAudit: AuditDeps = {
|
|
|
73
75
|
schema: { journal: () => null, applied: () => [], db: fakeDb },
|
|
74
76
|
capabilityAbi: { modules: [] },
|
|
75
77
|
browserPin: { consumers: [], provisioned: null },
|
|
78
|
+
serverBunPin: {
|
|
79
|
+
pinnedVersion: '1.4.2',
|
|
80
|
+
runningVersion: '1.4.2',
|
|
81
|
+
pinPath: '/etc/celilo/mise.pin.toml',
|
|
82
|
+
},
|
|
83
|
+
rebootPending: { verdict: { pending: false, unmeasured: false, reasons: [] }, marker: null },
|
|
76
84
|
terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
|
|
77
85
|
moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
|
|
78
86
|
moduleConfigs: { modules: [] },
|
|
@@ -88,6 +96,7 @@ const cleanAudit: AuditDeps = {
|
|
|
88
96
|
secretsDecryptable: { results: [] },
|
|
89
97
|
servicesReachable: { results: [] },
|
|
90
98
|
machinesReachable: { results: [] },
|
|
99
|
+
machineInterfaceZones: { machines: [] },
|
|
91
100
|
diskSpace: { results: [] },
|
|
92
101
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
93
102
|
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
@@ -162,6 +171,8 @@ describe('runSystemUpdate', () => {
|
|
|
162
171
|
expect(result.audit.verdict).toBe('BLOCKED');
|
|
163
172
|
expect(result.modules).toEqual([]);
|
|
164
173
|
expect(upgradeCalls).toBe(0);
|
|
174
|
+
// Not `no-network` — the network is fine; the audit is what refused.
|
|
175
|
+
expect(result.selfUpdate).toEqual({ performed: false, reason: 'audit-blocked' });
|
|
165
176
|
});
|
|
166
177
|
|
|
167
178
|
test('happy path: clean audit + one drifting module → backup, upgrade, deploy, health, done', async () => {
|
|
@@ -471,3 +482,156 @@ describe('runSystemUpdate', () => {
|
|
|
471
482
|
expect([...seenIds]).toEqual(['shared-id']);
|
|
472
483
|
});
|
|
473
484
|
});
|
|
485
|
+
|
|
486
|
+
describe('blockersOutOfReach', () => {
|
|
487
|
+
const abiBlocker = (subject: string): DriftFinding => ({
|
|
488
|
+
category: 'capability_abi',
|
|
489
|
+
severity: 'blocked',
|
|
490
|
+
code: 'capability_abi_provider_mismatch',
|
|
491
|
+
message: `${subject} provides public_web@3.1.0 but framework runtime expects 4.0.0`,
|
|
492
|
+
subject,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('an unscoped run cannot reach any blocker', () => {
|
|
496
|
+
expect(blockersOutOfReach([abiBlocker('caddy')], undefined)).toHaveLength(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test('--module X reaches a blocker about X', () => {
|
|
500
|
+
expect(blockersOutOfReach([abiBlocker('caddy')], 'caddy')).toEqual([]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('--module Y does not reach a blocker about X', () => {
|
|
504
|
+
const out = blockersOutOfReach([abiBlocker('caddy')], 'celilo-mgmt');
|
|
505
|
+
expect(out.map((f) => f.subject)).toEqual(['caddy']);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('--module X never reaches a system-wide blocker', () => {
|
|
509
|
+
const systemBlocker: DriftFinding = {
|
|
510
|
+
category: 'schema',
|
|
511
|
+
severity: 'blocked',
|
|
512
|
+
code: 'schema_pending_migrations',
|
|
513
|
+
message: '1 pending migration',
|
|
514
|
+
subject: 'system',
|
|
515
|
+
};
|
|
516
|
+
const out = blockersOutOfReach([abiBlocker('caddy'), systemBlocker], 'caddy');
|
|
517
|
+
expect(out.map((f) => f.subject)).toEqual(['system']);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('non-blocking findings are never returned', () => {
|
|
521
|
+
const drift: DriftFinding = {
|
|
522
|
+
category: 'module_versions',
|
|
523
|
+
severity: 'drift',
|
|
524
|
+
code: 'module_version_drift',
|
|
525
|
+
message: 'caddy 1.0.0 → 1.1.0',
|
|
526
|
+
subject: 'caddy',
|
|
527
|
+
};
|
|
528
|
+
expect(blockersOutOfReach([drift], undefined)).toEqual([]);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('runSystemUpdate with a module-scoped BLOCKED audit', () => {
|
|
533
|
+
// caddy is deployed and provides public_web@3.1.0 while the runtime
|
|
534
|
+
// contract is at 4.0.0 — the celilo#1381 shape. Upgrading caddy is
|
|
535
|
+
// exactly what the blocking finding asks for.
|
|
536
|
+
const abiBlockedAudit: AuditDeps = {
|
|
537
|
+
...cleanAudit,
|
|
538
|
+
capabilityAbi: {
|
|
539
|
+
modules: [
|
|
540
|
+
{
|
|
541
|
+
id: 'caddy',
|
|
542
|
+
state: 'VERIFIED',
|
|
543
|
+
manifest: makeManifest('caddy', { provides: ['public_web'] }),
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
contractVersions: { public_web: '2.0.0' },
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
function run(onlyModule: string | undefined, ops: OrchestratorOps) {
|
|
551
|
+
return runSystemUpdate({
|
|
552
|
+
audit: abiBlockedAudit,
|
|
553
|
+
graph: buildModuleGraph([
|
|
554
|
+
makeManifest('caddy', { provides: ['public_web'] }),
|
|
555
|
+
makeManifest('celilo-mgmt'),
|
|
556
|
+
]),
|
|
557
|
+
snapshots: new Map([
|
|
558
|
+
['caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0' })],
|
|
559
|
+
['celilo-mgmt', makeSnapshot('celilo-mgmt', { installed: '1.0.0', latest: '1.1.0' })],
|
|
560
|
+
]),
|
|
561
|
+
ops,
|
|
562
|
+
selfUpdate: {
|
|
563
|
+
installedVersion: '0.1.5',
|
|
564
|
+
fetcher: async () => '0.1.5',
|
|
565
|
+
updater: async () => ({ ok: true, stderr: '' }),
|
|
566
|
+
},
|
|
567
|
+
progress: capturingProgress(),
|
|
568
|
+
now: () => new Date('2026-04-25T00:00:00Z'),
|
|
569
|
+
idGen: () => 'fixed-id',
|
|
570
|
+
onlyModule,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
test('--module caddy upgrades caddy, with backup and health still run', async () => {
|
|
575
|
+
const calls: string[] = [];
|
|
576
|
+
const ops = makeOps({
|
|
577
|
+
backup: async (id) => {
|
|
578
|
+
calls.push(`backup:${id}`);
|
|
579
|
+
return { ok: true };
|
|
580
|
+
},
|
|
581
|
+
upgrade: async (id) => {
|
|
582
|
+
calls.push(`upgrade:${id}`);
|
|
583
|
+
return { ok: true };
|
|
584
|
+
},
|
|
585
|
+
deploy: async (id) => {
|
|
586
|
+
calls.push(`deploy:${id}`);
|
|
587
|
+
return { ok: true };
|
|
588
|
+
},
|
|
589
|
+
health: async (id) => {
|
|
590
|
+
calls.push(`health:${id}`);
|
|
591
|
+
return { status: 'healthy' };
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const result = await run('caddy', ops);
|
|
596
|
+
|
|
597
|
+
expect(result.audit.verdict).toBe('BLOCKED');
|
|
598
|
+
expect(result.modules.map((m) => m.moduleId)).toEqual(['caddy']);
|
|
599
|
+
expect(result.modules[0]?.step).toBe('done');
|
|
600
|
+
expect(calls).toEqual(['backup:caddy', 'upgrade:caddy', 'deploy:caddy', 'health:caddy']);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('--module celilo-mgmt refuses, naming the caddy finding, and never says no-network', async () => {
|
|
604
|
+
let upgrades = 0;
|
|
605
|
+
const ops = makeOps({
|
|
606
|
+
upgrade: async () => {
|
|
607
|
+
upgrades++;
|
|
608
|
+
return { ok: true };
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const result = await run('celilo-mgmt', ops);
|
|
613
|
+
|
|
614
|
+
expect(result.ok).toBe(false);
|
|
615
|
+
expect(result.modules).toEqual([]);
|
|
616
|
+
expect(upgrades).toBe(0);
|
|
617
|
+
expect(result.selfUpdate).toEqual({ performed: false, reason: 'audit-blocked' });
|
|
618
|
+
const blocked = result.audit.findings.filter((f) => f.severity === 'blocked');
|
|
619
|
+
expect(blocked.map((f) => f.subject)).toEqual(['caddy']);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('an unscoped run still refuses', async () => {
|
|
623
|
+
let upgrades = 0;
|
|
624
|
+
const ops = makeOps({
|
|
625
|
+
upgrade: async () => {
|
|
626
|
+
upgrades++;
|
|
627
|
+
return { ok: true };
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const result = await run(undefined, ops);
|
|
632
|
+
|
|
633
|
+
expect(result.ok).toBe(false);
|
|
634
|
+
expect(result.modules).toEqual([]);
|
|
635
|
+
expect(upgrades).toBe(0);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { runAudit } from '../audit';
|
|
22
22
|
import type { AuditDeps, SystemAuditReport } from '../audit';
|
|
23
|
+
import type { DriftFinding } from '../audit/types';
|
|
23
24
|
import {
|
|
24
25
|
type ModuleGraph,
|
|
25
26
|
type ModuleId,
|
|
@@ -133,6 +134,29 @@ export function consumerSkipReason(
|
|
|
133
134
|
return null; // compatible; consumer can proceed against still-installed provider
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The blocking findings a run cannot clear by doing its own work.
|
|
139
|
+
*
|
|
140
|
+
* `system update` used to refuse on ANY blocked finding, which made the
|
|
141
|
+
* command unable to fix the condition that stopped it: when a provider is
|
|
142
|
+
* behind a capability major, upgrading that provider is the remediation,
|
|
143
|
+
* and `--module <provider>` was refused too (celilo#1381).
|
|
144
|
+
*
|
|
145
|
+
* A finding is within reach only when the run is scoped to one module
|
|
146
|
+
* (`--module`) and the finding is about that same module — that module's
|
|
147
|
+
* upgrade is what the finding asks for. Findings about `system` or about
|
|
148
|
+
* any other module are never within reach: nothing this run does touches
|
|
149
|
+
* them. An unscoped run therefore still refuses on every blocker, as before.
|
|
150
|
+
*/
|
|
151
|
+
export function blockersOutOfReach(
|
|
152
|
+
findings: DriftFinding[],
|
|
153
|
+
onlyModule: string | undefined,
|
|
154
|
+
): DriftFinding[] {
|
|
155
|
+
const blocked = findings.filter((f) => f.severity === 'blocked');
|
|
156
|
+
if (!onlyModule) return blocked;
|
|
157
|
+
return blocked.filter((f) => f.subject !== onlyModule);
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
/**
|
|
137
161
|
* Drive a single module through the state machine. Stops at the first
|
|
138
162
|
* failed step.
|
|
@@ -218,20 +242,27 @@ export async function runSystemUpdate(deps: RunUpdateDeps): Promise<SystemUpdate
|
|
|
218
242
|
|
|
219
243
|
deps.progress.emit({ kind: 'plan', modules: deps.snapshots.size });
|
|
220
244
|
|
|
221
|
-
// Audit first; bail if BLOCKED.
|
|
245
|
+
// Audit first; bail if BLOCKED and the selected module can't clear it.
|
|
222
246
|
const audit: SystemAuditReport = await runAudit(deps.audit);
|
|
223
247
|
if (audit.verdict === 'BLOCKED') {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
248
|
+
const stillBlocking = blockersOutOfReach(audit.findings, deps.onlyModule);
|
|
249
|
+
if (stillBlocking.length > 0) {
|
|
250
|
+
deps.progress.emit({
|
|
251
|
+
kind: 'self-update-skipped',
|
|
252
|
+
reason: `audit BLOCKED: ${stillBlocking.map((f) => `${f.subject}: ${f.message}`).join('; ')}`,
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
version: 1,
|
|
256
|
+
updateId,
|
|
257
|
+
startedAt,
|
|
258
|
+
finishedAt: now().toISOString(),
|
|
259
|
+
audit,
|
|
260
|
+
selfUpdate: { performed: false, reason: 'audit-blocked' },
|
|
261
|
+
backupsCreated: false,
|
|
262
|
+
modules: [],
|
|
263
|
+
ok: false,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
235
266
|
}
|
|
236
267
|
|
|
237
268
|
// Self-update.
|
|
@@ -43,7 +43,16 @@ export interface ModuleUpdateState {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export type SelfUpdateResult =
|
|
46
|
-
| {
|
|
46
|
+
| {
|
|
47
|
+
performed: false;
|
|
48
|
+
/**
|
|
49
|
+
* `audit-blocked` means the run refused before the self-update was
|
|
50
|
+
* attempted. It exists because that refusal used to report
|
|
51
|
+
* `no-network` (celilo#1381), sending operators to debug a network
|
|
52
|
+
* that was fine.
|
|
53
|
+
*/
|
|
54
|
+
reason: 'already-current' | 'dev-mode' | 'no-network' | 'audit-blocked';
|
|
55
|
+
}
|
|
47
56
|
| { performed: true; from: string; to: string };
|
|
48
57
|
|
|
49
58
|
export interface SystemUpdateResult {
|
|
@@ -656,7 +656,7 @@ resource "proxmox_lxc" "container" {
|
|
|
656
656
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
657
657
|
expect(out).toContain(' lifecycle {');
|
|
658
658
|
expect(out).toContain(
|
|
659
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].
|
|
659
|
+
' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
|
|
660
660
|
);
|
|
661
661
|
// Injected immediately after the opening line, before author attributes.
|
|
662
662
|
const lines = out.split('\n');
|
|
@@ -673,10 +673,21 @@ resource "proxmox_lxc" "container" {
|
|
|
673
673
|
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
674
674
|
expect(out).toContain(' lifecycle {');
|
|
675
675
|
expect(out).toContain(
|
|
676
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].
|
|
676
|
+
' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
|
|
677
677
|
);
|
|
678
678
|
});
|
|
679
679
|
|
|
680
|
+
// celilo#1402: `ostemplate` and `rootfs[0].storage` are ForceNew and do
|
|
681
|
+
// NOT round-trip through `terraform import` — Proxmox reports neither. Drop
|
|
682
|
+
// them from the ignore list and every import of an existing container
|
|
683
|
+
// computes a REPLACE, which the post-import guard refuses, leaving the
|
|
684
|
+
// container redeployable by no route at all.
|
|
685
|
+
test('ignores the ForceNew attributes an import cannot recover', () => {
|
|
686
|
+
const out = injectProxmoxDns(LXC, true);
|
|
687
|
+
expect(out).toContain('ostemplate');
|
|
688
|
+
expect(out).toContain('rootfs[0].storage');
|
|
689
|
+
});
|
|
690
|
+
|
|
680
691
|
test('is idempotent — already-injected content is returned unchanged', () => {
|
|
681
692
|
const once = injectProxmoxDns(LXC, true);
|
|
682
693
|
const twice = injectProxmoxDns(once, true);
|
|
@@ -737,7 +748,7 @@ resource "proxmox_lxc" "container" {
|
|
|
737
748
|
].join('\n');
|
|
738
749
|
const out = injectProxmoxDns(mixed, true);
|
|
739
750
|
expect(out).toContain(
|
|
740
|
-
'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].
|
|
751
|
+
'ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
|
|
741
752
|
);
|
|
742
753
|
expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]');
|
|
743
754
|
});
|
|
@@ -767,7 +778,7 @@ resource "proxmox_lxc" "container" {
|
|
|
767
778
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
768
779
|
expect(out).toContain(' lifecycle {');
|
|
769
780
|
expect(out).toContain(
|
|
770
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].
|
|
781
|
+
' ignore_changes = [nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys]',
|
|
771
782
|
);
|
|
772
783
|
});
|
|
773
784
|
});
|
|
@@ -199,9 +199,17 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
|
|
|
199
199
|
// in-place UPDATE, never a destructive REPLACE. A non-existent attribute
|
|
200
200
|
// here fails `terraform validate`, so each list is exactly the schema
|
|
201
201
|
// attributes the resource actually has.
|
|
202
|
-
// LXC (ISS-0055/0089): the create-time MAC
|
|
203
|
-
//
|
|
204
|
-
//
|
|
202
|
+
// LXC (ISS-0055/0089): the create-time MAC, plus `ssh_public_keys`
|
|
203
|
+
// (ForceNew — a fleet-key rotation must not REPLACE the container), plus
|
|
204
|
+
// the two ForceNew attributes an IMPORT cannot recover: `ostemplate`
|
|
205
|
+
// (Proxmox never reports the source template) and `rootfs[0].storage`
|
|
206
|
+
// (import records the volume, not the config's storage name). Without
|
|
207
|
+
// them every import of an existing container computes a REPLACE, the
|
|
208
|
+
// post-import guard refuses it, and the container becomes redeployable
|
|
209
|
+
// by no route at all (celilo#1402). `rootfs[0].volume` was listed here
|
|
210
|
+
// and terraform reported it "Redundant ... has no effect" — it is
|
|
211
|
+
// computed, never configured — so it is gone.
|
|
212
|
+
// `rootfs.size` is NOT ignored, so a disk grow still applies.
|
|
205
213
|
// We do NOT ignore `target_node` — a node change is real (migration,
|
|
206
214
|
// ISS-0090), never silently dropped.
|
|
207
215
|
// VM (telmate 3.x proxmox_vm_qemu): `clone` (ForceNew clone source),
|
|
@@ -212,7 +220,7 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
|
|
|
212
220
|
const ignore =
|
|
213
221
|
resourceType === 'proxmox_vm_qemu'
|
|
214
222
|
? 'nameserver, network[0].macaddr, sshkeys, clone'
|
|
215
|
-
: 'nameserver, network[0].hwaddr, rootfs[0].
|
|
223
|
+
: 'nameserver, network[0].hwaddr, ostemplate, rootfs[0].storage, ssh_public_keys';
|
|
216
224
|
injected.push(`${inner} ignore_changes = [${ignore}]`);
|
|
217
225
|
injected.push(`${inner}}`);
|
|
218
226
|
return injected.join('\n');
|
|
@@ -261,7 +261,7 @@ describe('Capability data $self: variable resolution', () => {
|
|
|
261
261
|
id: 'lunacycle',
|
|
262
262
|
name: 'LunaCycle',
|
|
263
263
|
version: '1.0.0',
|
|
264
|
-
celilo_contract: '
|
|
264
|
+
celilo_contract: '2.0',
|
|
265
265
|
variables: {
|
|
266
266
|
owns: [
|
|
267
267
|
{
|
|
@@ -388,7 +388,7 @@ describe('Capability data $self: variable resolution', () => {
|
|
|
388
388
|
id: 'lunacycle',
|
|
389
389
|
name: 'LunaCycle',
|
|
390
390
|
version: '1.0.0',
|
|
391
|
-
celilo_contract: '
|
|
391
|
+
celilo_contract: '2.0',
|
|
392
392
|
variables: {
|
|
393
393
|
owns: [
|
|
394
394
|
{
|
|
@@ -29,7 +29,7 @@ let db: DbClient;
|
|
|
29
29
|
/** Fresh manifest per call — never share a mutable manifest across tests. */
|
|
30
30
|
function namecheapManifest(computedValue = 'keys(secret.ddns_passwords)'): ModuleManifest {
|
|
31
31
|
return {
|
|
32
|
-
celilo_contract: '
|
|
32
|
+
celilo_contract: '2.0',
|
|
33
33
|
id: 'namecheap',
|
|
34
34
|
name: 'Namecheap',
|
|
35
35
|
version: '4.1.0',
|
package/src/variables/context.ts
CHANGED
|
@@ -423,8 +423,21 @@ async function assembleResolutionContext(
|
|
|
423
423
|
const deployedZone = machineRow.zone ?? zone;
|
|
424
424
|
const machineIdentity = resolveMachineIdentityAddress(machineRow, deployedZone);
|
|
425
425
|
if (!machineIdentity) {
|
|
426
|
+
// Distinguish "no such interface" from "the interface is there under
|
|
427
|
+
// a stale zone label" — the second is what a zone-model change
|
|
428
|
+
// produces, and its remediation is a re-classification, not new
|
|
429
|
+
// hardware (celilo#1382).
|
|
430
|
+
const { diffInterfaceZones, explainMissingZoneInterface } = await import(
|
|
431
|
+
'../services/machine-interface-zones'
|
|
432
|
+
);
|
|
433
|
+
const { detectZoneFromIp } = await import('../services/zone-detector');
|
|
434
|
+
const changes = await diffInterfaceZones(machineRow.interfaces, detectZoneFromIp);
|
|
426
435
|
throw new Error(
|
|
427
|
-
`Machine '${
|
|
436
|
+
`Machine '${machineRow.hostname}' for module '${moduleId}' ${explainMissingZoneInterface(
|
|
437
|
+
machineRow.interfaces,
|
|
438
|
+
deployedZone,
|
|
439
|
+
changes,
|
|
440
|
+
)}`,
|
|
428
441
|
);
|
|
429
442
|
}
|
|
430
443
|
upsertDeployedSystem(db, moduleId, {
|
|
@@ -441,7 +441,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
441
441
|
describe('basic derivations', () => {
|
|
442
442
|
test('applies single derivation', () => {
|
|
443
443
|
const manifest: ModuleManifest = {
|
|
444
|
-
celilo_contract: '
|
|
444
|
+
celilo_contract: '2.0',
|
|
445
445
|
id: 'test-module',
|
|
446
446
|
name: 'Test Module',
|
|
447
447
|
version: '1.0.0',
|
|
@@ -477,7 +477,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
477
477
|
|
|
478
478
|
test('applies multiple derivations in order', () => {
|
|
479
479
|
const manifest: ModuleManifest = {
|
|
480
|
-
celilo_contract: '
|
|
480
|
+
celilo_contract: '2.0',
|
|
481
481
|
id: 'test-module',
|
|
482
482
|
name: 'Test Module',
|
|
483
483
|
version: '1.0.0',
|
|
@@ -523,7 +523,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
523
523
|
describe('user config precedence', () => {
|
|
524
524
|
test('does not overwrite user-provided values', () => {
|
|
525
525
|
const manifest: ModuleManifest = {
|
|
526
|
-
celilo_contract: '
|
|
526
|
+
celilo_contract: '2.0',
|
|
527
527
|
id: 'test-module',
|
|
528
528
|
name: 'Test Module',
|
|
529
529
|
version: '1.0.0',
|
|
@@ -562,7 +562,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
562
562
|
describe('dependency resolution', () => {
|
|
563
563
|
test('resolves dependencies in declaration order', () => {
|
|
564
564
|
const manifest: ModuleManifest = {
|
|
565
|
-
celilo_contract: '
|
|
565
|
+
celilo_contract: '2.0',
|
|
566
566
|
id: 'test-module',
|
|
567
567
|
name: 'Test Module',
|
|
568
568
|
version: '1.0.0',
|
|
@@ -619,7 +619,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
619
619
|
describe('error handling', () => {
|
|
620
620
|
test('throws error for required variable with missing dependency', () => {
|
|
621
621
|
const manifest: ModuleManifest = {
|
|
622
|
-
celilo_contract: '
|
|
622
|
+
celilo_contract: '2.0',
|
|
623
623
|
id: 'test-module',
|
|
624
624
|
name: 'Test Module',
|
|
625
625
|
version: '1.0.0',
|
|
@@ -655,7 +655,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
655
655
|
|
|
656
656
|
test('silently skips optional variable with missing dependency', () => {
|
|
657
657
|
const manifest: ModuleManifest = {
|
|
658
|
-
celilo_contract: '
|
|
658
|
+
celilo_contract: '2.0',
|
|
659
659
|
id: 'test-module',
|
|
660
660
|
name: 'Test Module',
|
|
661
661
|
version: '1.0.0',
|
|
@@ -693,7 +693,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
693
693
|
|
|
694
694
|
test('derives scalar numeric values from capability data', () => {
|
|
695
695
|
const manifest: ModuleManifest = {
|
|
696
|
-
celilo_contract: '
|
|
696
|
+
celilo_contract: '2.0',
|
|
697
697
|
id: 'test-module',
|
|
698
698
|
name: 'Test Module',
|
|
699
699
|
version: '1.0.0',
|
|
@@ -731,7 +731,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
731
731
|
|
|
732
732
|
test('continues to skip structured derivations', () => {
|
|
733
733
|
const manifest: ModuleManifest = {
|
|
734
|
-
celilo_contract: '
|
|
734
|
+
celilo_contract: '2.0',
|
|
735
735
|
id: 'test-module',
|
|
736
736
|
name: 'Test Module',
|
|
737
737
|
version: '1.0.0',
|
|
@@ -776,7 +776,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
776
776
|
// context. The key must remain unset rather than being poisoned with the raw template.
|
|
777
777
|
test('does not set selfConfig when capability value still contains $self: ref', () => {
|
|
778
778
|
const manifest: ModuleManifest = {
|
|
779
|
-
celilo_contract: '
|
|
779
|
+
celilo_contract: '2.0',
|
|
780
780
|
id: 'lunacycle',
|
|
781
781
|
name: 'Lunacycle',
|
|
782
782
|
version: '1.0.0',
|
|
@@ -816,7 +816,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
816
816
|
|
|
817
817
|
test('uses existing selfConfig value when capability returns unresolved $self: ref', () => {
|
|
818
818
|
const manifest: ModuleManifest = {
|
|
819
|
-
celilo_contract: '
|
|
819
|
+
celilo_contract: '2.0',
|
|
820
820
|
id: 'lunacycle',
|
|
821
821
|
name: 'Lunacycle',
|
|
822
822
|
version: '1.0.0',
|
|
@@ -860,7 +860,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
860
860
|
// all 5 resolution passes unchanged and must be skipped.
|
|
861
861
|
test('does not set selfConfig when capability value contains $secret: ref', () => {
|
|
862
862
|
const manifest: ModuleManifest = {
|
|
863
|
-
celilo_contract: '
|
|
863
|
+
celilo_contract: '2.0',
|
|
864
864
|
id: 'consumer',
|
|
865
865
|
name: 'Consumer',
|
|
866
866
|
version: '1.0.0',
|
|
@@ -903,7 +903,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
903
903
|
// end-to-end, not just resolveDeclarativeDerivation in isolation.
|
|
904
904
|
test('resolves $system: ref inside capability value', () => {
|
|
905
905
|
const manifest: ModuleManifest = {
|
|
906
|
-
celilo_contract: '
|
|
906
|
+
celilo_contract: '2.0',
|
|
907
907
|
id: 'caddy',
|
|
908
908
|
name: 'Caddy',
|
|
909
909
|
version: '1.0.0',
|
|
@@ -946,7 +946,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
946
946
|
// on first pass (the other isn't set yet). Both should remain unset.
|
|
947
947
|
test('silently skips both optional variables in a circular reference', () => {
|
|
948
948
|
const manifest: ModuleManifest = {
|
|
949
|
-
celilo_contract: '
|
|
949
|
+
celilo_contract: '2.0',
|
|
950
950
|
id: 'test-module',
|
|
951
951
|
name: 'Test Module',
|
|
952
952
|
version: '1.0.0',
|
|
@@ -990,7 +990,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
990
990
|
|
|
991
991
|
test('throws when either circular variable is required', () => {
|
|
992
992
|
const manifest: ModuleManifest = {
|
|
993
|
-
celilo_contract: '
|
|
993
|
+
celilo_contract: '2.0',
|
|
994
994
|
id: 'test-module',
|
|
995
995
|
name: 'Test Module',
|
|
996
996
|
version: '1.0.0',
|
|
@@ -1040,7 +1040,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
1040
1040
|
// Equivalent to the {var} syntax; reads from selfConfig.
|
|
1041
1041
|
test('resolves $self: against selfConfig like {var}', () => {
|
|
1042
1042
|
const manifest: ModuleManifest = {
|
|
1043
|
-
celilo_contract: '
|
|
1043
|
+
celilo_contract: '2.0',
|
|
1044
1044
|
id: 'test-module',
|
|
1045
1045
|
name: 'Test Module',
|
|
1046
1046
|
version: '1.0.0',
|
|
@@ -1079,7 +1079,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
1079
1079
|
// auth_url derives from "https://auth.$self:domain" where domain
|
|
1080
1080
|
// is another self-owned variable set by the user.
|
|
1081
1081
|
const manifest: ModuleManifest = {
|
|
1082
|
-
celilo_contract: '
|
|
1082
|
+
celilo_contract: '2.0',
|
|
1083
1083
|
id: 'authentik',
|
|
1084
1084
|
name: 'Authentik',
|
|
1085
1085
|
version: '1.0.0',
|
|
@@ -1121,7 +1121,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
1121
1121
|
|
|
1122
1122
|
test('throws when $self: references an undeclared variable', () => {
|
|
1123
1123
|
const manifest: ModuleManifest = {
|
|
1124
|
-
celilo_contract: '
|
|
1124
|
+
celilo_contract: '2.0',
|
|
1125
1125
|
id: 'test',
|
|
1126
1126
|
name: 'Test',
|
|
1127
1127
|
version: '1.0.0',
|
|
@@ -1162,7 +1162,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
1162
1162
|
describe('skips', () => {
|
|
1163
1163
|
test('skips variables without derive_from', () => {
|
|
1164
1164
|
const manifest: ModuleManifest = {
|
|
1165
|
-
celilo_contract: '
|
|
1165
|
+
celilo_contract: '2.0',
|
|
1166
1166
|
id: 'test-module',
|
|
1167
1167
|
name: 'Test Module',
|
|
1168
1168
|
version: '1.0.0',
|
|
@@ -1199,7 +1199,7 @@ describe('applyDeclarativeDerivations', () => {
|
|
|
1199
1199
|
|
|
1200
1200
|
test('handles manifest with no variables', () => {
|
|
1201
1201
|
const manifest: ModuleManifest = {
|
|
1202
|
-
celilo_contract: '
|
|
1202
|
+
celilo_contract: '2.0',
|
|
1203
1203
|
id: 'test-module',
|
|
1204
1204
|
name: 'Test Module',
|
|
1205
1205
|
version: '1.0.0',
|