@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.10
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/drizzle/0009_dns_registrations.sql +13 -0
- package/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +22 -1
- package/package.json +3 -3
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/api-clients/proxmox.test.ts +240 -0
- package/src/api-clients/proxmox.ts +456 -8
- package/src/cli/command-registry.ts +83 -6
- package/src/cli/commands/backup-delete.ts +10 -7
- package/src/cli/commands/backup-import.ts +11 -8
- package/src/cli/commands/backup-restore.ts +11 -8
- package/src/cli/commands/dns.ts +57 -0
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +89 -24
- package/src/cli/commands/machine-add.ts +178 -163
- package/src/cli/commands/machine-remove.ts +10 -7
- package/src/cli/commands/module-config.test.ts +78 -0
- package/src/cli/commands/module-config.ts +18 -3
- package/src/cli/commands/module-import.ts +9 -5
- package/src/cli/commands/module-publish.ts +24 -0
- package/src/cli/commands/module-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.test.ts +37 -0
- package/src/cli/commands/module-upgrade.ts +26 -6
- package/src/cli/commands/proxmox-node-list.ts +101 -0
- package/src/cli/commands/proxmox-template-selection.ts +16 -15
- package/src/cli/commands/proxmox-vm-template-build.ts +166 -0
- package/src/cli/commands/publish/alpha.test.ts +26 -0
- package/src/cli/commands/publish/alpha.ts +23 -0
- package/src/cli/commands/publish/types.ts +7 -2
- package/src/cli/commands/publish/workspace.ts +11 -1
- package/src/cli/commands/restore.ts +29 -0
- package/src/cli/commands/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +283 -209
- package/src/cli/commands/service-reconfigure.test.ts +115 -0
- package/src/cli/commands/service-reconfigure.ts +252 -129
- package/src/cli/commands/service-remove.ts +19 -13
- package/src/cli/commands/service-verify.ts +9 -10
- package/src/cli/commands/storage-add-local.ts +120 -107
- package/src/cli/commands/storage-add-s3.ts +145 -131
- package/src/cli/commands/storage-remove.ts +11 -8
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-init.ts +119 -128
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +23 -0
- package/src/cli/index.ts +91 -7
- package/src/cli/service-credential.ts +54 -0
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +74 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +43 -2
- package/src/hooks/run-named-hook.ts +28 -2
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +17 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-interview.ts +232 -0
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +54 -4
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-provider-backfill.ts +14 -2
- package/src/services/dns-registrations.test.ts +120 -0
- package/src/services/dns-registrations.ts +108 -0
- package/src/services/events-daemon.test.ts +59 -0
- package/src/services/events-daemon.ts +191 -57
- package/src/services/fleet-checks.test.ts +508 -0
- package/src/services/fleet-checks.ts +678 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- package/src/services/module-subscriptions.test.ts +88 -0
- package/src/services/module-subscriptions.ts +50 -1
- package/src/services/module-validator/bundled-deps.test.ts +55 -0
- package/src/services/module-validator/bundled-deps.ts +115 -0
- package/src/services/module-validator/capability-versions.test.ts +1 -1
- package/src/services/placement-reconcile.test.ts +86 -0
- package/src/services/placement-reconcile.ts +108 -0
- package/src/services/programmatic-responder.ts +34 -0
- package/src/services/terminal-responder.ts +113 -0
- package/src/templates/generator.test.ts +122 -15
- package/src/templates/generator.ts +206 -57
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile a module's ACTUAL node placement from Proxmox (ISS-0060 pt 2).
|
|
3
|
+
*
|
|
4
|
+
* "The DB stores intent, Proxmox reports reality." `module status` / `list`
|
|
5
|
+
* must show where a container ACTUALLY lives — queried live from the Proxmox
|
|
6
|
+
* cluster — not a cached `__infra_target_node` that drifts (caddy recorded
|
|
7
|
+
* node3, ran on node2). Machine-pool systems and non-Proxmox container services
|
|
8
|
+
* have no Proxmox node to reconcile.
|
|
9
|
+
*
|
|
10
|
+
* Never throws: a Proxmox outage yields an 'unreachable' resolution so a status
|
|
11
|
+
* or list command still renders instead of erroring.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DeployedSystem } from '@celilo/capabilities';
|
|
15
|
+
import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
|
|
16
|
+
import { getContainerService, getServiceCredentials } from './container-service';
|
|
17
|
+
|
|
18
|
+
export type PlacementResolution =
|
|
19
|
+
| { kind: 'node'; node: string } // reconciled: lives on <node>
|
|
20
|
+
| { kind: 'unreachable' } // a Proxmox container, but the API couldn't be reached
|
|
21
|
+
| { kind: 'absent' } // a Proxmox container with a vmid not present in the cluster
|
|
22
|
+
| { kind: 'uncreated' } // a container_service system not yet created (no vmid)
|
|
23
|
+
| { kind: 'machine' } // a machine-pool system (no Proxmox node)
|
|
24
|
+
| { kind: 'other' }; // a non-Proxmox container service (e.g. DigitalOcean)
|
|
25
|
+
|
|
26
|
+
/** One-line placement description for `module status`. Pure (Rule 10). */
|
|
27
|
+
export function formatPlacementLine(sys: DeployedSystem, res: PlacementResolution): string {
|
|
28
|
+
const zone = `zone ${sys.zone}`;
|
|
29
|
+
const vmid = sys.infrastructure.vmid;
|
|
30
|
+
switch (res.kind) {
|
|
31
|
+
case 'machine':
|
|
32
|
+
return `${sys.hostname} — machine (${zone})`;
|
|
33
|
+
case 'other':
|
|
34
|
+
return `${sys.hostname} — container_service, non-Proxmox (${zone})`;
|
|
35
|
+
case 'uncreated':
|
|
36
|
+
return `${sys.hostname} — not yet created (${zone})`;
|
|
37
|
+
case 'unreachable':
|
|
38
|
+
return `${sys.hostname} (vmid ${vmid}) → node unknown — Proxmox unreachable (${zone})`;
|
|
39
|
+
case 'absent':
|
|
40
|
+
return `${sys.hostname} (vmid ${vmid}) → not found in Proxmox — not created? (${zone})`;
|
|
41
|
+
case 'node':
|
|
42
|
+
return `${sys.hostname} (vmid ${vmid}) → ${res.node} (${zone})`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pure resolution of one system given the reconciled vmid→node map + service classification. */
|
|
47
|
+
export function resolveOnePlacement(
|
|
48
|
+
sys: DeployedSystem,
|
|
49
|
+
vmidToNode: Map<number, string>,
|
|
50
|
+
unreachable: Set<string>,
|
|
51
|
+
nonProxmox: Set<string>,
|
|
52
|
+
): PlacementResolution {
|
|
53
|
+
const infra = sys.infrastructure;
|
|
54
|
+
if (infra.type !== 'container_service') return { kind: 'machine' };
|
|
55
|
+
if (infra.serviceId && nonProxmox.has(infra.serviceId)) return { kind: 'other' };
|
|
56
|
+
if (infra.vmid == null) return { kind: 'uncreated' };
|
|
57
|
+
if (infra.serviceId && unreachable.has(infra.serviceId)) return { kind: 'unreachable' };
|
|
58
|
+
const node = vmidToNode.get(infra.vmid);
|
|
59
|
+
return node ? { kind: 'node', node } : { kind: 'absent' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reconcile each system's real node from Proxmox. One `/cluster/resources` fetch
|
|
64
|
+
* per distinct Proxmox service (usually one); machine + non-Proxmox systems need
|
|
65
|
+
* no call. Returns each system paired with its resolution, in input order.
|
|
66
|
+
*/
|
|
67
|
+
export async function reconcilePlacement(
|
|
68
|
+
systems: DeployedSystem[],
|
|
69
|
+
): Promise<Array<{ system: DeployedSystem; resolution: PlacementResolution }>> {
|
|
70
|
+
const serviceIds = [
|
|
71
|
+
...new Set(
|
|
72
|
+
systems
|
|
73
|
+
.filter((s) => s.infrastructure.type === 'container_service')
|
|
74
|
+
.map((s) => s.infrastructure.serviceId)
|
|
75
|
+
.filter((id): id is string => !!id),
|
|
76
|
+
),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const vmidToNode = new Map<number, string>();
|
|
80
|
+
const unreachable = new Set<string>();
|
|
81
|
+
const nonProxmox = new Set<string>();
|
|
82
|
+
|
|
83
|
+
for (const serviceId of serviceIds) {
|
|
84
|
+
const service = await getContainerService(serviceId);
|
|
85
|
+
if (!service || service.providerName !== 'proxmox') {
|
|
86
|
+
nonProxmox.add(serviceId);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const creds = (await getServiceCredentials(serviceId)) as ProxmoxCredentials;
|
|
91
|
+
const result = await new ProxmoxClient(creds).clusterResources();
|
|
92
|
+
if (!result.success) {
|
|
93
|
+
unreachable.add(serviceId);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (const r of result.data) {
|
|
97
|
+
if (typeof r.vmid === 'number' && r.node) vmidToNode.set(r.vmid, r.node);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
unreachable.add(serviceId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return systems.map((system) => ({
|
|
105
|
+
system,
|
|
106
|
+
resolution: resolveOnePlacement(system, vmidToNode, unreachable, nonProxmox),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
@@ -23,6 +23,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
|
23
23
|
import type {
|
|
24
24
|
ConfigRequiredPayload,
|
|
25
25
|
EnsureRequiredPayload,
|
|
26
|
+
InterviewRequiredPayload,
|
|
26
27
|
SecretRequiredPayload,
|
|
27
28
|
} from './bus-interview';
|
|
28
29
|
import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
|
|
@@ -58,6 +59,13 @@ export interface ResponderValues {
|
|
|
58
59
|
secretValues?: Record<string, string>;
|
|
59
60
|
}
|
|
60
61
|
>;
|
|
62
|
+
/**
|
|
63
|
+
* Generic interview answers keyed by `<scope>.<key>` (ISS-0127). When a
|
|
64
|
+
* command emits `interview.required.<scope>.<key>`, the responder replies
|
|
65
|
+
* with `{ value }`. The value's shape should match the payload's `kind`
|
|
66
|
+
* (string for text/select, string[] for multiselect, boolean for confirm).
|
|
67
|
+
*/
|
|
68
|
+
interview?: Record<string, unknown>;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
export interface ProgrammaticResponderOptions {
|
|
@@ -108,6 +116,7 @@ export interface ProgrammaticResponderHandle {
|
|
|
108
116
|
seenConfigPayloads(): ConfigRequiredPayload[];
|
|
109
117
|
seenSecretPayloads(): SecretRequiredPayload[];
|
|
110
118
|
seenEnsurePayloads(): EnsureRequiredPayload[];
|
|
119
|
+
seenInterviewPayloads(): InterviewRequiredPayload[];
|
|
111
120
|
/** Stop watching. Caller still owns the db client. */
|
|
112
121
|
close(): void;
|
|
113
122
|
}
|
|
@@ -126,6 +135,7 @@ export function startProgrammaticResponder(
|
|
|
126
135
|
const seenConfig: ConfigRequiredPayload[] = [];
|
|
127
136
|
const seenSecret: SecretRequiredPayload[] = [];
|
|
128
137
|
const seenEnsure: EnsureRequiredPayload[] = [];
|
|
138
|
+
const seenInterview: InterviewRequiredPayload[] = [];
|
|
129
139
|
let lastActivityAt = Date.now();
|
|
130
140
|
|
|
131
141
|
const me = opts.emittedBy ?? 'programmatic';
|
|
@@ -276,6 +286,28 @@ export function startProgrammaticResponder(
|
|
|
276
286
|
answered.push({ type: event.type, key: lookupKey });
|
|
277
287
|
});
|
|
278
288
|
|
|
289
|
+
const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
|
|
290
|
+
if (event.replyFor !== null) return;
|
|
291
|
+
lastActivityAt = Date.now();
|
|
292
|
+
|
|
293
|
+
const payload = event.payload as InterviewRequiredPayload;
|
|
294
|
+
if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
|
|
295
|
+
missed.push({ type: event.type, key: '?', reason: 'malformed payload' });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
seenInterview.push(payload);
|
|
299
|
+
|
|
300
|
+
const lookupKey = `${payload.scope}.${payload.key}`;
|
|
301
|
+
const value = opts.values.interview?.[lookupKey];
|
|
302
|
+
if (value === undefined) {
|
|
303
|
+
handleMissing(event.type, lookupKey, `no interview value for "${lookupKey}"`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
bus.emitRaw(`${event.type}.reply`, { value }, { replyFor: event.id, emittedBy: me });
|
|
308
|
+
answered.push({ type: event.type, key: lookupKey });
|
|
309
|
+
});
|
|
310
|
+
|
|
279
311
|
// Liveness probe: a non-interactive caller (e.g. `module generate`
|
|
280
312
|
// with no TTY) emits `responder.probe` to detect whether any
|
|
281
313
|
// responder is listening before calling busInterview (which waits
|
|
@@ -298,10 +330,12 @@ export function startProgrammaticResponder(
|
|
|
298
330
|
seenConfigPayloads: () => [...seenConfig],
|
|
299
331
|
seenSecretPayloads: () => [...seenSecret],
|
|
300
332
|
seenEnsurePayloads: () => [...seenEnsure],
|
|
333
|
+
seenInterviewPayloads: () => [...seenInterview],
|
|
301
334
|
close: () => {
|
|
302
335
|
configWatch.close();
|
|
303
336
|
secretWatch.close();
|
|
304
337
|
ensureWatch.close();
|
|
338
|
+
interviewWatch.close();
|
|
305
339
|
probeWatch.close();
|
|
306
340
|
bus.close();
|
|
307
341
|
},
|
|
@@ -37,6 +37,7 @@ import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
|
37
37
|
import type {
|
|
38
38
|
ConfigRequiredPayload,
|
|
39
39
|
EnsureRequiredPayload,
|
|
40
|
+
InterviewRequiredPayload,
|
|
40
41
|
SecretRequiredPayload,
|
|
41
42
|
} from './bus-interview';
|
|
42
43
|
import { readModuleSecretKey, writeModuleSecretKey } from './config-interview';
|
|
@@ -283,6 +284,117 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
283
284
|
});
|
|
284
285
|
});
|
|
285
286
|
|
|
287
|
+
// Generic interview family (ISS-0127). Non-deploy commands (e.g.
|
|
288
|
+
// `service reconfigure`) ask their questions here so they're driveable
|
|
289
|
+
// over the bus like a deploy. Renders by `kind`. Like the other watches,
|
|
290
|
+
// we ignore reply events (replyFor !== null) and de-dupe by event id.
|
|
291
|
+
const interviewWatch = bus.watch('interview.required.*.*', async (event) => {
|
|
292
|
+
if (event.replyFor !== null) return;
|
|
293
|
+
if (handled.has(event.id)) return;
|
|
294
|
+
handled.add(event.id);
|
|
295
|
+
|
|
296
|
+
const payload = event.payload as InterviewRequiredPayload;
|
|
297
|
+
if (!payload || typeof payload.scope !== 'string' || typeof payload.key !== 'string') {
|
|
298
|
+
log.warn(
|
|
299
|
+
`Terminal responder skipped malformed interview event ${event.type} (id ${event.id}): missing scope/key`,
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const message = payload.description
|
|
305
|
+
? `${payload.message} — ${payload.description}`
|
|
306
|
+
: payload.message;
|
|
307
|
+
|
|
308
|
+
let value: unknown;
|
|
309
|
+
|
|
310
|
+
if (payload.kind === 'confirm') {
|
|
311
|
+
const answer = await p.confirm({
|
|
312
|
+
message,
|
|
313
|
+
initialValue: payload.defaultValue === 'true',
|
|
314
|
+
});
|
|
315
|
+
if (p.isCancel(answer)) {
|
|
316
|
+
log.warn(
|
|
317
|
+
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
value = answer;
|
|
322
|
+
} else if (payload.kind === 'select') {
|
|
323
|
+
const answer = await p.select({
|
|
324
|
+
message,
|
|
325
|
+
options: (payload.options ?? []).map((opt) => ({
|
|
326
|
+
value: opt.value,
|
|
327
|
+
label: opt.label,
|
|
328
|
+
hint: opt.hint,
|
|
329
|
+
})),
|
|
330
|
+
initialValue: payload.defaultValue,
|
|
331
|
+
});
|
|
332
|
+
if (p.isCancel(answer)) {
|
|
333
|
+
log.warn(
|
|
334
|
+
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
value = answer;
|
|
339
|
+
} else if (payload.kind === 'multiselect') {
|
|
340
|
+
const answer = await p.multiselect({
|
|
341
|
+
message,
|
|
342
|
+
options: (payload.options ?? []).map((opt) => ({
|
|
343
|
+
value: opt.value,
|
|
344
|
+
label: opt.label,
|
|
345
|
+
hint: opt.hint,
|
|
346
|
+
})),
|
|
347
|
+
required: payload.required,
|
|
348
|
+
});
|
|
349
|
+
if (p.isCancel(answer)) {
|
|
350
|
+
log.warn(
|
|
351
|
+
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
352
|
+
);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
value = answer;
|
|
356
|
+
} else {
|
|
357
|
+
// kind === 'text'
|
|
358
|
+
const type = payload.type ?? 'string';
|
|
359
|
+
const typeHint = describeTypeHint(type);
|
|
360
|
+
const answer = await promptText({
|
|
361
|
+
message: typeHint ? `${message} (${typeHint})` : message,
|
|
362
|
+
defaultValue: payload.defaultValue,
|
|
363
|
+
placeholder: payload.placeholder,
|
|
364
|
+
validate: (val) => {
|
|
365
|
+
if (payload.required && (!val || val.trim() === '')) {
|
|
366
|
+
return 'This field is required';
|
|
367
|
+
}
|
|
368
|
+
if (payload.pattern && val) {
|
|
369
|
+
const re = new RegExp(payload.pattern);
|
|
370
|
+
if (!re.test(val)) return `Value must match: ${payload.pattern}`;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
coerceValue(val, type);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return err instanceof Error ? err.message : String(err);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
if (answer === undefined) {
|
|
380
|
+
log.warn(
|
|
381
|
+
`Terminal responder: cancelled prompt for ${payload.scope}.${payload.key}; no reply emitted`,
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
value = coerceValue(answer, type);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
bus.emitRaw(
|
|
389
|
+
`${event.type}.reply`,
|
|
390
|
+
{ value },
|
|
391
|
+
{
|
|
392
|
+
replyFor: event.id,
|
|
393
|
+
emittedBy: me,
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
286
398
|
// Liveness probe: lets a non-interactive caller in another shell
|
|
287
399
|
// (e.g. `module generate`) detect that a terminal-responder is
|
|
288
400
|
// running here and that calling busInterview is safe.
|
|
@@ -300,6 +412,7 @@ export function startTerminalResponder(): TerminalResponderHandle {
|
|
|
300
412
|
configWatch.close();
|
|
301
413
|
secretWatch.close();
|
|
302
414
|
ensureWatch.close();
|
|
415
|
+
interviewWatch.close();
|
|
303
416
|
probeWatch.close();
|
|
304
417
|
bus.close();
|
|
305
418
|
},
|
|
@@ -6,12 +6,14 @@ import { type DbClient, createDbClient } from '../db/client';
|
|
|
6
6
|
import { capabilities } from '../db/schema';
|
|
7
7
|
import { upsertModuleConfig } from '../services/module-config';
|
|
8
8
|
import {
|
|
9
|
+
decideTargetNode,
|
|
9
10
|
discoverTemplateFiles,
|
|
10
11
|
generateTemplates,
|
|
11
12
|
getOutputFilename,
|
|
12
|
-
|
|
13
|
+
injectProxmoxDns,
|
|
13
14
|
isTemplateFile,
|
|
14
15
|
readTemplateFiles,
|
|
16
|
+
targetNodeFromTfState,
|
|
15
17
|
writeGeneratedFiles,
|
|
16
18
|
} from './generator';
|
|
17
19
|
import type { GeneratedFile } from './types';
|
|
@@ -635,7 +637,7 @@ resource "proxmox_lxc" "container" {
|
|
|
635
637
|
});
|
|
636
638
|
});
|
|
637
639
|
|
|
638
|
-
describe('
|
|
640
|
+
describe('injectProxmoxDns', () => {
|
|
639
641
|
const LXC = [
|
|
640
642
|
'resource "proxmox_lxc" "caddy" {',
|
|
641
643
|
' target_node = "pve"',
|
|
@@ -647,11 +649,11 @@ resource "proxmox_lxc" "container" {
|
|
|
647
649
|
].join('\n');
|
|
648
650
|
|
|
649
651
|
test('injects nameserver + lifecycle when a nameserver is computable', () => {
|
|
650
|
-
const out =
|
|
652
|
+
const out = injectProxmoxDns(LXC, true);
|
|
651
653
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
652
654
|
expect(out).toContain(' lifecycle {');
|
|
653
655
|
expect(out).toContain(
|
|
654
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
656
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
655
657
|
);
|
|
656
658
|
// Injected immediately after the opening line, before author attributes.
|
|
657
659
|
const lines = out.split('\n');
|
|
@@ -664,17 +666,17 @@ resource "proxmox_lxc" "container" {
|
|
|
664
666
|
});
|
|
665
667
|
|
|
666
668
|
test('injects lifecycle only (no nameserver) when none is computable', () => {
|
|
667
|
-
const out =
|
|
669
|
+
const out = injectProxmoxDns(LXC, false);
|
|
668
670
|
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
669
671
|
expect(out).toContain(' lifecycle {');
|
|
670
672
|
expect(out).toContain(
|
|
671
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
673
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
672
674
|
);
|
|
673
675
|
});
|
|
674
676
|
|
|
675
677
|
test('is idempotent — already-injected content is returned unchanged', () => {
|
|
676
|
-
const once =
|
|
677
|
-
const twice =
|
|
678
|
+
const once = injectProxmoxDns(LXC, true);
|
|
679
|
+
const twice = injectProxmoxDns(once, true);
|
|
678
680
|
expect(twice).toBe(once);
|
|
679
681
|
});
|
|
680
682
|
|
|
@@ -688,32 +690,137 @@ resource "proxmox_lxc" "container" {
|
|
|
688
690
|
' start = true',
|
|
689
691
|
'}',
|
|
690
692
|
].join('\n');
|
|
691
|
-
const out =
|
|
693
|
+
const out = injectProxmoxDns(stale, true);
|
|
692
694
|
// Exactly one nameserver attribute survives.
|
|
693
695
|
expect(out.match(/nameserver\s*=/g)?.length).toBe(1);
|
|
694
696
|
// The lifecycle guard is still added (ISS-0055 extended the ignore list).
|
|
695
697
|
expect(out).toContain('ignore_changes = [nameserver,');
|
|
696
698
|
});
|
|
697
699
|
|
|
698
|
-
test('
|
|
699
|
-
const vm = [
|
|
700
|
-
|
|
700
|
+
test('injects cloud-init DNS + the VM ForceNew ignore list into proxmox_vm_qemu', () => {
|
|
701
|
+
const vm = [
|
|
702
|
+
'resource "proxmox_vm_qemu" "builder" {',
|
|
703
|
+
' target_node = "pve"',
|
|
704
|
+
' clone = "ubuntu-2204-cloudinit"',
|
|
705
|
+
' network {',
|
|
706
|
+
' bridge = "vmbr0"',
|
|
707
|
+
' }',
|
|
708
|
+
'}',
|
|
709
|
+
].join('\n');
|
|
710
|
+
const out = injectProxmoxDns(vm, true);
|
|
711
|
+
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
712
|
+
expect(out).toContain(' lifecycle {');
|
|
713
|
+
// The VM ForceNew list (telmate 3.x), NOT the LXC one.
|
|
714
|
+
expect(out).toContain(
|
|
715
|
+
' ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]',
|
|
716
|
+
);
|
|
717
|
+
const lines = out.split('\n');
|
|
718
|
+
expect(lines[0]).toBe('resource "proxmox_vm_qemu" "builder" {');
|
|
719
|
+
expect(lines[1]).toBe(' nameserver = "$self:lxc_nameserver"');
|
|
720
|
+
expect(out).toContain(' clone = "ubuntu-2204-cloudinit"');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test('branches the ignore list per block in a mixed lxc + vm file', () => {
|
|
724
|
+
// The forgejo-runner builder pattern: one template with both a count-gated
|
|
725
|
+
// proxmox_lxc (light) and proxmox_vm_qemu (builder) — each gets its own list.
|
|
726
|
+
const mixed = [
|
|
727
|
+
'resource "proxmox_lxc" "light" {',
|
|
728
|
+
' target_node = "pve"',
|
|
729
|
+
'}',
|
|
730
|
+
'',
|
|
731
|
+
'resource "proxmox_vm_qemu" "builder" {',
|
|
732
|
+
' clone = "ubuntu-2204-cloudinit"',
|
|
733
|
+
'}',
|
|
734
|
+
].join('\n');
|
|
735
|
+
const out = injectProxmoxDns(mixed, true);
|
|
736
|
+
expect(out).toContain(
|
|
737
|
+
'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
738
|
+
);
|
|
739
|
+
expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('leaves non-Proxmox-compute resources untouched', () => {
|
|
743
|
+
// Not proxmox_lxc / proxmox_vm_qemu — an external-zone droplet, and the bare
|
|
744
|
+
// `proxmox_vm` type (which is NOT the cloud-init qemu resource we target).
|
|
745
|
+
const droplet = [
|
|
746
|
+
'resource "digitalocean_droplet" "edge" {',
|
|
747
|
+
' size = "s-1vcpu-1gb"',
|
|
748
|
+
'}',
|
|
749
|
+
].join('\n');
|
|
750
|
+
const bareVm = ['resource "proxmox_vm" "x" {', ' cores = 4', '}'].join('\n');
|
|
751
|
+
expect(injectProxmoxDns(droplet, true)).toBe(droplet);
|
|
752
|
+
expect(injectProxmoxDns(bareVm, true)).toBe(bareVm);
|
|
701
753
|
});
|
|
702
754
|
|
|
703
755
|
test('injects into every proxmox_lxc block in a multi-resource file', () => {
|
|
704
756
|
const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`;
|
|
705
|
-
const out =
|
|
757
|
+
const out = injectProxmoxDns(two, true);
|
|
706
758
|
expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2);
|
|
707
759
|
});
|
|
708
760
|
|
|
709
761
|
test('matches the indentation of the resource opening line', () => {
|
|
710
762
|
const indented = [' resource "proxmox_lxc" "x" {', ' cores = 1', ' }'].join('\n');
|
|
711
|
-
const out =
|
|
763
|
+
const out = injectProxmoxDns(indented, true);
|
|
712
764
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
713
765
|
expect(out).toContain(' lifecycle {');
|
|
714
766
|
expect(out).toContain(
|
|
715
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
767
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
716
768
|
);
|
|
717
769
|
});
|
|
718
770
|
});
|
|
719
771
|
});
|
|
772
|
+
|
|
773
|
+
describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => {
|
|
774
|
+
test('reads the node from the proxmox_lxc target_node attribute', () => {
|
|
775
|
+
const state = {
|
|
776
|
+
resources: [
|
|
777
|
+
{
|
|
778
|
+
type: 'proxmox_lxc',
|
|
779
|
+
instances: [{ attributes: { target_node: 'node2', id: 'node2/lxc/200' } }],
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
};
|
|
783
|
+
expect(targetNodeFromTfState(state)).toBe('node2');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test('falls back to parsing the resource id when target_node is absent', () => {
|
|
787
|
+
const state = {
|
|
788
|
+
resources: [{ type: 'proxmox_lxc', instances: [{ attributes: { id: 'node3/lxc/201' } }] }],
|
|
789
|
+
};
|
|
790
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test('returns null when there is no proxmox_lxc resource (fresh/empty state)', () => {
|
|
794
|
+
expect(targetNodeFromTfState({ resources: [] })).toBeNull();
|
|
795
|
+
expect(targetNodeFromTfState({})).toBeNull();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
describe('decideTargetNode (ISS-0090 — deploy follows reality: Proxmox > state > default)', () => {
|
|
800
|
+
test('Proxmox reality wins — adopts a hand-migration tf-state would miss', () => {
|
|
801
|
+
expect(
|
|
802
|
+
decideTargetNode({ proxmoxNode: 'node2', stateNode: 'node3', defaultNode: 'node3' }),
|
|
803
|
+
).toEqual({ node: 'node2', source: 'proxmox' });
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('falls back to terraform state when Proxmox is unknown/unreachable', () => {
|
|
807
|
+
expect(
|
|
808
|
+
decideTargetNode({ proxmoxNode: null, stateNode: 'node2', defaultNode: 'node3' }),
|
|
809
|
+
).toEqual({ node: 'node2', source: 'state' });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('first deploy (no Proxmox, no state) → service default', () => {
|
|
813
|
+
expect(decideTargetNode({ proxmoxNode: null, stateNode: null, defaultNode: 'node3' })).toEqual({
|
|
814
|
+
node: 'node3',
|
|
815
|
+
source: 'default',
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('a changed default never relocates a running container (reality overrides default)', () => {
|
|
820
|
+
// default flipped node2→node3, but the container actually runs on node2:
|
|
821
|
+
// resolution stays node2, so the redeploy is an in-place update, not a move.
|
|
822
|
+
expect(
|
|
823
|
+
decideTargetNode({ proxmoxNode: 'node2', stateNode: null, defaultNode: 'node3' }),
|
|
824
|
+
).toEqual({ node: 'node2', source: 'proxmox' });
|
|
825
|
+
});
|
|
826
|
+
});
|