@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.
@@ -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 { fetchAndUpgrade } from './module-upgrade';
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 (`fetchAndUpgrade` from module-upgrade.ts).
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 fetchAndUpgrade(registry, moduleId, latest.vers, db, {});
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
@@ -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
- // proxmox node list <service-id> proxmox services only
256
- if (command === 'proxmox' && args[1] === 'node' && args[2] === 'list' && currentIndex === 3) {
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
- git_forge: {
67
- script: 'scripts/git-forge-functions.ts',
68
- legacyFactoryName: 'createForgejoGitForge',
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',
@@ -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
- data: z.record(z.unknown()),
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
+ });
@@ -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
- // Map manifest fields to module variable names and apply defaults
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' }, // manifest.requires.system.cpu → cores variable
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.