@celilo/cli 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/package.json +5 -4
- 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/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 +19 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +8 -2
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/services/api-access.test.ts +44 -0
- 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/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/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/celilo-events.test.ts +39 -0
- package/src/services/celilo-events.ts +41 -4
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +81 -2
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/orchestrator.test.ts +164 -0
- 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/context.ts +14 -1
|
@@ -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';
|
|
@@ -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');
|
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, {
|