@celilo/cli 0.5.0-alpha.7 → 0.5.0-alpha.9
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 +2 -2
- package/src/api-clients/proxmox.test.ts +78 -0
- package/src/api-clients/proxmox.ts +96 -1
- package/src/cli/command-registry.ts +32 -3
- 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/events.ts +8 -3
- 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-remove.ts +20 -9
- package/src/cli/commands/module-status.ts +15 -0
- package/src/cli/commands/module-upgrade.ts +10 -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/service-add-digitalocean.ts +120 -109
- package/src/cli/commands/service-add-proxmox.ts +275 -260
- package/src/cli/commands/service-reconfigure.ts +171 -153
- 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-init.ts +119 -128
- package/src/cli/completion.ts +15 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/service-credential.ts +54 -0
- package/src/services/bus-interview.ts +232 -0
- package/src/services/deploy-validation.test.ts +52 -2
- package/src/services/deploy-validation.ts +27 -36
- package/src/services/fleet-checks.test.ts +13 -0
- package/src/services/fleet-checks.ts +15 -0
- package/src/services/module-config.ts +12 -0
- package/src/services/module-deploy.ts +7 -6
- 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 +30 -0
- package/src/templates/generator.ts +86 -31
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { type DbClient, getDb } from '../db/client';
|
|
22
22
|
import { modules, secrets } from '../db/schema';
|
|
23
23
|
import type { ModuleManifest } from '../manifest/schema';
|
|
24
24
|
import { findMissingSecrets } from './config-interview';
|
|
25
|
-
import { findMissingRequiredVariables } from './deploy-validation';
|
|
25
|
+
import { findMissingBuildArtifacts, findMissingRequiredVariables } from './deploy-validation';
|
|
26
26
|
|
|
27
27
|
function manifestWithStringMapSecret(): ModuleManifest {
|
|
28
28
|
return {
|
|
@@ -293,3 +293,53 @@ describe('findMissingSecrets (shared)', () => {
|
|
|
293
293
|
expect(missing[0].value_pattern_message).toBe('min 8 chars');
|
|
294
294
|
});
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The deploy build-gate: the management server does NOT build modules from
|
|
299
|
+
* source. A module's declared artifacts must already be present on disk
|
|
300
|
+
* (shipped in the .netapp at `module package` / `module publish` time). Deploy
|
|
301
|
+
* verifies them; a missing artifact is a hard error, never a from-source
|
|
302
|
+
* rebuild on the management box (ISS-0131).
|
|
303
|
+
*/
|
|
304
|
+
describe('findMissingBuildArtifacts (deploy build-gate)', () => {
|
|
305
|
+
let dir: string;
|
|
306
|
+
|
|
307
|
+
beforeEach(() => {
|
|
308
|
+
dir = mkdtempSync(join(tmpdir(), 'build-gate-'));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
afterEach(() => {
|
|
312
|
+
rmSync(dir, { recursive: true, force: true });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
|
|
316
|
+
return {
|
|
317
|
+
celilo_contract: '1.0',
|
|
318
|
+
id: 'buildmod',
|
|
319
|
+
name: 'Build Module',
|
|
320
|
+
version: '1.0.0',
|
|
321
|
+
description: 'fixture',
|
|
322
|
+
build: { command: 'true', artifacts },
|
|
323
|
+
} as unknown as ModuleManifest;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
test('returns [] when the module declares no build section', () => {
|
|
327
|
+
const manifest = { celilo_contract: '1.0', id: 'm', version: '1.0.0' } as ModuleManifest;
|
|
328
|
+
expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('returns [] when all declared artifacts exist on disk (prebuilt .netapp)', () => {
|
|
332
|
+
mkdirSync(join(dir, 'dist'), { recursive: true });
|
|
333
|
+
writeFileSync(join(dir, 'dist', 'server'), 'binary');
|
|
334
|
+
writeFileSync(join(dir, 'dist', 'index.html'), '<html>');
|
|
335
|
+
const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html']);
|
|
336
|
+
expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('returns the missing artifacts when some are absent (no mgmt-server rebuild)', () => {
|
|
340
|
+
mkdirSync(join(dir, 'dist'), { recursive: true });
|
|
341
|
+
writeFileSync(join(dir, 'dist', 'server'), 'binary');
|
|
342
|
+
const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html', 'dist/db.sqlite']);
|
|
343
|
+
expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -14,14 +14,12 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
14
14
|
import { isPrivilegedCapability } from '../manifest/validate';
|
|
15
15
|
import { generateTemplates } from '../templates/generator';
|
|
16
16
|
import { findMissingSecrets } from './config-interview';
|
|
17
|
-
import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
|
|
18
17
|
|
|
19
18
|
export interface ValidationResult {
|
|
20
19
|
success: boolean;
|
|
21
20
|
error?: string;
|
|
22
21
|
warnings?: string[];
|
|
23
22
|
autoGenerated?: boolean;
|
|
24
|
-
autoBuilt?: boolean;
|
|
25
23
|
missingVariables?: Array<{
|
|
26
24
|
name: string;
|
|
27
25
|
source: 'user' | 'secret' | 'capability' | 'system';
|
|
@@ -41,9 +39,27 @@ export interface ValidationResult {
|
|
|
41
39
|
}>;
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Declared build artifacts that are missing on disk.
|
|
44
|
+
*
|
|
45
|
+
* Modules are built when their package is created: `module package` /
|
|
46
|
+
* `module publish` runs `manifest.build.command` and bakes the declared
|
|
47
|
+
* artifacts into the .netapp (cross-arch binaries and all). By deploy time —
|
|
48
|
+
* from the registry in production, or via `celilo package` in e2e — those
|
|
49
|
+
* artifacts are already on disk, so deploy only VERIFIES them. An empty result
|
|
50
|
+
* means the module is built; a non-empty result is a hard deploy error, because
|
|
51
|
+
* the management server does NOT build from source (ISS-0131: a from-source
|
|
52
|
+
* rebuild on the 3.7 GB management box deadlocked at `bun install`, then
|
|
53
|
+
* OOM-crashed nx workers, despite the .netapp shipping the binaries).
|
|
54
|
+
*/
|
|
55
|
+
export function findMissingBuildArtifacts(manifest: ModuleManifest, sourcePath: string): string[] {
|
|
56
|
+
const declared = manifest.build?.artifacts ?? [];
|
|
57
|
+
return declared.filter((rel) => !existsSync(join(sourcePath, rel)));
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* Validate module is ready for deployment and auto-prepare if needed
|
|
46
|
-
* Policy + Execution function - checks prerequisites and runs generate
|
|
62
|
+
* Policy + Execution function - checks prerequisites and runs generate if needed
|
|
47
63
|
*
|
|
48
64
|
* @param moduleId - Module identifier
|
|
49
65
|
* @param db - Database connection
|
|
@@ -54,7 +70,6 @@ export async function validateAndPrepareDeployment(
|
|
|
54
70
|
db: DbClient,
|
|
55
71
|
): Promise<ValidationResult> {
|
|
56
72
|
let autoGenerated = false;
|
|
57
|
-
let autoBuilt = false;
|
|
58
73
|
|
|
59
74
|
// Check module exists
|
|
60
75
|
const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
@@ -107,7 +122,6 @@ export async function validateAndPrepareDeployment(
|
|
|
107
122
|
return {
|
|
108
123
|
success: true,
|
|
109
124
|
autoGenerated: false,
|
|
110
|
-
autoBuilt: false,
|
|
111
125
|
missingVariables,
|
|
112
126
|
};
|
|
113
127
|
}
|
|
@@ -131,42 +145,19 @@ export async function validateAndPrepareDeployment(
|
|
|
131
145
|
|
|
132
146
|
autoGenerated = true;
|
|
133
147
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// let the deploy run on without binaries — exactly how Ansible ends up
|
|
143
|
-
// failing on a missing `src:` file.
|
|
144
|
-
const declaredArtifacts = manifest.build.artifacts ?? [];
|
|
145
|
-
const declaredArtifactsOk = declaredArtifacts.every((rel) =>
|
|
146
|
-
existsSync(join(module.sourcePath, rel)),
|
|
147
|
-
);
|
|
148
|
-
const needsBuild =
|
|
149
|
-
!buildStatus ||
|
|
150
|
-
buildStatus.status !== 'success' ||
|
|
151
|
-
!verifyArtifactsExist(buildStatus.artifacts) ||
|
|
152
|
-
(declaredArtifacts.length > 0 && !declaredArtifactsOk);
|
|
153
|
-
|
|
154
|
-
if (needsBuild) {
|
|
155
|
-
const buildResult = await buildModuleFromSource(moduleId, db);
|
|
156
|
-
if (!buildResult.success) {
|
|
157
|
-
return {
|
|
158
|
-
success: false,
|
|
159
|
-
error: `Auto-build failed: ${buildResult.error || 'Build failed'}`,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
autoBuilt = true;
|
|
163
|
-
}
|
|
148
|
+
// Verify build artifacts are present — the management server does NOT build
|
|
149
|
+
// modules from source (see findMissingBuildArtifacts / ISS-0131).
|
|
150
|
+
const missingArtifacts = findMissingBuildArtifacts(manifest, module.sourcePath);
|
|
151
|
+
if (missingArtifacts.length > 0) {
|
|
152
|
+
return {
|
|
153
|
+
success: false,
|
|
154
|
+
error: `Module '${moduleId}' is missing build artifacts that must ship in its package:\n${missingArtifacts.map((m) => ` - ${m}`).join('\n')}\n\nThe management server does not build modules from source. Build where the module is authored, then republish and update:\n celilo module publish <path> (or: celilo module build ${moduleId})\n celilo module update`,
|
|
155
|
+
};
|
|
164
156
|
}
|
|
165
157
|
|
|
166
158
|
return {
|
|
167
159
|
success: true,
|
|
168
160
|
autoGenerated,
|
|
169
|
-
autoBuilt,
|
|
170
161
|
};
|
|
171
162
|
}
|
|
172
163
|
|
|
@@ -378,6 +378,19 @@ describe('checkSubscribers + checkCapabilityProviders', () => {
|
|
|
378
378
|
expect(f.remediation).toContain('natIp');
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
it('SKIPS `.infra.<zone>` system-identity records (intentionally container-IP)', async () => {
|
|
382
|
+
seedFirewall('iptables', NAT_IP);
|
|
383
|
+
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
384
|
+
insertModule('forgejo', baseManifest({ id: 'forgejo', name: 'Forgejo' }));
|
|
385
|
+
seedSystem('forgejo', '10.0.20.14', 'dmz');
|
|
386
|
+
// The on-system-event handler registers <host>.infra.<domain> at the
|
|
387
|
+
// system's own container IP on purpose — a zone-side identity name, not a
|
|
388
|
+
// LAN-reachability record. The check must NOT flag it.
|
|
389
|
+
seedRecord('technitium', 'forgejo', 'forgejo.infra.celilo.computer', '10.0.20.14');
|
|
390
|
+
const f = await checkServiceDns(db);
|
|
391
|
+
expect(f.status).toBe('ok');
|
|
392
|
+
});
|
|
393
|
+
|
|
381
394
|
it('is ok when a record points at an internal-zone (LAN) system IP', async () => {
|
|
382
395
|
seedFirewall('iptables', NAT_IP);
|
|
383
396
|
insertModule('technitium', baseManifest({ id: 'technitium', name: 'Technitium' }));
|
|
@@ -40,6 +40,17 @@ import { resolveSubscription } from './module-subscriptions';
|
|
|
40
40
|
*/
|
|
41
41
|
const LAN_REACHABLE_ZONE = 'internal';
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Records under the dedicated `.infra.<zone>` label are system-IDENTITY names,
|
|
45
|
+
* registered by modules/technitium/scripts/on-system-event.ts at the system's
|
|
46
|
+
* own container IP ON PURPOSE — the zone-side name for in-zone / VPN access,
|
|
47
|
+
* deliberately kept separate from the natIp records LAN devices use (so a
|
|
48
|
+
* container-IP identity record doesn't clobber the natIp record public_web
|
|
49
|
+
* needs). They are NOT LAN-reachability records, so the natIp rule doesn't
|
|
50
|
+
* apply to them — the service-DNS check skips them.
|
|
51
|
+
*/
|
|
52
|
+
const SYSTEM_IDENTITY_HOST = /\.infra\./;
|
|
53
|
+
|
|
43
54
|
export type FleetFindingStatus = 'ok' | 'warn' | 'fail';
|
|
44
55
|
|
|
45
56
|
/**
|
|
@@ -600,6 +611,10 @@ export async function checkServiceDns(db: DbClient): Promise<FleetFinding> {
|
|
|
600
611
|
const atContainer: string[] = [];
|
|
601
612
|
const atOther: string[] = [];
|
|
602
613
|
for (const r of records) {
|
|
614
|
+
// `.infra.<zone>` system-identity records are intentionally container-IP
|
|
615
|
+
// (zone-side names, not LAN-reachability records) — not subject to the
|
|
616
|
+
// natIp rule.
|
|
617
|
+
if (SYSTEM_IDENTITY_HOST.test(r.host)) continue;
|
|
603
618
|
if (r.ip === natIp) continue;
|
|
604
619
|
const owner = ipZone.get(r.ip);
|
|
605
620
|
if (owner && owner.zone !== LAN_REACHABLE_ZONE) {
|
|
@@ -106,6 +106,18 @@ export function upsertModuleConfig(
|
|
|
106
106
|
.run();
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Delete a module config row if present (no-op if absent). Used to drop a
|
|
111
|
+
* derived/cached key that should no longer persist — e.g. `__infra_target_node`,
|
|
112
|
+
* now resolved live from Proxmox each generate rather than cached in the DB
|
|
113
|
+
* (ISS-0090: "the DB stores intent, Proxmox reports reality").
|
|
114
|
+
*/
|
|
115
|
+
export function deleteModuleConfig(db: DbClient, moduleId: string, key: string): void {
|
|
116
|
+
db.delete(moduleConfigs)
|
|
117
|
+
.where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
|
|
118
|
+
.run();
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
/**
|
|
110
122
|
* Parse a stored module_configs row into its canonical typed value.
|
|
111
123
|
* Throws if valueJson is null — that's a row written before the
|
|
@@ -48,7 +48,6 @@ export interface DeployResult {
|
|
|
48
48
|
phases: {
|
|
49
49
|
validation?: boolean;
|
|
50
50
|
autoGenerated?: boolean;
|
|
51
|
-
autoBuilt?: boolean;
|
|
52
51
|
planning?: boolean;
|
|
53
52
|
terraformInit?: boolean;
|
|
54
53
|
terraformPlan?: boolean;
|
|
@@ -353,7 +352,6 @@ async function deployModuleImpl(
|
|
|
353
352
|
const validation = await validateAndPrepareDeployment(moduleId, db);
|
|
354
353
|
phases.validation = validation.success;
|
|
355
354
|
phases.autoGenerated = validation.autoGenerated;
|
|
356
|
-
phases.autoBuilt = validation.autoBuilt;
|
|
357
355
|
if (!validation.success) {
|
|
358
356
|
return {
|
|
359
357
|
success: false,
|
|
@@ -532,9 +530,7 @@ async function deployModuleImpl(
|
|
|
532
530
|
}
|
|
533
531
|
}
|
|
534
532
|
|
|
535
|
-
log.success(
|
|
536
|
-
validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
|
|
537
|
-
);
|
|
533
|
+
log.success('Templates generated');
|
|
538
534
|
|
|
539
535
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
540
536
|
if (manifest.hooks?.validate_config) {
|
|
@@ -911,7 +907,12 @@ async function deployModuleImpl(
|
|
|
911
907
|
lines.push(` ${key} = ${value}`);
|
|
912
908
|
}
|
|
913
909
|
if (resolution.skipped.length > 0) {
|
|
914
|
-
|
|
910
|
+
// These are infrastructure-managed vars with no value this deploy (e.g.
|
|
911
|
+
// vmid/target_node on a machine deploy). NOT honored operator overrides —
|
|
912
|
+
// such keys are rejected at `config set` (ISS-0069); don't imply otherwise.
|
|
913
|
+
lines.push(
|
|
914
|
+
` (auto-managed, not applicable this deploy: ${resolution.skipped.join(', ')})`,
|
|
915
|
+
);
|
|
915
916
|
}
|
|
916
917
|
log.success(lines.join('\n'));
|
|
917
918
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { DeployedSystem } from '@celilo/capabilities';
|
|
3
|
+
import { formatPlacementLine, resolveOnePlacement } from './placement-reconcile';
|
|
4
|
+
|
|
5
|
+
function sys(
|
|
6
|
+
overrides: Partial<DeployedSystem> & { infrastructure: DeployedSystem['infrastructure'] },
|
|
7
|
+
): DeployedSystem {
|
|
8
|
+
return {
|
|
9
|
+
name: 'main',
|
|
10
|
+
hostname: 'caddy',
|
|
11
|
+
ipv4_address: '10.0.10.10',
|
|
12
|
+
zone: 'dmz',
|
|
13
|
+
...overrides,
|
|
14
|
+
} as DeployedSystem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SVC = 'svc-1';
|
|
18
|
+
const cs = (vmid?: number) =>
|
|
19
|
+
sys({
|
|
20
|
+
infrastructure: {
|
|
21
|
+
type: 'container_service',
|
|
22
|
+
serviceId: SVC,
|
|
23
|
+
...(vmid != null ? { vmid } : {}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('resolveOnePlacement (ISS-0060 — node reconciled from Proxmox, not a cached DB value)', () => {
|
|
28
|
+
test('machine-pool system → machine (no Proxmox node)', () => {
|
|
29
|
+
const s = sys({ infrastructure: { type: 'machine', machineId: 'm1' } });
|
|
30
|
+
expect(resolveOnePlacement(s, new Map(), new Set(), new Set())).toEqual({ kind: 'machine' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('non-Proxmox container service → other', () => {
|
|
34
|
+
expect(resolveOnePlacement(cs(200), new Map(), new Set(), new Set([SVC]))).toEqual({
|
|
35
|
+
kind: 'other',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('container_service without a vmid → uncreated', () => {
|
|
40
|
+
expect(resolveOnePlacement(cs(undefined), new Map(), new Set(), new Set())).toEqual({
|
|
41
|
+
kind: 'uncreated',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Proxmox unreachable for the service → unreachable (never a stale fallback)', () => {
|
|
46
|
+
expect(resolveOnePlacement(cs(200), new Map(), new Set([SVC]), new Set())).toEqual({
|
|
47
|
+
kind: 'unreachable',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('vmid present in the cluster → node (the reconciled reality)', () => {
|
|
52
|
+
expect(resolveOnePlacement(cs(200), new Map([[200, 'node2']]), new Set(), new Set())).toEqual({
|
|
53
|
+
kind: 'node',
|
|
54
|
+
node: 'node2',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('vmid absent from the cluster → absent', () => {
|
|
59
|
+
expect(resolveOnePlacement(cs(200), new Map([[999, 'node2']]), new Set(), new Set())).toEqual({
|
|
60
|
+
kind: 'absent',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('formatPlacementLine', () => {
|
|
66
|
+
const s = cs(200);
|
|
67
|
+
|
|
68
|
+
test('node resolution shows the real node + vmid', () => {
|
|
69
|
+
expect(formatPlacementLine(s, { kind: 'node', node: 'node2' })).toBe(
|
|
70
|
+
'caddy (vmid 200) → node2 (zone dmz)',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('unreachable is explicit — no silent stale value', () => {
|
|
75
|
+
expect(formatPlacementLine(s, { kind: 'unreachable' })).toContain('Proxmox unreachable');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('absent flags a vmid not in the cluster', () => {
|
|
79
|
+
expect(formatPlacementLine(s, { kind: 'absent' })).toContain('not found in Proxmox');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('machine line omits vmid/node', () => {
|
|
83
|
+
const m = sys({ hostname: 'iot', zone: 'internal', infrastructure: { type: 'machine' } });
|
|
84
|
+
expect(formatPlacementLine(m, { kind: 'machine' })).toBe('iot — machine (zone internal)');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -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
|
},
|