@celilo/cli 0.6.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 +1 -1
- package/src/cli/command-registry.ts +65 -1
- 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/completion.ts +11 -3
- package/src/cli/index.ts +15 -0
- package/src/db/schema.ts +11 -0
- package/src/services/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/variables/context.ts +36 -7
package/package.json
CHANGED
|
@@ -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,219 @@
|
|
|
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
|
+
console.log(
|
|
204
|
+
`✓ Canonical size updated: ${name} → ${reqCpu ?? target.cpu}c / ${reqMemory ?? target.memory}MB`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Reconcile declaratively: redeploy the owning module — its Terraform now reads
|
|
208
|
+
// the updated system size and applies it to the live instance.
|
|
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
|
+
console.log(`✓ Resized ${name} and reconciled ${target.moduleId}.`);
|
|
218
|
+
return { success: true, message: `resized ${name}` };
|
|
219
|
+
}
|
|
@@ -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
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -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
|
-
|
|
257
|
-
|
|
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
|
}
|
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.
|