@celilo/cli 0.5.0 → 0.7.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/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +2 -2
- package/src/cli/command-registry.ts +84 -1
- 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-instance-list.test.ts +77 -0
- package/src/cli/commands/proxmox-instance-list.ts +137 -0
- package/src/cli/commands/proxmox-instance-resize.ts +219 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -34
- package/src/cli/commands/proxmox-resize-guards.test.ts +55 -0
- package/src/cli/commands/proxmox-resize-guards.ts +102 -0
- package/src/cli/commands/proxmox-service.ts +38 -0
- package/src/cli/commands/system-update.ts +3 -3
- package/src/cli/completion.ts +12 -3
- package/src/cli/index.ts +18 -0
- package/src/db/schema.ts +11 -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/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/services/module-subscriptions.test.ts +32 -2
- package/src/variables/context.ts +36 -7
|
@@ -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
|
@@ -141,6 +141,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
141
141
|
'remove',
|
|
142
142
|
'search',
|
|
143
143
|
'update',
|
|
144
|
+
'upgrade',
|
|
144
145
|
'verify',
|
|
145
146
|
'audit',
|
|
146
147
|
'backup',
|
|
@@ -247,13 +248,21 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
247
248
|
// Service subcommands
|
|
248
249
|
// Proxmox subcommands
|
|
249
250
|
if (command === 'proxmox' && currentIndex === 1) {
|
|
250
|
-
return filterSuggestions(['node'], args[1] || '');
|
|
251
|
+
return filterSuggestions(['node', 'vm', 'ct'], args[1] || '');
|
|
251
252
|
}
|
|
252
253
|
if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
|
|
253
254
|
return filterSuggestions(['list'], args[2] || '');
|
|
254
255
|
}
|
|
255
|
-
|
|
256
|
-
|
|
256
|
+
if (command === 'proxmox' && (args[1] === 'vm' || args[1] === 'ct') && currentIndex === 2) {
|
|
257
|
+
return filterSuggestions(['list', 'resize'], args[2] || '');
|
|
258
|
+
}
|
|
259
|
+
// proxmox <node|vm|ct> list <service-id> — proxmox services only
|
|
260
|
+
if (
|
|
261
|
+
command === 'proxmox' &&
|
|
262
|
+
(args[1] === 'node' || args[1] === 'vm' || args[1] === 'ct') &&
|
|
263
|
+
args[2] === 'list' &&
|
|
264
|
+
currentIndex === 3
|
|
265
|
+
) {
|
|
257
266
|
const services = await listContainerServices();
|
|
258
267
|
const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
|
|
259
268
|
return filterSuggestions(serviceIds, args[3] || '');
|
package/src/cli/index.ts
CHANGED
|
@@ -63,9 +63,12 @@ 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';
|
|
70
|
+
import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
|
|
71
|
+
import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
|
|
69
72
|
import { handleProxmoxNodeList } from './commands/proxmox-node-list';
|
|
70
73
|
import { main as runPublish } from './commands/publish';
|
|
71
74
|
import { handleSecretList } from './commands/secret-list';
|
|
@@ -1266,6 +1269,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1266
1269
|
case 'remove':
|
|
1267
1270
|
return handleModuleRemove(parsed.args, parsed.flags);
|
|
1268
1271
|
case 'update':
|
|
1272
|
+
return handleModuleUpdate(parsed.args, parsed.flags);
|
|
1273
|
+
case 'upgrade':
|
|
1269
1274
|
return handleModuleUpgrade(parsed.args, parsed.flags);
|
|
1270
1275
|
case 'audit':
|
|
1271
1276
|
return moduleAudit(parsed.args);
|
|
@@ -1813,6 +1818,19 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1813
1818
|
error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
|
|
1814
1819
|
};
|
|
1815
1820
|
}
|
|
1821
|
+
if (parsed.subcommand === 'vm' || parsed.subcommand === 'ct') {
|
|
1822
|
+
const action = parsed.args[0];
|
|
1823
|
+
if (action === 'list') {
|
|
1824
|
+
return handleProxmoxInstanceList(parsed.subcommand, parsed.args.slice(1));
|
|
1825
|
+
}
|
|
1826
|
+
if (action === 'resize') {
|
|
1827
|
+
return handleProxmoxInstanceResize(parsed.subcommand, parsed.args.slice(1), parsed.flags);
|
|
1828
|
+
}
|
|
1829
|
+
return {
|
|
1830
|
+
success: false,
|
|
1831
|
+
error: `Proxmox ${parsed.subcommand} action required (list, resize)\n\nRun "celilo proxmox --help" for usage`,
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1816
1834
|
return {
|
|
1817
1835
|
success: false,
|
|
1818
1836
|
error: `Unknown proxmox subcommand: ${parsed.subcommand}\n\nRun "celilo proxmox --help" for usage`,
|
package/src/db/schema.ts
CHANGED
|
@@ -392,6 +392,17 @@ export const moduleSystems = sqliteTable(
|
|
|
392
392
|
serviceId: text('service_id').references(() => containerServices.id),
|
|
393
393
|
/** Proxmox VMID — set only for proxmox containers. */
|
|
394
394
|
vmid: integer('vmid'),
|
|
395
|
+
// Canonical deployed SIZE of this system (ISS-0150). For celilo-provisioned
|
|
396
|
+
// VM/LXC instances only (null for machine-pool systems celilo doesn't size).
|
|
397
|
+
// Seeded from the module's `requires.system` at first provision, then owned
|
|
398
|
+
// by `celilo proxmox … resize` — `requires.system` is only the minimum floor,
|
|
399
|
+
// never the live size. See CLAUDE.md "requires.system is the MINIMUM".
|
|
400
|
+
/** vCPU cores. */
|
|
401
|
+
cpu: integer('cpu'),
|
|
402
|
+
/** RAM in MB. */
|
|
403
|
+
memory: integer('memory'),
|
|
404
|
+
/** Root disk in GB. */
|
|
405
|
+
disk: integer('disk'),
|
|
395
406
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
396
407
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
397
408
|
},
|
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { and, eq } from 'drizzle-orm';
|
|
4
5
|
import { type DbClient, createDbClient } from '../db/client';
|
|
5
6
|
import {
|
|
6
7
|
containerServices,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
moduleSystems,
|
|
11
12
|
modules,
|
|
12
13
|
} from '../db/schema';
|
|
13
|
-
import { backfillModuleSystems, getModuleSystems } from './deployed-systems';
|
|
14
|
+
import { backfillModuleSystems, getModuleSystems, upsertDeployedSystem } from './deployed-systems';
|
|
14
15
|
|
|
15
16
|
const TEST_DB_PATH = './test-deployed-systems.db';
|
|
16
17
|
|
|
@@ -233,3 +234,74 @@ describe('backfillModuleSystems', () => {
|
|
|
233
234
|
expect(getModuleSystems('namecheap', db)).toHaveLength(0);
|
|
234
235
|
});
|
|
235
236
|
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Canonical instance sizing (ISS-0150): sizing is seeded onto module_systems
|
|
240
|
+
* once at first provision and then OWNED by `celilo proxmox … resize` — a routine
|
|
241
|
+
* re-deploy must never reset a resized instance back to its manifest minimum.
|
|
242
|
+
*/
|
|
243
|
+
describe('upsertDeployedSystem sizing — seed-once (ISS-0150)', () => {
|
|
244
|
+
let db: DbClient;
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
db = createDbClient({ path: TEST_DB_PATH });
|
|
248
|
+
db.insert(modules)
|
|
249
|
+
.values({
|
|
250
|
+
id: 'm1',
|
|
251
|
+
name: 'm1',
|
|
252
|
+
version: '1.0.0',
|
|
253
|
+
manifestData: {},
|
|
254
|
+
sourcePath: '/tmp/m1',
|
|
255
|
+
state: 'VERIFIED',
|
|
256
|
+
})
|
|
257
|
+
.run();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
afterEach(async () => {
|
|
261
|
+
db.$client.close();
|
|
262
|
+
for (const suffix of ['', '-shm', '-wal']) {
|
|
263
|
+
const p = `${TEST_DB_PATH}${suffix}`;
|
|
264
|
+
if (existsSync(p)) await rm(p);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const sizeArgs = (memory: number) => ({
|
|
269
|
+
name: 'main',
|
|
270
|
+
hostname: 'h',
|
|
271
|
+
ipv4Address: '10.0.0.5/24',
|
|
272
|
+
zone: 'app' as const,
|
|
273
|
+
infraType: 'container_service' as const,
|
|
274
|
+
vmid: 200,
|
|
275
|
+
cpu: 4,
|
|
276
|
+
memory,
|
|
277
|
+
disk: 80,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const row = () =>
|
|
281
|
+
db
|
|
282
|
+
.select()
|
|
283
|
+
.from(moduleSystems)
|
|
284
|
+
.where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
|
|
285
|
+
.get();
|
|
286
|
+
|
|
287
|
+
test('seeds sizing on first insert', () => {
|
|
288
|
+
upsertDeployedSystem(db, 'm1', sizeArgs(8192));
|
|
289
|
+
const r = row();
|
|
290
|
+
expect(r?.cpu).toBe(4);
|
|
291
|
+
expect(r?.memory).toBe(8192);
|
|
292
|
+
expect(r?.disk).toBe(80);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('a re-deploy does NOT reset a resized instance to the manifest minimum', () => {
|
|
296
|
+
upsertDeployedSystem(db, 'm1', sizeArgs(8192)); // first provision: seed 8 GB
|
|
297
|
+
// Simulate `celilo proxmox vm resize` bumping the canonical size to 16 GB.
|
|
298
|
+
db.update(moduleSystems)
|
|
299
|
+
.set({ memory: 16384 })
|
|
300
|
+
.where(and(eq(moduleSystems.moduleId, 'm1'), eq(moduleSystems.name, 'main')))
|
|
301
|
+
.run();
|
|
302
|
+
// Re-deploy passes the manifest minimum (8 GB) again — must be ignored.
|
|
303
|
+
upsertDeployedSystem(db, 'm1', sizeArgs(8192));
|
|
304
|
+
expect(row()?.memory).toBe(16384);
|
|
305
|
+
expect(row()?.cpu).toBe(4);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -52,6 +52,52 @@ export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem
|
|
|
52
52
|
return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* A celilo-provisioned instance with its canonical size (ISS-0150). CLI-internal
|
|
57
|
+
* shape (NOT the `DeployedSystem` capability type) for the `celilo proxmox
|
|
58
|
+
* vm/ct …` surface, which needs the sizing columns the capability type omits.
|
|
59
|
+
*/
|
|
60
|
+
export interface ProvisionedSystem {
|
|
61
|
+
moduleId: string;
|
|
62
|
+
name: string;
|
|
63
|
+
hostname: string;
|
|
64
|
+
ipv4Address: string;
|
|
65
|
+
zone: NetworkZone;
|
|
66
|
+
serviceId: string | null;
|
|
67
|
+
vmid: number | null;
|
|
68
|
+
/** Canonical desired size (null until seeded / for non-Proxmox). */
|
|
69
|
+
cpu: number | null;
|
|
70
|
+
memory: number | null;
|
|
71
|
+
disk: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every celilo-provisioned (container_service) system with a Proxmox vmid, across
|
|
76
|
+
* all modules, including its canonical sizing — the read model behind
|
|
77
|
+
* `celilo proxmox vm/ct list|show`. Ordered by vmid for stable output.
|
|
78
|
+
*/
|
|
79
|
+
export function getProvisionedSystems(db: DbClient): ProvisionedSystem[] {
|
|
80
|
+
return db
|
|
81
|
+
.select()
|
|
82
|
+
.from(moduleSystems)
|
|
83
|
+
.where(eq(moduleSystems.infraType, 'container_service'))
|
|
84
|
+
.all()
|
|
85
|
+
.filter((r) => r.vmid != null)
|
|
86
|
+
.map((r) => ({
|
|
87
|
+
moduleId: r.moduleId,
|
|
88
|
+
name: r.name,
|
|
89
|
+
hostname: r.hostname,
|
|
90
|
+
ipv4Address: r.ipv4Address,
|
|
91
|
+
zone: r.zone,
|
|
92
|
+
serviceId: r.serviceId,
|
|
93
|
+
vmid: r.vmid,
|
|
94
|
+
cpu: r.cpu,
|
|
95
|
+
memory: r.memory,
|
|
96
|
+
disk: r.disk,
|
|
97
|
+
}))
|
|
98
|
+
.sort((a, b) => (a.vmid ?? 0) - (b.vmid ?? 0));
|
|
99
|
+
}
|
|
100
|
+
|
|
55
101
|
/**
|
|
56
102
|
* All container_service systems (Proxmox LXCs, droplets, …) whose zone is in
|
|
57
103
|
* `zones`, across every module — the LXC complement to machine-pool's
|
|
@@ -93,6 +139,15 @@ export interface DeployedSystemInput {
|
|
|
93
139
|
machineId?: string | null;
|
|
94
140
|
serviceId?: string | null;
|
|
95
141
|
vmid?: number | null;
|
|
142
|
+
/**
|
|
143
|
+
* Canonical deployed size (ISS-0150), seeded from the module's
|
|
144
|
+
* `requires.system` at first provision. Seed-once: written on INSERT only and
|
|
145
|
+
* preserved across re-deploys (omitted from the conflict update), so a later
|
|
146
|
+
* `celilo proxmox … resize` is not reset back to the manifest minimum.
|
|
147
|
+
*/
|
|
148
|
+
cpu?: number | null;
|
|
149
|
+
memory?: number | null;
|
|
150
|
+
disk?: number | null;
|
|
96
151
|
}
|
|
97
152
|
|
|
98
153
|
/**
|
|
@@ -117,6 +172,11 @@ export function upsertDeployedSystem(
|
|
|
117
172
|
machineId: system.machineId ?? null,
|
|
118
173
|
serviceId: system.serviceId ?? null,
|
|
119
174
|
vmid: system.vmid ?? null,
|
|
175
|
+
// Seed-once (ISS-0150): set on insert; deliberately omitted from the
|
|
176
|
+
// conflict update below so a resize survives re-deploys.
|
|
177
|
+
cpu: system.cpu ?? null,
|
|
178
|
+
memory: system.memory ?? null,
|
|
179
|
+
disk: system.disk ?? null,
|
|
120
180
|
updatedAt: new Date(),
|
|
121
181
|
})
|
|
122
182
|
.onConflictDoUpdate({
|
|
@@ -129,6 +189,9 @@ export function upsertDeployedSystem(
|
|
|
129
189
|
machineId: system.machineId ?? null,
|
|
130
190
|
serviceId: system.serviceId ?? null,
|
|
131
191
|
vmid: system.vmid ?? null,
|
|
192
|
+
// NOTE: cpu/memory/disk intentionally NOT updated here — sizing is
|
|
193
|
+
// canonical state owned by `celilo proxmox … resize`, not reset by a
|
|
194
|
+
// routine re-deploy (seed-once). See ISS-0150 / CLAUDE.md.
|
|
132
195
|
updatedAt: new Date(),
|
|
133
196
|
},
|
|
134
197
|
})
|
|
@@ -207,6 +270,11 @@ export async function recordDeployedSystemForModule(
|
|
|
207
270
|
machineId: infrastructure?.machineId ?? null,
|
|
208
271
|
serviceId: infrastructure?.serviceId ?? null,
|
|
209
272
|
vmid: Number.isNaN(vmid as number) ? null : vmid,
|
|
273
|
+
// Seed canonical size from requires.system (seed-once; preserved across
|
|
274
|
+
// re-deploys). Only meaningful for celilo-provisioned instances.
|
|
275
|
+
cpu: decl.resources.cpu ?? null,
|
|
276
|
+
memory: decl.resources.memory ?? null,
|
|
277
|
+
disk: decl.resources.disk ?? null,
|
|
210
278
|
});
|
|
211
279
|
|
|
212
280
|
return getModuleSystems(moduleId, db);
|
|
@@ -279,6 +347,10 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
279
347
|
machineId: infra.machineId ?? null,
|
|
280
348
|
serviceId: infra.serviceId ?? null,
|
|
281
349
|
vmid: vmid != null && !Number.isNaN(vmid) ? vmid : null,
|
|
350
|
+
// Seed canonical size from requires.system for upgraded deployments.
|
|
351
|
+
cpu: decl.resources.cpu ?? null,
|
|
352
|
+
memory: decl.resources.memory ?? null,
|
|
353
|
+
disk: decl.resources.disk ?? null,
|
|
282
354
|
});
|
|
283
355
|
backfilled.push(infra.moduleId);
|
|
284
356
|
}
|
|
@@ -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
|
+
});
|
package/src/variables/context.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
machines,
|
|
9
9
|
moduleConfigs,
|
|
10
10
|
moduleInfrastructure,
|
|
11
|
+
moduleSystems,
|
|
11
12
|
modules,
|
|
12
13
|
secrets,
|
|
13
14
|
systemConfig,
|
|
@@ -264,20 +265,48 @@ export async function buildResolutionContext(
|
|
|
264
265
|
const systemResources = getSingularSystemSpec(manifest);
|
|
265
266
|
|
|
266
267
|
if (systemResources) {
|
|
267
|
-
//
|
|
268
|
+
// The DEPLOYED size is the SYSTEM's canonical state (ISS-0150), seeded from
|
|
269
|
+
// requires.system at first provision and thereafter owned by
|
|
270
|
+
// `celilo proxmox … resize`. So sizing flows: module_systems → these config
|
|
271
|
+
// vars → `$self:{cores,memory,disk}` in the instance Terraform.
|
|
272
|
+
//
|
|
273
|
+
// Precedence: the recorded system size WINS and overwrites the cached
|
|
274
|
+
// config (a resize must propagate on the next generate); only when this
|
|
275
|
+
// module has no recorded system size yet (the very first provision, before
|
|
276
|
+
// recordDeployedSystemForModule runs below) do we fall back to
|
|
277
|
+
// requires.system — and seed-when-unset, matching the prior behavior so the
|
|
278
|
+
// first-deploy / golden output is unchanged. `requires.system` stays the
|
|
279
|
+
// minimum floor, never the canonical size. (CLAUDE.md / ISS-0150.)
|
|
280
|
+
const sizedRow = db
|
|
281
|
+
.select({
|
|
282
|
+
cpu: moduleSystems.cpu,
|
|
283
|
+
memory: moduleSystems.memory,
|
|
284
|
+
disk: moduleSystems.disk,
|
|
285
|
+
})
|
|
286
|
+
.from(moduleSystems)
|
|
287
|
+
.where(eq(moduleSystems.moduleId, moduleId))
|
|
288
|
+
.all()
|
|
289
|
+
.find((r) => r.cpu != null || r.memory != null || r.disk != null);
|
|
290
|
+
|
|
268
291
|
const resourceMappings: Array<{
|
|
269
292
|
manifestKey: keyof typeof systemResources;
|
|
270
293
|
configKey: string;
|
|
294
|
+
systemValue: number | null | undefined;
|
|
271
295
|
}> = [
|
|
272
|
-
{ manifestKey: 'cpu', configKey: 'cores' }, //
|
|
273
|
-
{ manifestKey: 'memory', configKey: 'memory' },
|
|
274
|
-
{ manifestKey: 'disk', configKey: 'disk' },
|
|
275
|
-
{ manifestKey: 'storage', configKey: 'storage' },
|
|
296
|
+
{ manifestKey: 'cpu', configKey: 'cores', systemValue: sizedRow?.cpu }, // requires.system.cpu → cores
|
|
297
|
+
{ manifestKey: 'memory', configKey: 'memory', systemValue: sizedRow?.memory },
|
|
298
|
+
{ manifestKey: 'disk', configKey: 'disk', systemValue: sizedRow?.disk },
|
|
299
|
+
{ manifestKey: 'storage', configKey: 'storage', systemValue: undefined }, // pool name, not sizing
|
|
276
300
|
];
|
|
277
301
|
|
|
278
|
-
for (const { manifestKey, configKey } of resourceMappings) {
|
|
302
|
+
for (const { manifestKey, configKey, systemValue } of resourceMappings) {
|
|
303
|
+
if (systemValue != null) {
|
|
304
|
+
// Canonical system size — always wins so a resize propagates.
|
|
305
|
+
upsertModuleConfig(db, moduleId, configKey, systemValue);
|
|
306
|
+
selfConfig[configKey] = String(systemValue);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
279
309
|
const value = systemResources[manifestKey];
|
|
280
|
-
|
|
281
310
|
// Manifest fields are typed (cpu: number, storage: string, etc.).
|
|
282
311
|
// Pass them through unstringified so valueJson preserves the
|
|
283
312
|
// shape — see comment in the variable-defaults block above.
|