@celilo/cli 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `module_systems` ADD `cpu` integer;--> statement-breakpoint
2
+ ALTER TABLE `module_systems` ADD `memory` integer;--> statement-breakpoint
3
+ ALTER TABLE `module_systems` ADD `disk` integer;
@@ -85,6 +85,13 @@
85
85
  "when": 1781481660000,
86
86
  "tag": "0011_backups_name",
87
87
  "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "6",
92
+ "when": 1781481720000,
93
+ "tag": "0012_module_systems_sizing",
94
+ "breakpoints": true
88
95
  }
89
96
  ]
90
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -636,6 +636,23 @@ export class ProxmoxClient {
636
636
  if (!result.success) return result;
637
637
  return { success: true, data: findNodeForVmid(result.data, vmid) };
638
638
  }
639
+
640
+ /**
641
+ * Power-cycle a guest so PENDING config changes take effect (ISS-0150). A
642
+ * cpu/memory change Terraform writes to the VM config is "pending" until the
643
+ * QEMU process is recreated; `qm reboot` does a shutdown+start that applies it.
644
+ * Returns the UPID and waits for the task to finish.
645
+ */
646
+ async rebootVm(node: string, vmid: number): Promise<ProxmoxResult<string>> {
647
+ const res = await makeProxmoxPost<string>(
648
+ this.credentials,
649
+ `/nodes/${node}/qemu/${vmid}/status/reboot`,
650
+ {},
651
+ );
652
+ if (!res.success) return res;
653
+ await pollTaskUntilDone(this.credentials, node, res.data, 300_000);
654
+ return res;
655
+ }
639
656
  }
640
657
 
