@celilo/cli 0.5.0-alpha.9 → 0.6.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/package.json +2 -2
- package/src/api-clients/proxmox.test.ts +30 -20
- package/src/api-clients/proxmox.ts +34 -23
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/commands/module-update.test.ts +252 -0
- package/src/cli/commands/module-update.ts +571 -0
- package/src/cli/commands/module-upgrade.test.ts +55 -225
- package/src/cli/commands/module-upgrade.ts +205 -500
- package/src/cli/commands/proxmox-vm-template-build.ts +25 -18
- package/src/cli/commands/system-update.ts +3 -3
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/hooks/capability-loader.ts +7 -3
- package/src/manifest/schema.ts +3 -1
- package/src/services/deploy-posture.test.ts +106 -0
- package/src/services/deploy-posture.ts +87 -0
- package/src/services/module-subscriptions.test.ts +32 -2
- package/src/services/proxmox-state-recovery.ts +26 -7
- package/src/templates/generator.test.ts +22 -1
- package/src/templates/generator.ts +22 -8
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
deleteVm,
|
|
26
26
|
downloadCloudImage,
|
|
27
27
|
findFreeTemplateVmid,
|
|
28
|
-
|
|
28
|
+
findImportStorage,
|
|
29
29
|
listVmTemplates,
|
|
30
30
|
pollTaskUntilDone,
|
|
31
31
|
} from '../../api-clients/proxmox';
|
|
@@ -33,7 +33,12 @@ import { FuelGauge } from '../fuel-gauge';
|
|
|
33
33
|
|
|
34
34
|
const UBUNTU_2404_AMD64_URL =
|
|
35
35
|
'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img';
|
|
36
|
-
|
|
36
|
+
// Stored under the storage's import/ namespace, so the filename must end in an
|
|
37
|
+
// extension PVE accepts for `import` content (UPLOAD_IMPORT_EXT_RE_1 =
|
|
38
|
+
// ova|qcow2|raw|vmdk) — a `.img` is rejected with "invalid filename or wrong
|
|
39
|
+
// extension". Ubuntu's cloud image is qcow2 format despite the upstream `.img`
|
|
40
|
+
// URL, so we download it under a `.qcow2` name. The URL above is unchanged.
|
|
41
|
+
const UBUNTU_2404_AMD64_FILENAME = 'noble-server-cloudimg-amd64.qcow2';
|
|
37
42
|
const DEFAULT_TEMPLATE_NAME = 'ubuntu-2404-cloudinit';
|
|
38
43
|
|
|
39
44
|
export interface VmTemplateBuildParams {
|
|
@@ -69,21 +74,23 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
|
|
|
69
74
|
let vmid: number | null = null;
|
|
70
75
|
|
|
71
76
|
try {
|
|
72
|
-
// Step 1: find
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
// Step 1: find import-capable storage. The cloud image is consumed via the
|
|
78
|
+
// VM-create `import-from=`, which requires an `import`/`images` source — a
|
|
79
|
+
// `.img` in the `iso/` namespace is rejected (`has wrong type 'iso'`).
|
|
80
|
+
gauge.addOutput('Finding import-capable storage on the node…');
|
|
81
|
+
const importStorageResult = await findImportStorage(credentials, nodeName);
|
|
82
|
+
if (!importStorageResult.success) {
|
|
83
|
+
throw new Error(`Could not list node storage: ${importStorageResult.message}`);
|
|
77
84
|
}
|
|
78
|
-
const
|
|
79
|
-
if (!
|
|
85
|
+
const importStorage = importStorageResult.data;
|
|
86
|
+
if (!importStorage) {
|
|
80
87
|
throw new Error(
|
|
81
|
-
'No
|
|
82
|
-
"Enable '
|
|
88
|
+
'No import-capable storage found on the node. ' +
|
|
89
|
+
"Enable 'import' content on a storage (typically 'local') in the Proxmox UI: " +
|
|
83
90
|
'Datacenter → Storage → Edit → Content.',
|
|
84
91
|
);
|
|
85
92
|
}
|
|
86
|
-
gauge.addOutput(`
|
|
93
|
+
gauge.addOutput(`Import storage: ${importStorage}`);
|
|
87
94
|
|
|
88
95
|
// Step 2: find a free template VMID
|
|
89
96
|
gauge.addOutput('Finding a free template VMID (>= 9000)…');
|
|
@@ -92,21 +99,21 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
|
|
|
92
99
|
vmid = vmidResult.data;
|
|
93
100
|
gauge.addOutput(`Using VMID ${vmid}`);
|
|
94
101
|
|
|
95
|
-
// Step 3: download cloud image to
|
|
102
|
+
// Step 3: download cloud image to import storage
|
|
96
103
|
gauge.addOutput(
|
|
97
|
-
`Downloading Ubuntu 24.04 cloud image to '${
|
|
104
|
+
`Downloading Ubuntu 24.04 cloud image to '${importStorage}' (~600 MB, may take several minutes)…`,
|
|
98
105
|
);
|
|
99
106
|
const downloadResult = await downloadCloudImage(
|
|
100
107
|
credentials,
|
|
101
108
|
nodeName,
|
|
102
|
-
|
|
109
|
+
importStorage,
|
|
103
110
|
UBUNTU_2404_AMD64_URL,
|
|
104
111
|
UBUNTU_2404_AMD64_FILENAME,
|
|
105
112
|
);
|
|
106
113
|
if (!downloadResult.success) {
|
|
107
114
|
if (downloadResult.message.includes('403') || downloadResult.message.includes('not found')) {
|
|
108
115
|
throw new Error(
|
|
109
|
-
`Proxmox storage download-url endpoint rejected the request.
|
|
116
|
+
`Proxmox storage download-url endpoint rejected the request. Downloading to 'import' content requires Proxmox VE 8.2+. Error: ${downloadResult.message}`,
|
|
110
117
|
);
|
|
111
118
|
}
|
|
112
119
|
throw new Error(`Cloud image download failed: ${downloadResult.message}`);
|
|
@@ -116,14 +123,14 @@ export async function buildCloudInitTemplate(params: VmTemplateBuildParams): Pro
|
|
|
116
123
|
gauge.addOutput('Cloud image downloaded');
|
|
117
124
|
|
|
118
125
|
// Step 4: create VM from cloud image
|
|
119
|
-
const
|
|
126
|
+
const imageVolid = buildCloudImageVolid(importStorage, UBUNTU_2404_AMD64_FILENAME);
|
|
120
127
|
gauge.addOutput(`Creating VM ${vmid} '${DEFAULT_TEMPLATE_NAME}' (importing disk)…`);
|
|
121
128
|
const createResult = await createVmFromCloudImage({
|
|
122
129
|
credentials,
|
|
123
130
|
nodeName,
|
|
124
131
|
vmid,
|
|
125
132
|
name: DEFAULT_TEMPLATE_NAME,
|
|
126
|
-
|
|
133
|
+
imageVolid,
|
|
127
134
|
diskStorage,
|
|
128
135
|
});
|
|
129
136
|
if (!createResult.success) {
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import type { SystemUpdateResult } from '../../services/update/types';
|
|
42
42
|
import { getFlag, hasFlag } from '../parser';
|
|
43
43
|
import type { CommandResult } from '../types';
|
|
44
|
-
import {
|
|
44
|
+
import { fetchAndUpdate } from './module-update';
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Packages we manage via the global bun install. The self-update step
|
|
@@ -275,7 +275,7 @@ async function buildSnapshots(
|
|
|
275
275
|
* - `backup` calls `createModuleBackup`. Modules without an
|
|
276
276
|
* `on_backup` hook return ok (nothing to back up; not an error).
|
|
277
277
|
* - `upgrade` fetches the latest version from the registry and runs
|
|
278
|
-
* the in-place upgrade (`
|
|
278
|
+
* the in-place upgrade (`fetchAndUpdate` from module-upgrade.ts).
|
|
279
279
|
* Same code path that `module update` uses for its sweep mode.
|
|
280
280
|
* - `deploy` calls `deployModule`.
|
|
281
281
|
* - `health` calls `runModuleHealthCheck`.
|
|
@@ -314,7 +314,7 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
|
|
|
314
314
|
if (!latest) {
|
|
315
315
|
return { ok: false, error: `${moduleId}: no non-yanked version` };
|
|
316
316
|
}
|
|
317
|
-
const result = await
|
|
317
|
+
const result = await fetchAndUpdate(registry, moduleId, latest.vers, db, {});
|
|
318
318
|
if (result.status === 'success') return { ok: true };
|
|
319
319
|
if (result.status === 'failed') return { ok: false, error: result.error };
|
|
320
320
|
// 'skipped' isn't expected here (the module IS installed), but
|
package/src/cli/completion.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -63,6 +63,7 @@ import { handleModuleShowConfig, handleModuleShowZone } from './commands/module-
|
|
|
63
63
|
import { handleModuleStatus } from './commands/module-status';
|
|
64
64
|
import { handleModuleTerraformUnlock } from './commands/module-terraform-unlock';
|
|
65
65
|
import { handleModuleTypesCheck, handleModuleTypesGenerate } from './commands/module-types';
|
|
66
|
+
import { handleModuleUpdate } from './commands/module-update';
|
|
66
67
|
import { handleModuleUpgrade } from './commands/module-upgrade';
|
|
67
68
|
import { moduleVerify } from './commands/module-verify';
|
|
68
69
|
import { handlePackage } from './commands/package';
|
|
@@ -1266,6 +1267,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1266
1267
|
case 'remove':
|
|
1267
1268
|
return handleModuleRemove(parsed.args, parsed.flags);
|
|
1268
1269
|
case 'update':
|
|
1270
|
+
return handleModuleUpdate(parsed.args, parsed.flags);
|
|
1271
|
+
case 'upgrade':
|
|
1269
1272
|
return handleModuleUpgrade(parsed.args, parsed.flags);
|
|
1270
1273
|
case 'audit':
|
|
1271
1274
|
return moduleAudit(parsed.args);
|
|
@@ -63,9 +63,13 @@ const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName:
|
|
|
63
63
|
script: 'scripts/idp-functions.ts',
|
|
64
64
|
legacyFactoryName: 'createIdp',
|
|
65
65
|
},
|
|
66
|
-
|
|
67
|
-
script: 'scripts/
|
|
68
|
-
legacyFactoryName: '
|
|
66
|
+
source_forge: {
|
|
67
|
+
script: 'scripts/source-forge-functions.ts',
|
|
68
|
+
legacyFactoryName: 'createForgejoSourceForge',
|
|
69
|
+
},
|
|
70
|
+
registry_publish: {
|
|
71
|
+
script: 'scripts/registry-publish-functions.ts',
|
|
72
|
+
legacyFactoryName: 'default',
|
|
69
73
|
},
|
|
70
74
|
dhcp_server: {
|
|
71
75
|
script: 'scripts/dhcp-server-functions.ts',
|
package/src/manifest/schema.ts
CHANGED
|
@@ -237,7 +237,9 @@ export type ComputedField = z.infer<typeof ComputedFieldSchema>;
|
|
|
237
237
|
export const CapabilityProviderSchema = z.object({
|
|
238
238
|
name: z.string().min(1),
|
|
239
239
|
version: z.string().min(1),
|
|
240
|
-
|
|
240
|
+
// A purely imperative capability (functions-only, no cross-module data —
|
|
241
|
+
// e.g. registry_publish) carries no `data` block; default to {}.
|
|
242
|
+
data: z.record(z.unknown()).default({}),
|
|
241
243
|
/**
|
|
242
244
|
* Computed fields, merged into the capability's data namespace at access
|
|
243
245
|
* time. A consumer reads `$capability:<name>.<computed-field>` exactly like
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { classifyVersionDelta, parseVersion, resolveDeployPosture } from './deploy-posture';
|
|
3
|
+
|
|
4
|
+
describe('parseVersion', () => {
|
|
5
|
+
test('parses major.minor.patch+build', () => {
|
|
6
|
+
expect(parseVersion('1.2.3+28')).toEqual({
|
|
7
|
+
major: 1,
|
|
8
|
+
minor: 2,
|
|
9
|
+
patch: 3,
|
|
10
|
+
prerelease: '',
|
|
11
|
+
build: '28',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('parses a prerelease', () => {
|
|
16
|
+
expect(parseVersion('0.5.0-alpha.9')).toEqual({
|
|
17
|
+
major: 0,
|
|
18
|
+
minor: 5,
|
|
19
|
+
patch: 0,
|
|
20
|
+
prerelease: 'alpha.9',
|
|
21
|
+
build: '',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('tolerates a bare version', () => {
|
|
26
|
+
expect(parseVersion('2.0.0')).toEqual({
|
|
27
|
+
major: 2,
|
|
28
|
+
minor: 0,
|
|
29
|
+
patch: 0,
|
|
30
|
+
prerelease: '',
|
|
31
|
+
build: '',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('classifyVersionDelta (highest-order component that changed)', () => {
|
|
37
|
+
test('major / minor / patch', () => {
|
|
38
|
+
expect(classifyVersionDelta('1.0.0', '2.0.0')).toBe('major');
|
|
39
|
+
expect(classifyVersionDelta('1.0.0', '1.1.0')).toBe('minor');
|
|
40
|
+
expect(classifyVersionDelta('1.0.0', '1.0.1')).toBe('patch');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('build-metadata-only change is a revision', () => {
|
|
44
|
+
expect(classifyVersionDelta('1.0.1+27', '1.0.1+28')).toBe('revision');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('prerelease-only change counts as patch (still fast)', () => {
|
|
48
|
+
expect(classifyVersionDelta('0.5.0-alpha.8', '0.5.0-alpha.9')).toBe('patch');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('identical versions → none', () => {
|
|
52
|
+
expect(classifyVersionDelta('1.0.1+28', '1.0.1+28')).toBe('none');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('highest-order wins when several components differ', () => {
|
|
56
|
+
expect(classifyVersionDelta('1.0.1+5', '1.1.0+1')).toBe('minor');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('resolveDeployPosture (precedence)', () => {
|
|
61
|
+
test('default by-semver: revision + patch ⇒ fast', () => {
|
|
62
|
+
expect(resolveDeployPosture({ installed: '1.0.1+1', next: '1.0.1+2' }).posture).toBe('fast');
|
|
63
|
+
expect(resolveDeployPosture({ installed: '1.0.1', next: '1.0.2' }).posture).toBe('fast');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('default by-semver: minor + major ⇒ safe', () => {
|
|
67
|
+
expect(resolveDeployPosture({ installed: '1.0.0', next: '1.1.0' }).posture).toBe('safe');
|
|
68
|
+
expect(resolveDeployPosture({ installed: '1.0.0', next: '2.0.0' }).posture).toBe('safe');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('per-release deploy_posture overrides the semver default', () => {
|
|
72
|
+
// a minor bump would default to safe; the release pins fast.
|
|
73
|
+
const r = resolveDeployPosture({ installed: '1.0.0', next: '1.1.0', releasePosture: 'fast' });
|
|
74
|
+
expect(r.posture).toBe('fast');
|
|
75
|
+
expect(r.reason).toContain('release deploy_posture');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('module always-safe is a hard floor — beats a per-release fast', () => {
|
|
79
|
+
const r = resolveDeployPosture({
|
|
80
|
+
installed: '1.0.1',
|
|
81
|
+
next: '1.0.2',
|
|
82
|
+
releasePosture: 'fast',
|
|
83
|
+
modulePolicy: 'always-safe',
|
|
84
|
+
});
|
|
85
|
+
expect(r.posture).toBe('safe');
|
|
86
|
+
expect(r.reason).toContain('always-safe');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('module always-fast forces fast on a minor bump (when no release override)', () => {
|
|
90
|
+
expect(
|
|
91
|
+
resolveDeployPosture({ installed: '1.0.0', next: '1.1.0', modulePolicy: 'always-fast' })
|
|
92
|
+
.posture,
|
|
93
|
+
).toBe('fast');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a per-release safe still beats always-fast (release intent wins below the floor)', () => {
|
|
97
|
+
expect(
|
|
98
|
+
resolveDeployPosture({
|
|
99
|
+
installed: '1.0.1',
|
|
100
|
+
next: '1.0.2',
|
|
101
|
+
releasePosture: 'safe',
|
|
102
|
+
modulePolicy: 'always-fast',
|
|
103
|
+
}).posture,
|
|
104
|
+
).toBe('safe');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy posture — fast vs. safe — for `celilo module upgrade` (ISS-0138,
|
|
3
|
+
* v2/BUILD_BUS.md).
|
|
4
|
+
*
|
|
5
|
+
* CD is CI-driven, so no operator is present to pass `--no-backup`. The posture
|
|
6
|
+
* (fast = skip backup + extended verify; safe = backup + full verify) is
|
|
7
|
+
* DERIVED, with this precedence:
|
|
8
|
+
* 1. per-module `upgrade_policy: always-safe` — a hard floor (always safe).
|
|
9
|
+
* 2. per-release `deploy_posture` stamped in the .netapp release metadata.
|
|
10
|
+
* 3. per-module `upgrade_policy: always-fast`.
|
|
11
|
+
* 4. default: the semver delta of installed → next (revision/patch = fast;
|
|
12
|
+
* minor/major = safe).
|
|
13
|
+
*
|
|
14
|
+
* celilo reads version NUMBERS, never changesets — the default needs only the
|
|
15
|
+
* versions. All functions here are pure (Rule 10).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type DeployPosture = 'fast' | 'safe';
|
|
19
|
+
export type VersionDelta = 'none' | 'revision' | 'patch' | 'minor' | 'major';
|
|
20
|
+
export type UpgradePolicy = 'by-semver' | 'always-safe' | 'always-fast';
|
|
21
|
+
|
|
22
|
+
export interface ParsedVersion {
|
|
23
|
+
major: number;
|
|
24
|
+
minor: number;
|
|
25
|
+
patch: number;
|
|
26
|
+
/** prerelease tag, e.g. "alpha.2"; '' when absent. */
|
|
27
|
+
prerelease: string;
|
|
28
|
+
/** build metadata — celilo's `+N` registry revision; '' when absent. */
|
|
29
|
+
build: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parse a celilo module version: `major.minor.patch[-prerelease][+build]`. */
|
|
33
|
+
export function parseVersion(version: string): ParsedVersion {
|
|
34
|
+
const [coreAndPre = '', build = ''] = version.split('+');
|
|
35
|
+
const [core = '', prerelease = ''] = coreAndPre.split('-');
|
|
36
|
+
const parts = core.split('.');
|
|
37
|
+
const num = (i: number) => Number.parseInt(parts[i] ?? '', 10) || 0;
|
|
38
|
+
return { major: num(0), minor: num(1), patch: num(2), prerelease, build };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Classify the delta between an installed version and the next one by the
|
|
43
|
+
* highest-order component that changed. Assumes `next` >= `installed` (the poll
|
|
44
|
+
* only upgrades). A prerelease-only change counts as `patch` (still fast); a
|
|
45
|
+
* build-metadata-only change (`+N`) is `revision`.
|
|
46
|
+
*/
|
|
47
|
+
export function classifyVersionDelta(installed: string, next: string): VersionDelta {
|
|
48
|
+
const a = parseVersion(installed);
|
|
49
|
+
const b = parseVersion(next);
|
|
50
|
+
if (a.major !== b.major) return 'major';
|
|
51
|
+
if (a.minor !== b.minor) return 'minor';
|
|
52
|
+
if (a.patch !== b.patch) return 'patch';
|
|
53
|
+
if (a.prerelease !== b.prerelease) return 'patch';
|
|
54
|
+
if (a.build !== b.build) return 'revision';
|
|
55
|
+
return 'none';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the deploy posture for an upgrade. Precedence: `always-safe` floor →
|
|
60
|
+
* per-release override → `always-fast` → semver-delta default. Returns the
|
|
61
|
+
* posture plus a short reason for operator-visible logging.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveDeployPosture(opts: {
|
|
64
|
+
installed: string;
|
|
65
|
+
next: string;
|
|
66
|
+
releasePosture?: DeployPosture | null;
|
|
67
|
+
modulePolicy?: UpgradePolicy;
|
|
68
|
+
}): { posture: DeployPosture; reason: string } {
|
|
69
|
+
const policy = opts.modulePolicy ?? 'by-semver';
|
|
70
|
+
|
|
71
|
+
if (policy === 'always-safe') {
|
|
72
|
+
return { posture: 'safe', reason: 'module upgrade_policy=always-safe' };
|
|
73
|
+
}
|
|
74
|
+
if (opts.releasePosture) {
|
|
75
|
+
return {
|
|
76
|
+
posture: opts.releasePosture,
|
|
77
|
+
reason: `release deploy_posture=${opts.releasePosture}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (policy === 'always-fast') {
|
|
81
|
+
return { posture: 'fast', reason: 'module upgrade_policy=always-fast' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const delta = classifyVersionDelta(opts.installed, opts.next);
|
|
85
|
+
const posture: DeployPosture = delta === 'minor' || delta === 'major' ? 'safe' : 'fast';
|
|
86
|
+
return { posture, reason: `semver delta=${delta}` };
|
|
87
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
7
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
8
|
import { closeDb, getDb } from '../db/client';
|
|
9
|
+
import { ModuleManifestSchema } from '../manifest/schema';
|
|
7
10
|
import { ModuleSubscriptionSchema } from '../manifest/schema';
|
|
8
11
|
import type { ModuleManifest } from '../manifest/schema';
|
|
9
12
|
import { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
|
|
@@ -341,3 +344,30 @@ describe('resyncAllSubscriptions (ISS-0088)', () => {
|
|
|
341
344
|
expect(subscriberNames()).toEqual(['caddy.reconcile']);
|
|
342
345
|
});
|
|
343
346
|
});
|
|
347
|
+
|
|
348
|
+
describe('build-bus registry-poll wiring (ISS-0139)', () => {
|
|
349
|
+
// The CD poll is wired as a celilo-mgmt manifest subscription so it registers
|
|
350
|
+
// ONLY on the management host. Guard the exact handler/pattern against drift.
|
|
351
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
352
|
+
|
|
353
|
+
it('celilo-mgmt subscribes the registry poll to timer.tick.15m', () => {
|
|
354
|
+
const manifestPath = join(REPO_ROOT, 'modules', 'celilo-mgmt', 'manifest.yml');
|
|
355
|
+
const manifest = ModuleManifestSchema.parse(parseYaml(readFileSync(manifestPath, 'utf-8')));
|
|
356
|
+
|
|
357
|
+
const poll = manifest.subscriptions?.find((s) => s.name === 'registry-poll');
|
|
358
|
+
expect(poll).toBeDefined();
|
|
359
|
+
expect(poll?.pattern).toBe('timer.tick.15m');
|
|
360
|
+
// A literal handler command (the CLI poll), not a hook — see manifest comment.
|
|
361
|
+
expect(poll?.handler).toBe('celilo module upgrade');
|
|
362
|
+
expect(poll?.hook).toBeUndefined();
|
|
363
|
+
|
|
364
|
+
const resolved = resolveSubscription(
|
|
365
|
+
// biome-ignore lint/style/noNonNullAssertion: asserted defined above
|
|
366
|
+
poll!,
|
|
367
|
+
'celilo-mgmt',
|
|
368
|
+
'/modules/celilo-mgmt',
|
|
369
|
+
);
|
|
370
|
+
expect(resolved.name).toBe('celilo-mgmt.registry-poll');
|
|
371
|
+
expect(resolved.handler).toBe('celilo module upgrade');
|
|
372
|
+
});
|
|
373
|
+
});
|
|
@@ -23,14 +23,29 @@ interface TerraformState {
|
|
|
23
23
|
instances?: Array<{
|
|
24
24
|
attributes?: {
|
|
25
25
|
vmid?: number;
|
|
26
|
+
// proxmox_lxc carries the IP here ("10.0.10.12/24" or "dhcp").
|
|
26
27
|
network?: Array<{
|
|
27
28
|
ip?: string;
|
|
28
29
|
}>;
|
|
30
|
+
// proxmox_vm_qemu carries the cloud-init IP here ("ip=10.0.10.12/24,gw=…").
|
|
31
|
+
ipconfig0?: string;
|
|
29
32
|
};
|
|
30
33
|
}>;
|
|
31
34
|
}>;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Extract the CIDR from a qemu cloud-init `ipconfig0` string
|
|
39
|
+
* ("ip=10.0.10.12/24,gw=10.0.10.1" → "10.0.10.12/24"). Returns undefined for a
|
|
40
|
+
* DHCP config or anything without an explicit `ip=`.
|
|
41
|
+
*/
|
|
42
|
+
function extractIpFromIpconfig(ipconfig0: string | undefined): string | undefined {
|
|
43
|
+
if (!ipconfig0) return undefined;
|
|
44
|
+
const match = ipconfig0.match(/(?:^|,)ip=([^,]+)/);
|
|
45
|
+
const value = match?.[1];
|
|
46
|
+
return value && value !== 'dhcp' ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
/**
|
|
35
50
|
* Ensure module_configs has vmid and target_ip from Terraform state
|
|
36
51
|
* This recovers from state drift scenarios where container was deleted/recreated
|
|
@@ -82,19 +97,23 @@ export async function ensureProxmoxConfigFromState(
|
|
|
82
97
|
);
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
// Find proxmox_lxc
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
// Find the provisioned guest: an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu).
|
|
101
|
+
const guest = state.resources?.find(
|
|
102
|
+
(r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
|
|
103
|
+
);
|
|
104
|
+
if (!guest || !guest.instances || guest.instances.length === 0) {
|
|
105
|
+
throw new Error('No proxmox_lxc or proxmox_vm_qemu resource found in Terraform state');
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
const attributes =
|
|
108
|
+
const attributes = guest.instances[0].attributes;
|
|
92
109
|
if (!attributes) {
|
|
93
|
-
throw new Error(
|
|
110
|
+
throw new Error(`No attributes found in ${guest.type} resource`);
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
const vmid = attributes.vmid;
|
|
97
|
-
|
|
114
|
+
// LXC exposes the IP at network[0].ip; a qemu VM exposes it via the cloud-init
|
|
115
|
+
// ipconfig0 ("ip=<cidr>,gw=<gw>"). Use whichever the guest type provides.
|
|
116
|
+
const containerIp = attributes.network?.[0]?.ip ?? extractIpFromIpconfig(attributes.ipconfig0);
|
|
98
117
|
|
|
99
118
|
if (!vmid) {
|
|
100
119
|
throw new Error('vmid not found in Terraform state');
|
|
@@ -790,7 +790,28 @@ describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placem
|
|
|
790
790
|
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
791
791
|
});
|
|
792
792
|
|
|
793
|
-
test('
|
|
793
|
+
test('reads the node from a proxmox_vm_qemu target_node attribute (type:vm)', () => {
|
|
794
|
+
const state = {
|
|
795
|
+
resources: [
|
|
796
|
+
{
|
|
797
|
+
type: 'proxmox_vm_qemu',
|
|
798
|
+
instances: [{ attributes: { target_node: 'node3', id: 'node3/qemu/208' } }],
|
|
799
|
+
},
|
|
800
|
+
],
|
|
801
|
+
};
|
|
802
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test('parses the qemu resource id when target_node is absent (type:vm)', () => {
|
|
806
|
+
const state = {
|
|
807
|
+
resources: [
|
|
808
|
+
{ type: 'proxmox_vm_qemu', instances: [{ attributes: { id: 'node3/qemu/208' } }] },
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
expect(targetNodeFromTfState(state)).toBe('node3');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
test('returns null when there is no proxmox_lxc/proxmox_vm_qemu resource (fresh/empty state)', () => {
|
|
794
815
|
expect(targetNodeFromTfState({ resources: [] })).toBeNull();
|
|
795
816
|
expect(targetNodeFromTfState({})).toBeNull();
|
|
796
817
|
});
|
|
@@ -216,10 +216,11 @@ export function injectProxmoxDns(content: string, hasNameserver: boolean): strin
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Extract the node a
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* resource id (`<node>/lxc/<vmid>`
|
|
219
|
+
* Extract the node a Proxmox guest is deployed on from a parsed terraform state
|
|
220
|
+
* object — an LXC (proxmox_lxc) or a VM (proxmox_vm_qemu). Pure logic (Rule 10),
|
|
221
|
+
* split from the file read for testability. Prefers the explicit `target_node`
|
|
222
|
+
* attribute; falls back to parsing the resource id (`<node>/lxc/<vmid>` for an
|
|
223
|
+
* LXC, `<node>/qemu/<vmid>` for a VM). Returns null when there's no such
|
|
223
224
|
* resource (e.g. an empty/fresh state).
|
|
224
225
|
*/
|
|
225
226
|
export function targetNodeFromTfState(state: {
|
|
@@ -228,15 +229,17 @@ export function targetNodeFromTfState(state: {
|
|
|
228
229
|
instances?: Array<{ attributes?: { target_node?: string; id?: string } }>;
|
|
229
230
|
}>;
|
|
230
231
|
}): string | null {
|
|
231
|
-
const
|
|
232
|
-
|
|
232
|
+
const guest = state.resources?.find(
|
|
233
|
+
(r) => r.type === 'proxmox_lxc' || r.type === 'proxmox_vm_qemu',
|
|
234
|
+
);
|
|
235
|
+
const attrs = guest?.instances?.[0]?.attributes;
|
|
233
236
|
if (!attrs) {
|
|
234
237
|
return null;
|
|
235
238
|
}
|
|
236
239
|
if (attrs.target_node) {
|
|
237
240
|
return attrs.target_node;
|
|
238
241
|
}
|
|
239
|
-
// id format: "<node>/lxc/<vmid>"
|
|
242
|
+
// id format: "<node>/lxc/<vmid>" (LXC) or "<node>/qemu/<vmid>" (VM)
|
|
240
243
|
return attrs.id?.split('/')[0] || null;
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -745,6 +748,10 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
745
748
|
default_target_node: string;
|
|
746
749
|
lxc_template: string;
|
|
747
750
|
storage: string;
|
|
751
|
+
// Optional: only set once a cloud-init VM template has been built/selected
|
|
752
|
+
// for the service (see service reconfigure). Required by `type: vm` modules;
|
|
753
|
+
// absent for services that only deploy LXCs.
|
|
754
|
+
vm_template?: string;
|
|
748
755
|
};
|
|
749
756
|
|
|
750
757
|
// ISS-0090: deploy follows REALITY. Resolve the node from Proxmox (it sees
|
|
@@ -774,6 +781,13 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
774
781
|
// any stale __infra_target_node a prior generate left behind.
|
|
775
782
|
upsertModuleConfig(db, moduleId, '__infra_lxc_template', providerConfig.lxc_template);
|
|
776
783
|
upsertModuleConfig(db, moduleId, '__infra_storage', providerConfig.storage);
|
|
784
|
+
// vm_template is only present once a VM template exists for the service.
|
|
785
|
+
// Persist it when set so `type: vm` modules can resolve $self:vm_template;
|
|
786
|
+
// a `type: vm` deploy against a service without one then fails loudly at
|
|
787
|
+
// variable resolution (the intended "no VM template configured" error).
|
|
788
|
+
if (providerConfig.vm_template) {
|
|
789
|
+
upsertModuleConfig(db, moduleId, '__infra_vm_template', providerConfig.vm_template);
|
|
790
|
+
}
|
|
777
791
|
deleteModuleConfig(db, moduleId, '__infra_target_node');
|
|
778
792
|
|
|
779
793
|
log.success(`Infrastructure resolved: target_node=${decision.node} (${decision.source})`);
|
|
@@ -816,7 +830,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
|
|
|
816
830
|
|
|
817
831
|
// lxc_template / storage are provider config (intent, not drift-prone) — read
|
|
818
832
|
// them back from the __infra_* rows persisted above.
|
|
819
|
-
const infraKeys = ['lxc_template', 'storage'];
|
|
833
|
+
const infraKeys = ['lxc_template', 'storage', 'vm_template'];
|
|
820
834
|
for (const key of infraKeys) {
|
|
821
835
|
const infraConfig = db
|
|
822
836
|
.select()
|