@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.1
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/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { findNodeForVmid } from './proxmox';
|
|
3
|
+
|
|
4
|
+
describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => {
|
|
5
|
+
// A trimmed /cluster/resources payload: guest rows carry a vmid; node/storage
|
|
6
|
+
// rows do not (only a node name).
|
|
7
|
+
const resources = [
|
|
8
|
+
{ node: 'node2' }, // node row — no vmid
|
|
9
|
+
{ node: 'node3' }, // storage row — no vmid
|
|
10
|
+
{ vmid: 200, node: 'node2' }, // caddy
|
|
11
|
+
{ vmid: 201, node: 'node3' }, // authentik
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
test('returns the node a vmid currently lives on', () => {
|
|
15
|
+
expect(findNodeForVmid(resources, 200)).toBe('node2');
|
|
16
|
+
expect(findNodeForVmid(resources, 201)).toBe('node3');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('returns null when the vmid is absent — container not created yet (first deploy)', () => {
|
|
20
|
+
expect(findNodeForVmid(resources, 999)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('never matches a node/storage row that has no vmid', () => {
|
|
24
|
+
expect(findNodeForVmid([{ node: 'node2' }, { node: 'node3' }], 200)).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('returns null for an empty inventory', () => {
|
|
28
|
+
expect(findNodeForVmid([], 200)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -114,6 +114,18 @@ async function makeProxmoxRequest<T>(
|
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
// Fail fast instead of hanging on an unreachable host (no implicit timeout
|
|
118
|
+
// on https.request). Callers treat a failed result as "couldn't reach
|
|
119
|
+
// Proxmox" and fall back accordingly.
|
|
120
|
+
req.setTimeout(15_000, () => {
|
|
121
|
+
req.destroy();
|
|
122
|
+
resolve({
|
|
123
|
+
success: false,
|
|
124
|
+
message: 'Request timed out',
|
|
125
|
+
details: { timeoutMs: 15_000 },
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
117
129
|
req.end();
|
|
118
130
|
} catch (error) {
|
|
119
131
|
resolve({
|
|
@@ -486,6 +498,51 @@ export async function listNodeStorage(
|
|
|
486
498
|
return makeProxmoxRequest(credentials, `/nodes/${nodeName}/storage`);
|
|
487
499
|
}
|
|
488
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Find which Proxmox node a given VMID currently lives on.
|
|
503
|
+
*
|
|
504
|
+
* Queries the cluster resource inventory (`/cluster/resources`), which lists
|
|
505
|
+
* every guest across all nodes with its current node, and matches by VMID
|
|
506
|
+
* (unique cluster-wide). Returns the node name, or `null` if the VMID isn't
|
|
507
|
+
* present — i.e. the container hasn't been created yet (a first deploy).
|
|
508
|
+
*
|
|
509
|
+
* ISS-0090: this is celilo's source of truth for WHERE a system currently is.
|
|
510
|
+
* A redeploy must target the node Proxmox reports here, NOT re-derive placement
|
|
511
|
+
* from the service's `default_target_node` (which only governs new placement) —
|
|
512
|
+
* otherwise a changed default tries to relocate every running container.
|
|
513
|
+
*/
|
|
514
|
+
export async function getNodeForVmid(
|
|
515
|
+
credentials: ProxmoxCredentials,
|
|
516
|
+
vmid: number,
|
|
517
|
+
): Promise<ProxmoxResult<string | null>> {
|
|
518
|
+
// makeProxmoxRequest sends only url.pathname, so a `?type=vm` filter would be
|
|
519
|
+
// dropped — fetch the full inventory and match by vmid client-side instead.
|
|
520
|
+
const result = await makeProxmoxRequest<Array<{ vmid?: number; node?: string }>>(
|
|
521
|
+
credentials,
|
|
522
|
+
'/cluster/resources',
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
if (!result.success) {
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return { success: true, data: findNodeForVmid(result.data, vmid) };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Find the node a VMID lives on within a Proxmox cluster-resource list. Pure
|
|
534
|
+
* matching logic, split out from the network call for testability (Rule 10).
|
|
535
|
+
* Non-guest entries (storage/node rows) have no `vmid` and are skipped. Returns
|
|
536
|
+
* the node name, or `null` when the VMID isn't present.
|
|
537
|
+
*/
|
|
538
|
+
export function findNodeForVmid(
|
|
539
|
+
resources: Array<{ vmid?: number; node?: string }>,
|
|
540
|
+
vmid: number,
|
|
541
|
+
): string | null {
|
|
542
|
+
const match = resources.find((r) => typeof r.vmid === 'number' && r.vmid === vmid);
|
|
543
|
+
return match?.node ?? null;
|
|
544
|
+
}
|
|
545
|
+
|
|
489
546
|
/**
|
|
490
547
|
* List available LXC templates in storage
|
|
491
548
|
*/
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
injectProxmoxLxcDns,
|
|
13
13
|
isTemplateFile,
|
|
14
14
|
readTemplateFiles,
|
|
15
|
+
targetNodeFromTfState,
|
|
15
16
|
writeGeneratedFiles,
|
|
16
17
|
} from './generator';
|
|
17
18
|
import type { GeneratedFile } from './types';
|
|
@@ -651,7 +652,7 @@ resource "proxmox_lxc" "container" {
|
|
|
651
652
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
652
653
|
expect(out).toContain(' lifecycle {');
|
|
653
654
|
expect(out).toContain(
|
|
654
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
655
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
655
656
|
);
|
|
656
657
|
// Injected immediately after the opening line, before author attributes.
|
|
657
658
|
const lines = out.split('\n');
|
|
@@ -668,7 +669,7 @@ resource "proxmox_lxc" "container" {
|
|
|
668
669
|
expect(out).not.toContain('nameserver = "$self:lxc_nameserver"');
|
|
669
670
|
expect(out).toContain(' lifecycle {');
|
|
670
671
|
expect(out).toContain(
|
|
671
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
672
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
672
673
|
);
|
|
673
674
|
});
|
|
674
675
|
|
|
@@ -712,8 +713,34 @@ resource "proxmox_lxc" "container" {
|
|
|
712
713
|
expect(out).toContain(' nameserver = "$self:lxc_nameserver"');
|
|
713
714
|
expect(out).toContain(' lifecycle {');
|
|
714
715
|
expect(out).toContain(
|
|
715
|
-
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume]',
|
|
716
|
+
' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]',
|
|
716
717
|
);
|
|
717
718
|
});
|
|
718
719
|
});
|
|
719
720
|
});
|
|
721
|
+
|
|
722
|
+
describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => {
|
|
723
|
+
test('reads the node from the proxmox_lxc target_node attribute', () => {
|
|
724
|
+
const state = {
|
|
725
|
+
resources: [
|
|
726
|
+
{
|
|
727
|
+
type: 'proxmox_lxc',
|
|
728
|
+
instances: [{ attributes: { target_node: 'node2', id: 'node2/lxc/200' } }],
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
};
|
|
732
|
+
expect(targetNodeFromTfState(state)).toBe('node2');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('falls back to parsing the resource id when target_node is absent', () => {
|
|
736
|
+
const state = {
|
|
737
|
+
resources: [{ type: 'proxmox_lxc', instances: [{ attributes: { id: 'node3/lxc/201' } }] }],
|
|
738
|
+
};
|
|
739
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('returns null when there is no proxmox_lxc resource (fresh/empty state)', () => {
|
|
743
|
+
expect(targetNodeFromTfState({ resources: [] })).toBeNull();
|
|
744
|
+
expect(targetNodeFromTfState({})).toBeNull();
|
|
745
|
+
});
|
|
746
|
+
});
|
|
@@ -6,6 +6,7 @@ import { and, eq } from 'drizzle-orm';
|
|
|
6
6
|
import { generateInventory } from '../ansible/inventory';
|
|
7
7
|
import { generateAnsibleSecrets } from '../ansible/secrets';
|
|
8
8
|
import { log } from '../cli/prompts';
|
|
9
|
+
import { getModuleStoragePath } from '../config/paths';
|
|
9
10
|
import { type DbClient, getDb } from '../db/client';
|
|
10
11
|
import {
|
|
11
12
|
capabilities,
|
|
@@ -195,12 +196,69 @@ export function injectProxmoxLxcDns(content: string, hasNameserver: boolean): st
|
|
|
195
196
|
// schema attributes in telmate ~>2.9 and `terraform validate` rejects them.
|
|
196
197
|
// `nameserver` stays for the original DNS reason; `rootfs.size` is NOT ignored
|
|
197
198
|
// so a disk grow still applies in place.
|
|
198
|
-
|
|
199
|
+
// ISS-0089: also ignore `ssh_public_keys` — it's ForceNew, and on a module
|
|
200
|
+
// deployed before the current fleet key, the LXC's birth keys differ from the
|
|
201
|
+
// regenerated config, forcing a spurious REPLACE on redeploy. Rotating the
|
|
202
|
+
// authorized keys must never recreate the container. We do NOT ignore
|
|
203
|
+
// `target_node` — a node change is real and is handled by migration (ISS-0090),
|
|
204
|
+
// never silently dropped.
|
|
205
|
+
injected.push(
|
|
206
|
+
`${inner} ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]`,
|
|
207
|
+
);
|
|
199
208
|
injected.push(`${inner}}`);
|
|
200
209
|
return injected.join('\n');
|
|
201
210
|
});
|
|
202
211
|
}
|
|
203
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Extract the node a proxmox_lxc resource is deployed on from a parsed terraform
|
|
215
|
+
* state object. Pure logic (Rule 10), split from the file read for testability.
|
|
216
|
+
* Prefers the explicit `target_node` attribute; falls back to parsing the
|
|
217
|
+
* resource id (`<node>/lxc/<vmid>`). Returns null when there's no proxmox_lxc
|
|
218
|
+
* resource (e.g. an empty/fresh state).
|
|
219
|
+
*/
|
|
220
|
+
export function targetNodeFromTfState(state: {
|
|
221
|
+
resources?: Array<{
|
|
222
|
+
type?: string;
|
|
223
|
+
instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
|
|
224
|
+
}>;
|
|
225
|
+
}): string | null {
|
|
226
|
+
const lxc = state.resources?.find((r) => r.type === 'proxmox_lxc');
|
|
227
|
+
const attrs = lxc?.instances?.[0]?.attributes;
|
|
228
|
+
if (!attrs) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (attrs.target_node) {
|
|
232
|
+
return attrs.target_node;
|
|
233
|
+
}
|
|
234
|
+
// id format: "<node>/lxc/<vmid>"
|
|
235
|
+
return attrs.id?.split('/')[0] || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Read the node a module's container is currently deployed on, from its terraform
|
|
240
|
+
* state file. This is celilo's authoritative record of placement (ISS-0090).
|
|
241
|
+
* Returns null when no state exists yet (a first deploy) or it can't be read —
|
|
242
|
+
* the caller then falls back to the service `default_target_node`.
|
|
243
|
+
*/
|
|
244
|
+
async function readDeployedTargetNode(moduleId: string): Promise<string | null> {
|
|
245
|
+
const statePath = join(
|
|
246
|
+
getModuleStoragePath(),
|
|
247
|
+
moduleId,
|
|
248
|
+
'generated',
|
|
249
|
+
'terraform',
|
|
250
|
+
'terraform.tfstate',
|
|
251
|
+
);
|
|
252
|
+
if (!existsSync(statePath)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
return targetNodeFromTfState(JSON.parse(await readFile(statePath, 'utf-8')));
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
204
262
|
/**
|
|
205
263
|
* Discover template files in directory recursively
|
|
206
264
|
*
|
|
@@ -638,9 +696,28 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
638
696
|
storage: string;
|
|
639
697
|
};
|
|
640
698
|
|
|
699
|
+
// ISS-0090: target the node the container ACTUALLY lives on, not the
|
|
700
|
+
// service default. `default_target_node` governs only NEW placement; a
|
|
701
|
+
// changed default must NEVER relocate a running system. celilo's terraform
|
|
702
|
+
// state is its authoritative record of where it placed this container (kept
|
|
703
|
+
// in sync by deploy and by `module migrate`), so read the deployed node from
|
|
704
|
+
// there. Fall back to the default only when there's no state yet — a first
|
|
705
|
+
// deploy. Deliberate relocation is an explicit `module migrate`, not a
|
|
706
|
+
// side-effect of the default changing.
|
|
707
|
+
let targetNode = providerConfig.default_target_node;
|
|
708
|
+
const deployedNode = await readDeployedTargetNode(moduleId);
|
|
709
|
+
if (deployedNode) {
|
|
710
|
+
if (deployedNode !== targetNode) {
|
|
711
|
+
log.info(
|
|
712
|
+
`${moduleId} is already deployed on node '${deployedNode}' — targeting it (service default is '${providerConfig.default_target_node}'). Relocating requires a deliberate migration.`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
targetNode = deployedNode;
|
|
716
|
+
}
|
|
717
|
+
|
|
641
718
|
// Store provider config values as temporary config (similar to IPAM allocation)
|
|
642
719
|
const infraProperties = [
|
|
643
|
-
{ key: 'target_node', value:
|
|
720
|
+
{ key: 'target_node', value: targetNode },
|
|
644
721
|
{ key: 'lxc_template', value: providerConfig.lxc_template },
|
|
645
722
|
{ key: 'storage', value: providerConfig.storage },
|
|
646
723
|
];
|
|
@@ -649,9 +726,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
649
726
|
upsertModuleConfig(db, moduleId, `__infra_${prop.key}`, prop.value);
|
|
650
727
|
}
|
|
651
728
|
|
|
652
|
-
log.success(
|
|
653
|
-
`Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
|
|
654
|
-
);
|
|
729
|
+
log.success(`Infrastructure properties resolved from service: target_node=${targetNode}`);
|
|
655
730
|
}
|
|
656
731
|
}
|
|
657
732
|
|