@celilo/cli 0.7.1 → 0.8.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.
- package/drizzle/meta/_journal.json +0 -7
- package/package.json +1 -1
- package/src/api-clients/proxmox.ts +0 -17
- package/src/cli/command-registry.ts +1 -65
- package/src/cli/commands/module-changeset.test.ts +52 -0
- package/src/cli/commands/module-changeset.ts +65 -0
- package/src/cli/commands/module-publish.ts +10 -1
- package/src/cli/commands/module-version.test.ts +118 -0
- package/src/cli/commands/module-version.ts +155 -0
- package/src/cli/commands/proxmox-node-list.ts +34 -1
- package/src/cli/completion.ts +3 -11
- package/src/cli/index.ts +6 -15
- package/src/db/schema.ts +0 -11
- package/src/manifest/schema.ts +24 -0
- package/src/module/versioning/changeset-version.test.ts +108 -0
- package/src/module/versioning/changeset-version.ts +139 -0
- package/src/services/deployed-systems.test.ts +1 -73
- package/src/services/deployed-systems.ts +0 -72
- package/src/services/module-validator/git-hygiene.test.ts +35 -0
- package/src/services/module-validator/git-hygiene.ts +36 -17
- package/src/services/module-validator/index.ts +2 -1
- package/src/variables/context.ts +7 -36
- package/drizzle/0012_module_systems_sizing.sql +0 -3
- package/src/cli/commands/proxmox-instance-list.test.ts +0 -77
- package/src/cli/commands/proxmox-instance-list.ts +0 -137
- package/src/cli/commands/proxmox-instance-resize.ts +0 -233
- package/src/cli/commands/proxmox-resize-guards.test.ts +0 -55
- package/src/cli/commands/proxmox-resize-guards.ts +0 -102
- package/src/cli/commands/proxmox-service.ts +0 -38
package/src/variables/context.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
machines,
|
|
9
9
|
moduleConfigs,
|
|
10
10
|
moduleInfrastructure,
|
|
11
|
-
moduleSystems,
|
|
12
11
|
modules,
|
|
13
12
|
secrets,
|
|
14
13
|
systemConfig,
|
|
@@ -265,48 +264,20 @@ export async function buildResolutionContext(
|
|
|
265
264
|
const systemResources = getSingularSystemSpec(manifest);
|
|
266
265
|
|
|
267
266
|
if (systemResources) {
|
|
268
|
-
//
|
|
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
|
-
|
|
267
|
+
// Map manifest fields to module variable names and apply defaults
|
|
291
268
|
const resourceMappings: Array<{
|
|
292
269
|
manifestKey: keyof typeof systemResources;
|
|
293
270
|
configKey: string;
|
|
294
|
-
systemValue: number | null | undefined;
|
|
295
271
|
}> = [
|
|
296
|
-
{ manifestKey: 'cpu', configKey: 'cores'
|
|
297
|
-
{ manifestKey: 'memory', configKey: 'memory'
|
|
298
|
-
{ manifestKey: 'disk', configKey: 'disk'
|
|
299
|
-
{ manifestKey: 'storage', configKey: 'storage'
|
|
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' },
|
|
300
276
|
];
|
|
301
277
|
|
|
302
|
-
for (const { manifestKey, configKey
|
|
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
|
-
}
|
|
278
|
+
for (const { manifestKey, configKey } of resourceMappings) {
|
|
309
279
|
const value = systemResources[manifestKey];
|
|
280
|
+
|
|
310
281
|
// Manifest fields are typed (cpu: number, storage: string, etc.).
|
|
311
282
|
// Pass them through unstringified so valueJson preserves the
|
|
312
283
|
// shape — see comment in the variable-defaults block above.
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,137 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,233 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
}
|