641
658
  /**
@@ -1090,7 +1090,7 @@ export const COMMANDS: CommandDef[] = [
1090
1090
  },
1091
1091
  {
1092
1092
  name: 'proxmox',
1093
- description: 'Proxmox cluster introspection (nodes, capacity)',
1093
+ description: 'Proxmox cluster introspection (nodes, capacity, instance sizing)',
1094
1094
  subcommands: [
1095
1095
  {
1096
1096
  name: 'node',
@@ -1103,6 +1103,70 @@ export const COMMANDS: CommandDef[] = [
1103
1103
  },
1104
1104
  ],
1105
1105
  },
1106
+ {
1107
+ name: 'vm',
1108
+ description: 'celilo-provisioned VM sizing',
1109
+ subcommands: [
1110
+ {
1111
+ name: 'list',
1112
+ description: 'List celilo VMs with desired vs actual size',
1113
+ args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
1114
+ },
1115
+ {
1116
+ name: 'resize',
1117
+ description: 'Resize a celilo VM (canonical size + reconcile)',
1118
+ args: [{ name: 'name', description: 'Instance name (module/hostname)' }],
1119
+ flags: [
1120
+ { name: 'memory', description: 'New RAM in MB', takesValue: true },
1121
+ { name: 'cpu', description: 'New vCPU count', takesValue: true },
1122
+ { name: 'force', description: 'Override the node capacity check', takesValue: false },
1123
+ {
1124
+ name: 'allow-reboot',
1125
+ description: 'Approve the stop/start a resize needs',
1126
+ takesValue: false,
1127
+ },
1128
+ { name: 'skip-backup', description: 'Skip the pre-resize backup', takesValue: false },
1129
+ {
1130
+ name: 'yes',
1131
+ description: 'Auto-approve reboot + backup (headless)',
1132
+ takesValue: false,
1133
+ },
1134
+ ],
1135
+ },
1136
+ ],
1137
+ },
1138
+ {
1139
+ name: 'ct',
1140
+ description: 'celilo-provisioned container (LXC) sizing',
1141
+ subcommands: [
1142
+ {
1143
+ name: 'list',
1144
+ description: 'List celilo containers with desired vs actual size',
1145
+ args: [{ name: 'service-id', description: 'Proxmox service (optional if only one)' }],
1146
+ },
1147
+ {
1148
+ name: 'resize',
1149
+ description: 'Resize a celilo container (canonical size + reconcile)',
1150
+ args: [{ name: 'name', description: 'Instance name (module/hostname)' }],
1151
+ flags: [
1152
+ { name: 'memory', description: 'New RAM in MB', takesValue: true },
1153
+ { name: 'cpu', description: 'New vCPU count', takesValue: true },
1154
+ { name: 'force', description: 'Override the node capacity check', takesValue: false },
1155
+ {
1156
+ name: 'allow-reboot',
1157
+ description: 'Approve the stop/start a resize needs',
1158
+ takesValue: false,
1159
+ },
1160
+ { name: 'skip-backup', description: 'Skip the pre-resize backup', takesValue: false },
1161
+ {
1162
+ name: 'yes',
1163
+ description: 'Auto-approve reboot + backup (headless)',
1164
+ takesValue: false,
1165
+ },
1166
+ ],
1167
+ },
1168
+ ],
1169
+ },
1106
1170
  ],
1107
1171
  },
1108
1172
  {
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { ProxmoxClusterResource } from '../../api-clients/proxmox';
3
+ import type { ProvisionedSystem } from '../../services/deployed-systems';
4
+ import { joinInstanceRows } from './proxmox-instance-list';
5
+
6
+ const GB = 1024 * 1024 * 1024;
7
+ const MB = 1024 * 1024;
8
+
9
+ function sys(p: Partial<ProvisionedSystem> & { vmid: number }): ProvisionedSystem {
10
+ return {
11
+ moduleId: 'forgejo-builder',
12
+ name: 'main',
13
+ hostname: 'forgejo-builder',
14
+ ipv4Address: '10.0.10.20',
15
+ zone: 'dmz',
16
+ serviceId: 'svc',
17
+ cpu: 4,
18
+ memory: 8192,
19
+ disk: 80,
20
+ ...p,
21
+ };
22
+ }
23
+
24
+ describe('joinInstanceRows (ISS-0150)', () => {
25
+ test('joins a VM: desired from system, actual from the qemu guest', () => {
26
+ const systems = [sys({ vmid: 208 })];
27
+ const resources: ProxmoxClusterResource[] = [
28
+ {
29
+ type: 'qemu',
30
+ vmid: 208,
31
+ node: 'node3',
32
+ status: 'running',
33
+ maxcpu: 4,
34
+ maxmem: 16 * GB,
35
+ maxdisk: 100 * GB,
36
+ },
37
+ ];
38
+ const rows = joinInstanceRows(systems, resources, 'vm');
39
+ expect(rows).toHaveLength(1);
40
+ expect(rows[0].desired).toEqual({ cpu: 4, memMb: 8192, diskGb: 80 });
41
+ expect(rows[0].actual).toEqual({ cpu: 4, memMb: 16384, diskGb: 100 });
42
+ expect(rows[0].node).toBe('node3');
43
+ });
44
+
45
+ test('kind filters by Proxmox guest type — a qemu guest never shows under ct', () => {
46
+ const systems = [sys({ vmid: 208 })];
47
+ const resources: ProxmoxClusterResource[] = [
48
+ { type: 'qemu', vmid: 208, node: 'node3', maxcpu: 4, maxmem: 8 * GB },
49
+ ];
50
+ expect(joinInstanceRows(systems, resources, 'ct')).toHaveLength(0);
51
+ expect(joinInstanceRows(systems, resources, 'vm')).toHaveLength(1);
52
+ });
53
+
54
+ test('an lxc system shows under ct', () => {
55
+ const systems = [sys({ moduleId: 'caddy', vmid: 202 })];
56
+ const resources: ProxmoxClusterResource[] = [
57
+ {
58
+ type: 'lxc',
59
+ vmid: 202,
60
+ node: 'node2',
61
+ status: 'running',
62
+ maxcpu: 1,
63
+ maxmem: 512 * MB,
64
+ maxdisk: 10 * GB,
65
+ },
66
+ ];
67
+ const rows = joinInstanceRows(systems, resources, 'ct');
68
+ expect(rows).toHaveLength(1);
69
+ expect(rows[0].module).toBe('caddy');
70
+ expect(rows[0].actual).toEqual({ cpu: 1, memMb: 512, diskGb: 10 });
71
+ });
72
+
73
+ test('a system absent from the cluster is omitted', () => {
74
+ const systems = [sys({ vmid: 999 })];
75
+ expect(joinInstanceRows(systems, [], 'vm')).toHaveLength(0);
76
+ });
77
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `celilo proxmox vm list` / `celilo proxmox ct list` — celilo-provisioned
3
+ * Proxmox instances with their **desired** size (canonical `module_systems`
4
+ * state, ISS-0150) next to their **actual** size (live from the Proxmox API). A
5
+ * desired≠actual row means a resize is pending a reconcile/redeploy.
6
+ *
7
+ * Read-only sibling of `proxmox node list`; the foundation the `resize` verb
8
+ * (ISS-0150 P2) builds on. See apps/celilo/designs/PROXMOX_INSTANCE_SIZING.md.
9
+ */
10
+
11
+ import {
12
+ ProxmoxClient,
13
+ type ProxmoxClusterResource,
14
+ type ProxmoxCredentials,
15
+ } from '../../api-clients/proxmox';
16
+ import { getDb } from '../../db/client';
17
+ import { getServiceCredentials, listContainerServices } from '../../services/container-service';
18
+ import { type ProvisionedSystem, getProvisionedSystems } from '../../services/deployed-systems';
19
+ import { celiloIntro } from '../prompts';
20
+ import type { CommandResult } from '../types';
21
+ import { resolveProxmoxService } from './proxmox-service';
22
+
23
+ const BYTES_PER_MB = 1024 * 1024;
24
+ const BYTES_PER_GB = 1024 * 1024 * 1024;
25
+
26
+ export type InstanceKind = 'vm' | 'ct';
27
+
28
+ /** Proxmox `type` for each celilo instance kind. */
29
+ const PROXMOX_TYPE: Record<InstanceKind, string> = { vm: 'qemu', ct: 'lxc' };
30
+
31
+ export interface InstanceRow {
32
+ module: string;
33
+ name: string;
34
+ vmid: number;
35
+ node: string;
36
+ status: string;
37
+ desired: { cpu: number | null; memMb: number | null; diskGb: number | null };
38
+ /** null when the instance is in module_systems but not (yet) on the cluster. */
39
+ actual: { cpu: number; memMb: number; diskGb: number } | null;
40
+ }
41
+
42
+ /**
43
+ * Pure join of celilo's provisioned systems against live Proxmox guests, for one
44
+ * instance kind. Keyed by vmid; the Proxmox guest `type` decides vm vs ct
45
+ * membership (a celilo system row doesn't itself record the kind).
46
+ */
47
+ export function joinInstanceRows(
48
+ systems: ProvisionedSystem[],
49
+ resources: ProxmoxClusterResource[],
50
+ kind: InstanceKind,
51
+ ): InstanceRow[] {
52
+ const wantType = PROXMOX_TYPE[kind];
53
+ const byVmid = new Map<number, ProxmoxClusterResource>();
54
+ for (const r of resources) {
55
+ if (r.type === wantType && typeof r.vmid === 'number') byVmid.set(r.vmid, r);
56
+ }
57
+ const rows: InstanceRow[] = [];
58
+ for (const s of systems) {
59
+ if (s.vmid == null) continue;
60
+ const guest = byVmid.get(s.vmid);
61
+ if (!guest) continue; // not a guest of this kind (other kind, or gone from cluster)
62
+ rows.push({
63
+ module: s.moduleId,
64
+ name: s.name,
65
+ vmid: s.vmid,
66
+ node: guest.node ?? '—',
67
+ status: guest.status ?? '—',
68
+ desired: { cpu: s.cpu, memMb: s.memory, diskGb: s.disk },
69
+ actual:
70
+ guest.maxcpu != null && guest.maxmem != null
71
+ ? {
72
+ cpu: guest.maxcpu,
73
+ memMb: Math.round(guest.maxmem / BYTES_PER_MB),
74
+ diskGb: guest.maxdisk != null ? Math.round(guest.maxdisk / BYTES_PER_GB) : 0,
75
+ }
76
+ : null,
77
+ });
78
+ }
79
+ return rows;
80
+ }
81
+
82
+ function fmtSize(cpu: number | null, memMb: number | null, diskGb: number | null): string {
83
+ const c = cpu != null ? `${cpu}c` : '—';
84
+ const m = memMb != null ? `${(memMb / 1024).toFixed(0)}G` : '—';
85
+ const d = diskGb != null ? `${diskGb}G` : '—';
86
+ return `${c}/${m}/${d}`;
87
+ }
88
+
89
+ export async function handleProxmoxInstanceList(
90
+ kind: InstanceKind,
91
+ args: string[],
92
+ ): Promise<CommandResult> {
93
+ celiloIntro(kind === 'vm' ? 'Proxmox VMs' : 'Proxmox containers');
94
+
95
+ const resolved = resolveProxmoxService(await listContainerServices(), args[0]);
96
+ if ('error' in resolved) {
97
+ console.log(`✗ ${resolved.error}`);
98
+ return { success: false, error: resolved.error };
99
+ }
100
+ const { service } = resolved;
101
+
102
+ const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
103
+ const result = await new ProxmoxClient(creds).clusterResources();
104
+ if (!result.success) {
105
+ console.log(`✗ Could not reach Proxmox (${service.serviceId}): ${result.message}`);
106
+ return { success: false, error: result.message };
107
+ }
108
+
109
+ const rows = joinInstanceRows(getProvisionedSystems(getDb()), result.data, kind);
110
+ if (rows.length === 0) {
111
+ console.log(`No celilo-provisioned ${kind === 'vm' ? 'VMs' : 'containers'} found.`);
112
+ return { success: true, message: 'none' };
113
+ }
114
+
115
+ console.log(`Service: ${service.serviceId} (${service.name})\n`);
116
+ console.log(
117
+ 'MODULE VMID NODE STATUS DESIRED(c/m/d) ACTUAL(c/m/d) DRIFT',
118
+ );
119
+ console.log(
120
+ '────────────────────────────────────────────────────────────────────────────────────',
121
+ );
122
+ for (const r of rows) {
123
+ const desired = fmtSize(r.desired.cpu, r.desired.memMb, r.desired.diskGb);
124
+ const actual = r.actual ? fmtSize(r.actual.cpu, r.actual.memMb, r.actual.diskGb) : '—';
125
+ const drift =
126
+ r.actual &&
127
+ (r.desired.cpu !== r.actual.cpu ||
128
+ (r.desired.memMb != null && r.desired.memMb !== r.actual.memMb))
129
+ ? '⚠ resize pending'
130
+ : '';
131
+ console.log(
132
+ `${r.module.padEnd(19)} ${String(r.vmid).padEnd(6)} ${r.node.padEnd(8)} ${r.status.padEnd(9)} ${desired.padEnd(16)} ${actual.padEnd(15)} ${drift}`,
133
+ );
134
+ }
135
+ console.log('');
136
+ return { success: true, message: `${rows.length} ${kind}(s)` };
137
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `celilo proxmox vm resize <name> --memory <MB> [--cpu <n>]` (and `ct resize`) —
3
+ * the canonical resize for a celilo-provisioned instance (ISS-0150 P2). Updates
4
+ * the SYSTEM's canonical size in module_systems, then reconciles declaratively by
5
+ * redeploying the owning module (its Terraform now reads the system size).
6
+ *
7
+ * Guardrails (proxmox-resize-guards.ts): floor = max(requires.system) across
8
+ * co-hosted modules (hard); node capacity (overridable with --force). Reboot +
9
+ * pre-resize backup are confirmed via the event-bus interview (CLAUDE.md: no
10
+ * direct CLI prompts) — flags win: --allow-reboot / --skip-backup / --yes.
11
+ */
12
+
13
+ import { and, eq } from 'drizzle-orm';
14
+ import { ProxmoxClient, type ProxmoxCredentials } from '../../api-clients/proxmox';
15
+ import { getDb } from '../../db/client';
16
+ import { moduleSystems, modules } from '../../db/schema';
17
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
18
+ import { createModuleBackup } from '../../services/backup-create';
19
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
20
+ import { getServiceCredentials, listContainerServices } from '../../services/container-service';
21
+ import { getProvisionedSystems } from '../../services/deployed-systems';
22
+ import { deployModule } from '../../services/module-deploy';
23
+ import { celiloIntro } from '../prompts';
24
+ import type { CommandResult } from '../types';
25
+ import type { InstanceKind } from './proxmox-instance-list';
26
+ import { computeFloor, validateResize } from './proxmox-resize-guards';
27
+ import { resolveProxmoxService } from './proxmox-service';
28
+
29
+ const PROXMOX_TYPE: Record<InstanceKind, string> = { vm: 'qemu', ct: 'lxc' };
30
+
31
+ function numFlag(v: string | boolean | undefined): number | undefined {
32
+ if (typeof v !== 'string') return undefined;
33
+ const n = Number.parseInt(v, 10);
34
+ return Number.isNaN(n) ? undefined : n;
35
+ }
36
+
37
+ export async function handleProxmoxInstanceResize(
38
+ kind: InstanceKind,
39
+ args: string[],
40
+ flags: Record<string, string | boolean>,
41
+ ): Promise<CommandResult> {
42
+ celiloIntro(`Resize ${kind === 'vm' ? 'VM' : 'container'}`);
43
+
44
+ const name = args[0];
45
+ if (!name) {
46
+ const e = `Instance name required: celilo proxmox ${kind} resize <name> --memory <MB> [--cpu <n>]`;
47
+ console.log(`✗ ${e}`);
48
+ return { success: false, error: e };
49
+ }
50
+
51
+ if (numFlag(flags.disk) != null) {
52
+ const e = 'Disk resize is deferred (ISS-0150 D4). Use --memory / --cpu for now.';
53
+ console.log(`✗ ${e}`);
54
+ return { success: false, error: e };
55
+ }
56
+ const reqCpu = numFlag(flags.cpu);
57
+ const reqMemory = numFlag(flags.memory);
58
+ if (reqCpu == null && reqMemory == null) {
59
+ const e = 'Specify at least one of --memory <MB> or --cpu <n>.';
60
+ console.log(`✗ ${e}`);
61
+ return { success: false, error: e };
62
+ }
63
+
64
+ const resolved = resolveProxmoxService(await listContainerServices(), undefined);
65
+ if ('error' in resolved) {
66
+ console.log(`✗ ${resolved.error}`);
67
+ return { success: false, error: resolved.error };
68
+ }
69
+ const { service } = resolved;
70
+ const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
71
+ const client = new ProxmoxClient(creds);
72
+
73
+ const cluster = await client.clusterResources();
74
+ if (!cluster.success) {
75
+ console.log(`✗ Could not reach Proxmox: ${cluster.message}`);
76
+ return { success: false, error: cluster.message };
77
+ }
78
+ const wantType = PROXMOX_TYPE[kind];
79
+ const guestByVmid = new Map<number, (typeof cluster.data)[number]>();
80
+ for (const r of cluster.data) {
81
+ if (r.type === wantType && typeof r.vmid === 'number') guestByVmid.set(r.vmid, r);
82
+ }
83
+
84
+ const db = getDb();
85
+ const systems = getProvisionedSystems(db);
86
+ const matches = systems.filter(
87
+ (s) =>
88
+ s.vmid != null &&
89
+ guestByVmid.has(s.vmid) &&
90
+ (s.hostname === name || s.name === name || s.moduleId === name),
91
+ );
92
+ if (matches.length === 0) {
93
+ const e = `No celilo-provisioned ${kind} named '${name}'. Try: celilo proxmox ${kind} list`;
94
+ console.log(`✗ ${e}`);
95
+ return { success: false, error: e };
96
+ }
97
+ if (matches.length > 1) {
98
+ const e = `Ambiguous '${name}' — matches ${matches.map((m) => m.moduleId).join(', ')}`;
99
+ console.log(`✗ ${e}`);
100
+ return { success: false, error: e };
101
+ }
102
+ const target = matches[0];
103
+ const vmid = target.vmid as number;
104
+ const guest = guestByVmid.get(vmid);
105
+ const node = guest?.node;
106
+
107
+ // Co-hosted = every module recorded on the same physical instance (vmid).
108
+ const coHosted = systems.filter((s) => s.vmid === vmid);
109
+ const mins = coHosted.map((s) => {
110
+ const mod = db.select().from(modules).where(eq(modules.id, s.moduleId)).get();
111
+ const spec = mod ? getSingularSystemSpec(mod.manifestData as ModuleManifest) : undefined;
112
+ return { cpu: spec?.cpu, memory: spec?.memory, disk: spec?.disk };
113
+ });
114
+ const floor = computeFloor(mins);
115
+
116
+ const nodeCaps = await client.nodeCapacities();
117
+ const nodeCap = nodeCaps.success ? nodeCaps.data.find((n) => n.node === node) : undefined;
118
+
119
+ const decision = validateResize(
120
+ { cpu: reqCpu, memory: reqMemory },
121
+ {
122
+ current: { cpu: target.cpu, memory: target.memory, disk: target.disk },
123
+ floor,
124
+ nodeFreeMemMb: nodeCap?.memFreeMb ?? Number.POSITIVE_INFINITY,
125
+ nodeTotalCores: nodeCap?.cpuCores ?? Number.POSITIVE_INFINITY,
126
+ },
127
+ { force: flags.force === true },
128
+ );
129
+
130
+ if (decision.errors.length > 0) {
131
+ console.log('✗ Resize rejected:');
132
+ for (const err of decision.errors) console.log(` - ${err}`);
133
+ if (decision.capacityOnly) console.log(' (pass --force to override the capacity check)');
134
+ return { success: false, error: decision.errors.join('; ') };
135
+ }
136
+
137
+ const yes = flags.yes === true;
138
+ const coHostedIds = coHosted.map((s) => s.moduleId);
139
+
140
+ // Pre-resize backup — ON by default; --skip-backup opts out; otherwise the
141
+ // skip decision is an event-bus interview question (never a direct CLI prompt).
142
+ let doBackup: boolean;
143
+ if (flags['skip-backup'] === true) doBackup = false;
144
+ else if (yes) doBackup = true;
145
+ else
146
+ doBackup = await withInterviewSession(() =>
147
+ askConfirm({
148
+ scope: 'proxmox-resize',
149
+ key: 'backup',
150
+ message: `Take a pre-resize backup of ${coHostedIds.join(', ')} first?`,
151
+ defaultValue: true,
152
+ }),
153
+ );
154
+
155
+ if (doBackup) {
156
+ for (const s of coHosted) {
157
+ const mod = db.select().from(modules).where(eq(modules.id, s.moduleId)).get();
158
+ const manifest = mod?.manifestData as ModuleManifest | undefined;
159
+ if (!manifest?.hooks?.on_backup) {
160
+ console.log(` ⚠ ${s.moduleId} has no on_backup hook — skipping its backup`);
161
+ continue;
162
+ }
163
+ const backup = await createModuleBackup(s.moduleId);
164
+ if (!backup.success) {
165
+ const e = `Pre-resize backup failed for ${s.moduleId}: ${backup.error}`;
166
+ console.log(`✗ ${e}`);
167
+ return { success: false, error: e };
168
+ }
169
+ console.log(` ✓ Backed up ${s.moduleId}`);
170
+ }
171
+ }
172
+
173
+ // Reboot confirmation — a cpu/memory change needs a Proxmox stop/start.
174
+ if (decision.needsReboot) {
175
+ let allowReboot: boolean;
176
+ if (flags['allow-reboot'] === true || yes) allowReboot = true;
177
+ else
178
+ allowReboot = await withInterviewSession(() =>
179
+ askConfirm({
180
+ scope: 'proxmox-resize',
181
+ key: 'reboot',
182
+ message: `Resizing ${name} (vmid ${vmid}) requires a stop/start — brief downtime. Proceed?`,
183
+ defaultValue: false,
184
+ }),
185
+ );
186
+ if (!allowReboot) {
187
+ const e = 'Resize aborted — reboot not approved (re-run with --allow-reboot).';
188
+ console.log(`✗ ${e}`);
189
+ return { success: false, error: e };
190
+ }
191
+ }
192
+
193
+ // Update the canonical size on every module_systems row for this instance.
194
+ for (const s of coHosted) {
195
+ const set: { cpu?: number; memory?: number; updatedAt: Date } = { updatedAt: new Date() };
196
+ if (reqCpu != null) set.cpu = reqCpu;
197
+ if (reqMemory != null) set.memory = reqMemory;
198
+ db.update(moduleSystems)
199
+ .set(set)
200
+ .where(and(eq(moduleSystems.moduleId, s.moduleId), eq(moduleSystems.name, s.name)))
201
+ .run();
202
+ }
203
+ const effCpu = reqCpu ?? target.cpu;
204
+ const effMem = reqMemory ?? target.memory;
205
+ console.log(`✓ Canonical size updated: ${name} → ${effCpu ?? '?'}c / ${effMem ?? '?'}MB`);
206
+
207
+ // Reconcile declaratively: redeploy the owning module — its Terraform now reads
208
+ // the updated system size and writes it to the Proxmox VM config.
209
+ console.log(`▸ Reconciling ${target.moduleId} (Terraform apply)…`);
210
+ const deployed = await deployModule(target.moduleId, db, {});
211
+ if (!deployed.success) {
212
+ const e = `Size updated but reconcile failed: ${deployed.error}. Re-run: celilo module deploy ${target.moduleId}`;
213
+ console.log(`✗ ${e}`);
214
+ return { success: false, error: e };
215
+ }
216
+
217
+ // A cpu/memory change is written to the VM config as PENDING — the running
218
+ // QEMU keeps its old allocation until a power-cycle. Terraform alone won't do
219
+ // that, so the resize op owns the stop/start (already operator-approved above).
220
+ if (decision.needsReboot && node) {
221
+ console.log(`▸ Power-cycling ${name} (vmid ${vmid}) to apply the new size…`);
222
+ const rebooted = await client.rebootVm(node, vmid);
223
+ if (!rebooted.success) {
224
+ const e = `Config updated + reconciled, but the power-cycle failed: ${rebooted.message}. The new size applies on the next stop/start of vmid ${vmid}.`;
225
+ console.log(`✗ ${e}`);
226
+ return { success: false, error: e };
227
+ }
228
+ console.log(' ✓ Power-cycled; new size is live.');
229
+ }
230
+
231
+ console.log(`✓ Resized ${name} and reconciled ${target.moduleId}.`);
232
+ return { success: true, message: `resized ${name}` };
233
+ }
@@ -11,6 +11,7 @@ import { ProxmoxClient, type ProxmoxCredentials } from '../../api-clients/proxmo
11
11
  import { getServiceCredentials, listContainerServices } from '../../services/container-service';
12
12
  import { celiloIntro } from '../prompts';
13
13
  import type { CommandResult } from '../types';
14
+ import { resolveProxmoxService } from './proxmox-service';
14
15
 
15
16
  function formatUptime(sec: number): string {
16
17
  if (sec <= 0) return '—';
@@ -19,40 +20,6 @@ function formatUptime(sec: number): string {
19
20
  return days > 0 ? `${days}d${hours}h` : `${hours}h`;
20
21
  }
21
22
 
22
- /**
23
- * Resolve which Proxmox service to introspect: an explicit service-id arg, else
24
- * the sole Proxmox service. Returns an error message when the choice is
25
- * ambiguous or the named service doesn't exist.
26
- */
27
- function resolveProxmoxService(
28
- services: Awaited<ReturnType<typeof listContainerServices>>,
29
- requested: string | undefined,
30
- ): { service: (typeof services)[number] } | { error: string } {
31
- const proxmox = services.filter((s) => s.providerName === 'proxmox');
32
- if (proxmox.length === 0) {
33
- return {
34
- error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
35
- };
36
- }
37
- if (requested) {
38
- const match = proxmox.find((s) => s.serviceId === requested);
39
- if (!match) {
40
- return {
41
- error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
42
- };
43
- }
44
- return { service: match };
45
- }
46
- if (proxmox.length > 1) {
47
- return {
48
- error: `Multiple Proxmox services — specify one: celilo proxmox node list <service-id>\n ${proxmox
49
- .map((s) => s.serviceId)
50
- .join('\n ')}`,
51
- };
52
- }
53
- return { service: proxmox[0] };
54
- }
55
-
56
23
  export async function handleProxmoxNodeList(
57
24
  args: string[],
58
25
  _flags: Record<string, boolean | string> = {},
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { type ResizeContext, computeFloor, validateResize } from './proxmox-resize-guards';
3
+
4
+ const ctx = (over: Partial<ResizeContext> = {}): ResizeContext => ({
5
+ current: { cpu: 4, memory: 8192, disk: 80 },
6
+ floor: { cpu: 4, memory: 8192, disk: 80 },
7
+ nodeFreeMemMb: 20000,
8
+ nodeTotalCores: 6,
9
+ ...over,
10
+ });
11
+
12
+ describe('validateResize (ISS-0150)', () => {
13
+ test('a valid memory bump within headroom passes and needs a reboot', () => {
14
+ const d = validateResize({ memory: 16384 }, ctx());
15
+ expect(d.errors).toEqual([]);
16
+ expect(d.needsReboot).toBe(true);
17
+ expect(d.effective).toEqual({ cpu: 4, memory: 16384, disk: 80 });
18
+ });
19
+
20
+ test('below the floor is rejected (hard, not overridable)', () => {
21
+ const d = validateResize({ memory: 4096 }, ctx(), { force: true });
22
+ expect(d.errors.length).toBe(1);
23
+ expect(d.errors[0]).toContain('below the module minimum');
24
+ });
25
+
26
+ test('memory growth beyond node free RAM is a capacity error, overridable with --force', () => {
27
+ const tight = ctx({ nodeFreeMemMb: 1000 }); // only 1 GB free; +8 GB requested
28
+ const blocked = validateResize({ memory: 16384 }, tight, { force: false });
29
+ expect(blocked.errors.length).toBe(1);
30
+ expect(blocked.capacityOnly).toBe(true);
31
+ const forced = validateResize({ memory: 16384 }, tight, { force: true });
32
+ expect(forced.errors).toEqual([]);
33
+ });
34
+
35
+ test('cpu above physical cores is a capacity error', () => {
36
+ const d = validateResize({ cpu: 8 }, ctx(), { force: false });
37
+ expect(d.errors[0]).toContain('exceeds node physical cores');
38
+ expect(validateResize({ cpu: 8 }, ctx(), { force: true }).errors).toEqual([]);
39
+ });
40
+ });
41
+
42
+ describe('computeFloor', () => {
43
+ test('takes the max of each field across co-hosted modules', () => {
44
+ expect(
45
+ computeFloor([
46
+ { cpu: 2, memory: 4096, disk: 40 },
47
+ { cpu: 4, memory: 2048, disk: 80 },
48
+ ]),
49
+ ).toEqual({ cpu: 4, memory: 4096, disk: 80 });
50
+ });
51
+
52
+ test('treats missing minimums as 0', () => {
53
+ expect(computeFloor([{ memory: 8192 }, {}])).toEqual({ cpu: 0, memory: 8192, disk: 0 });
54
+ });
55
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pure guardrails for `celilo proxmox … resize` (ISS-0150 D4). Kept separate from
3
+ * the command so the floor + capacity logic is unit-testable without Proxmox or a
4
+ * DB. See apps/celilo/designs/PROXMOX_INSTANCE_SIZING.md.
5
+ */
6
+
7
+ export interface ResizeRequest {
8
+ cpu?: number;
9
+ memory?: number; // MB
10
+ disk?: number; // GB
11
+ }
12
+
13
+ export interface ResizeContext {
14
+ /** Current canonical size of the system (null where unknown). */
15
+ current: { cpu: number | null; memory: number | null; disk: number | null };
16
+ /**
17
+ * Minimum floor — `max(requires.system.<field>)` across every module co-hosted
18
+ * on this system. A resize may never drop below any tenant's minimum.
19
+ */
20
+ floor: { cpu: number; memory: number; disk: number };
21
+ /** Free RAM on the target node — memory growth must fit (delta check). */
22
+ nodeFreeMemMb: number;
23
+ /** Physical cores on the target node — a VM's vCPU can't exceed this (absolute). */
24
+ nodeTotalCores: number;
25
+ }
26
+
27
+ export interface ResizeDecision {
28
+ /** Hard errors that block the resize (unless overridden where noted). */
29
+ errors: string[];
30
+ /** The size that would be applied (requested fields merged over current). */
31
+ effective: { cpu: number | null; memory: number | null; disk: number | null };
32
+ /** cpu/memory changes need a Proxmox stop/start; disk-only growth does not. */
33
+ needsReboot: boolean;
34
+ /** True when the only blocker is capacity (so `--force` can override). */
35
+ capacityOnly: boolean;
36
+ }
37
+
38
+ /**
39
+ * Validate a resize request against the floor and node capacity. Disk shrink is
40
+ * rejected (qemu can't shrink safely); disk resize is otherwise deferred
41
+ * (D4) — callers should reject a disk change until that lands, but the floor/grow
42
+ * checks are here for when it does.
43
+ */
44
+ export function validateResize(
45
+ req: ResizeRequest,
46
+ ctx: ResizeContext,
47
+ opts: { force: boolean } = { force: false },
48
+ ): ResizeDecision {
49
+ const effective = {
50
+ cpu: req.cpu ?? ctx.current.cpu,
51
+ memory: req.memory ?? ctx.current.memory,
52
+ disk: req.disk ?? ctx.current.disk,
53
+ };
54
+
55
+ const floorErrors: string[] = [];
56
+ if (req.cpu != null && req.cpu < ctx.floor.cpu) {
57
+ floorErrors.push(`cpu ${req.cpu} is below the module minimum (${ctx.floor.cpu})`);
58
+ }
59
+ if (req.memory != null && req.memory < ctx.floor.memory) {
60
+ floorErrors.push(`memory ${req.memory}MB is below the module minimum (${ctx.floor.memory}MB)`);
61
+ }
62
+ if (req.disk != null && req.disk < ctx.floor.disk) {
63
+ floorErrors.push(`disk ${req.disk}GB is below the module minimum (${ctx.floor.disk}GB)`);
64
+ }
65
+
66
+ const capErrors: string[] = [];
67
+ // cpu: a single VM's vCPU can't usefully exceed the node's physical cores.
68
+ if (req.cpu != null && req.cpu > ctx.nodeTotalCores) {
69
+ capErrors.push(`cpu ${req.cpu} exceeds node physical cores (${ctx.nodeTotalCores})`);
70
+ }
71
+ // memory: only the GROWTH beyond the current size draws on free RAM.
72
+ if (req.memory != null && ctx.current.memory != null) {
73
+ const addedMb = req.memory - ctx.current.memory;
74
+ if (addedMb > ctx.nodeFreeMemMb) {
75
+ capErrors.push(`+${addedMb}MB exceeds node free RAM (${ctx.nodeFreeMemMb}MB)`);
76
+ }
77
+ }
78
+
79
+ const capacityOnly = floorErrors.length === 0 && capErrors.length > 0;
80
+ // Floor is a hard floor (never overridable). Capacity is overridable with --force.
81
+ const errors = [...floorErrors, ...(opts.force ? [] : capErrors)];
82
+
83
+ const needsReboot = req.cpu != null || req.memory != null;
84
+
85
+ return { errors, effective, needsReboot, capacityOnly };
86
+ }
87
+
88
+ /**
89
+ * Compute the floor for a system from the requires.system minimums of every
90
+ * module co-hosted on it. Pure over the extracted minimums.
91
+ */
92
+ export function computeFloor(
93
+ mins: Array<{ cpu?: number | null; memory?: number | null; disk?: number | null }>,
94
+ ): { cpu: number; memory: number; disk: number } {
95
+ const maxOf = (pick: (m: (typeof mins)[number]) => number | null | undefined): number =>
96
+ mins.reduce((acc, m) => Math.max(acc, pick(m) ?? 0), 0);
97
+ return {
98
+ cpu: maxOf((m) => m.cpu),
99
+ memory: maxOf((m) => m.memory),
100
+ disk: maxOf((m) => m.disk),
101
+ };
102
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared Proxmox-service resolution for the `celilo proxmox …` command tree
3
+ * (node list, vm/ct list|show|resize). Resolves an explicit service-id arg, else
4
+ * the sole Proxmox service; errors when ambiguous or unknown.
5
+ */
6
+
7
+ import type { listContainerServices } from '../../services/container-service';
8
+
9
+ type Services = Awaited<ReturnType<typeof listContainerServices>>;
10
+
11
+ export function resolveProxmoxService(
12
+ services: Services,
13
+ requested: string | undefined,
14
+ ): { service: Services[number] } | { error: string } {
15
+ const proxmox = services.filter((s) => s.providerName === 'proxmox');
16
+ if (proxmox.length === 0) {
17
+ return {
18
+ error: 'No Proxmox container service configured. Add one: celilo service add proxmox',
19
+ };
20
+ }
21
+ if (requested) {
22
+ const match = proxmox.find((s) => s.serviceId === requested);
23
+ if (!match) {
24
+ return {
25
+ error: `No Proxmox service '${requested}'. Known: ${proxmox.map((s) => s.serviceId).join(', ')}`,
26
+ };
27
+ }
28
+ return { service: match };
29
+ }
30
+ if (proxmox.length > 1) {
31
+ return {
32
+ error: `Multiple Proxmox services — specify one with <service-id>:\n ${proxmox
33
+ .map((s) => s.serviceId)
34
+ .join('\n ')}`,
35
+ };
36
+ }
37
+ return { service: proxmox[0] };
38
+ }
@@ -248,13 +248,21 @@ export async function getCompletions(words: string[], current: number): Promise<
248
248
  // Service subcommands
249
249
  // Proxmox subcommands
250
250
  if (command === 'proxmox' && currentIndex === 1) {
251
- return filterSuggestions(['node'], args[1] || '');
251
+ return filterSuggestions(['node', 'vm', 'ct'], args[1] || '');
252
252
  }
253
253
  if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) {
254
254
  return filterSuggestions(['list'], args[2] || '');
255
255
  }
256
- // proxmox node list <service-id> proxmox services only
257
- 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
+ ) {
258
266
  const services = await listContainerServices();
259
267
  const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId);
260
268
  return filterSuggestions(serviceIds, args[3] || '');
package/src/cli/index.ts CHANGED
@@ -67,6 +67,8 @@ import { handleModuleUpdate } from './commands/module-update';
67
67
  import { handleModuleUpgrade } from './commands/module-upgrade';
68
68
  import { moduleVerify } from './commands/module-verify';
69
69
  import { handlePackage } from './commands/package';
70
+ import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
71
+ import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
70
72
  import { handleProxmoxNodeList } from './commands/proxmox-node-list';
71
73
  import { main as runPublish } from './commands/publish';
72
74
  import { handleSecretList } from './commands/secret-list';
@@ -1816,6 +1818,19 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1816
1818
  error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
1817
1819
  };
1818
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
+ }
1819
1834
  return {
1820
1835
  success: false,
1821
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
  },
@@ -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
  }
@@ -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.