@celilo/cli 0.8.2 → 0.9.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/AGENTS.md +10 -18
- package/CELILO_CORE_MODULES.md +61 -0
- package/CELILO_SUBSYSTEMS.md +83 -0
- package/README.md +1539 -48
- package/drizzle/0012_module_systems_sizing.sql +3 -0
- package/drizzle/0013_dns_view_overrides.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +4 -3
- package/src/capabilities/well-known.test.ts +12 -7
- package/src/capabilities/well-known.ts +11 -3
- package/src/cli/command-registry.ts +65 -1
- package/src/cli/commands/module-upgrade.test.ts +29 -0
- package/src/cli/commands/module-upgrade.ts +57 -24
- package/src/cli/commands/proxmox-instance-list.test.ts +77 -0
- package/src/cli/commands/proxmox-instance-list.ts +140 -0
- package/src/cli/commands/proxmox-instance-resize.ts +235 -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 +21 -1
- package/src/hooks/capability-loader.ts +22 -0
- package/src/manifest/template-validator.test.ts +31 -1
- package/src/manifest/template-validator.ts +9 -0
- package/src/services/deployed-systems.test.ts +73 -1
- package/src/services/deployed-systems.ts +72 -0
- package/src/services/dns-internal-records.test.ts +76 -3
- package/src/services/dns-internal-records.ts +52 -3
- package/src/services/dns-provider-backfill.ts +15 -3
- package/src/services/fleet-checks.test.ts +18 -16
- package/src/services/machine-detector.ts +34 -12
- package/src/templates/generator.ts +49 -1
- package/src/variables/context.ts +36 -7
- package/CLI_USAGE.md +0 -433
|
@@ -0,0 +1,235 @@
|
|
|
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. The proxmox
|
|
209
|
+
// provider itself stop/starts the VM to apply a cpu/memory change (the reboot
|
|
210
|
+
// the operator approved above), so no explicit power-cycle is needed here.
|
|
211
|
+
console.log(`▸ Reconciling ${target.moduleId} (Terraform apply + VM restart)…`);
|
|
212
|
+
const deployed = await deployModule(target.moduleId, db, {});
|
|
213
|
+
if (!deployed.success) {
|
|
214
|
+
// The provider's restart-on-resize can surface a non-fatal "VM already
|
|
215
|
+
// running" and exit non-zero even though the deploy reached VERIFIED (the
|
|
216
|
+
// same deploy-over-SSH pattern noted in CLAUDE.md). Trust the module STATE
|
|
217
|
+
// over the exit code: VERIFIED means the resize landed.
|
|
218
|
+
const state = db
|
|
219
|
+
.select({ state: modules.state })
|
|
220
|
+
.from(modules)
|
|
221
|
+
.where(eq(modules.id, target.moduleId))
|
|
222
|
+
.get()?.state;
|
|
223
|
+
if (state !== 'VERIFIED') {
|
|
224
|
+
const e = `Size updated but reconcile failed: ${deployed.error}. Re-run: celilo module deploy ${target.moduleId}`;
|
|
225
|
+
console.log(`✗ ${e}`);
|
|
226
|
+
return { success: false, error: e };
|
|
227
|
+
}
|
|
228
|
+
console.log(
|
|
229
|
+
` ⚠ reconcile exited non-zero but ${target.moduleId} is VERIFIED — treating as success`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log(`✓ Resized ${name} and reconciled ${target.moduleId} (new size live).`);
|
|
234
|
+
return { success: true, message: `resized ${name}` };
|
|
235
|
+
}
|
|
@@ -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
|
@@ -69,6 +69,8 @@ import { handleModuleUpgrade } from './commands/module-upgrade';
|
|
|
69
69
|
import { moduleVerify } from './commands/module-verify';
|
|
70
70
|
import { handleModuleVersion } from './commands/module-version';
|
|
71
71
|
import { handlePackage } from './commands/package';
|
|
72
|
+
import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
|
|
73
|
+
import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
|
|
72
74
|
import { handleProxmoxNodeList } from './commands/proxmox-node-list';
|
|
73
75
|
import { main as runPublish } from './commands/publish';
|
|
74
76
|
import { handleSecretList } from './commands/secret-list';
|
|
@@ -1822,6 +1824,19 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1822
1824
|
error: 'Proxmox node action required (list)\n\nRun "celilo proxmox --help" for usage',
|
|
1823
1825
|
};
|
|
1824
1826
|
}
|
|
1827
|
+
if (parsed.subcommand === 'vm' || parsed.subcommand === 'ct') {
|
|
1828
|
+
const action = parsed.args[0];
|
|
1829
|
+
if (action === 'list') {
|
|
1830
|
+
return handleProxmoxInstanceList(parsed.subcommand, parsed.args.slice(1));
|
|
1831
|
+
}
|
|
1832
|
+
if (action === 'resize') {
|
|
1833
|
+
return handleProxmoxInstanceResize(parsed.subcommand, parsed.args.slice(1), parsed.flags);
|
|
1834
|
+
}
|
|
1835
|
+
return {
|
|
1836
|
+
success: false,
|
|
1837
|
+
error: `Proxmox ${parsed.subcommand} action required (list, resize)\n\nRun "celilo proxmox --help" for usage`,
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1825
1840
|
return {
|
|
1826
1841
|
success: false,
|
|
1827
1842
|
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
|
},
|
|
@@ -492,8 +503,17 @@ export const dnsInternalRecords = sqliteTable(
|
|
|
492
503
|
.references(() => modules.id, { onDelete: 'cascade' }),
|
|
493
504
|
/** The registered hostname (e.g. "git-ssh.git.celilo.computer"). */
|
|
494
505
|
host: text('host').notNull(),
|
|
495
|
-
/** The A-record value celilo asked the resolver to serve
|
|
506
|
+
/** The A-record value celilo asked the resolver to serve — the LAN/default
|
|
507
|
+
* answer (firewall natIp for a caddy-fronted host). */
|
|
496
508
|
ip: text('ip').notNull(),
|
|
509
|
+
/**
|
|
510
|
+
* In-zone split-horizon answer (caddy's zone-routable IP), when this is a
|
|
511
|
+
* caddy-fronted hostname that needs source-based views (ISS-0156,
|
|
512
|
+
* v2/INTERNAL_DNS_ZONE_VIEWS.md). NULL for records with no zone override
|
|
513
|
+
* (per-system identity, plain A records). This column is the durable
|
|
514
|
+
* desired-state the resolver's view config is reconciled from.
|
|
515
|
+
*/
|
|
516
|
+
zoneRoutableIp: text('zone_routable_ip'),
|
|
497
517
|
registeredAt: integer('registered_at', { mode: 'timestamp' })
|
|
498
518
|
.notNull()
|
|
499
519
|
.default(sql`(unixepoch())`),
|
|
@@ -124,6 +124,28 @@ export async function resolveFirewallNatIp(db: DbClient): Promise<string | undef
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Caddy's zone-routable IP — its own DMZ ingress address (`target_ip`, the same
|
|
129
|
+
* value public_web exposes as `dmz_ip`). This is the in-zone split-horizon
|
|
130
|
+
* answer (ISS-0156): clients INSIDE the segmented zones reach caddy here, since
|
|
131
|
+
* they can't route to the firewall natIp. Returns undefined when no public_web
|
|
132
|
+
* provider advertises a `target_ip`. Shared by the live public_web registration
|
|
133
|
+
* and the deploy-time backfill so both write the same `zoneRoutableValue`.
|
|
134
|
+
*/
|
|
135
|
+
export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefined> {
|
|
136
|
+
const webProviders = db
|
|
137
|
+
.select()
|
|
138
|
+
.from(capabilities)
|
|
139
|
+
.where(eq(capabilities.capabilityName, 'public_web'))
|
|
140
|
+
.all();
|
|
141
|
+
for (const wp of webProviders) {
|
|
142
|
+
const cfg = await loadModuleConfig(wp.moduleId, db);
|
|
143
|
+
const ip = String(cfg.target_ip ?? '').split('/')[0];
|
|
144
|
+
if (ip) return ip;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
127
149
|
export async function loadCapabilityFunctions(
|
|
128
150
|
consumingModuleId: string,
|
|
129
151
|
db: DbClient,
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import type { ModuleManifest } from './schema';
|
|
3
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type TemplateValidationError,
|
|
8
|
+
formatTemplateValidationErrors,
|
|
9
|
+
validateModuleTemplates,
|
|
10
|
+
} from './template-validator';
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
13
|
* Create a minimal valid manifest for testing
|
|
@@ -66,6 +73,29 @@ describe('template-validator', () => {
|
|
|
66
73
|
expect(system?.storage).toBe('local-lvm');
|
|
67
74
|
});
|
|
68
75
|
|
|
76
|
+
test('accepts $self:{cores,memory,disk,storage} as auto-allocated sizing (ISS-0150)', async () => {
|
|
77
|
+
// ISS-0150 repointed instance Terraform to read $self:cores/memory/disk/
|
|
78
|
+
// storage, which are injected at generate time from module_systems (see
|
|
79
|
+
// variables/context.ts) — not declared in the manifest. They must validate
|
|
80
|
+
// at `module import` time like vmid/target_ip, or every VM/CT module fails
|
|
81
|
+
// to import. Regression guard: this previously errored "Self variable
|
|
82
|
+
// 'cores' not found in module configuration".
|
|
83
|
+
const manifest = createTestManifest();
|
|
84
|
+
const dir = await mkdtemp(join(tmpdir(), 'celilo-tpl-'));
|
|
85
|
+
try {
|
|
86
|
+
await mkdir(join(dir, 'terraform'), { recursive: true });
|
|
87
|
+
await writeFile(
|
|
88
|
+
join(dir, 'terraform/main.tf.tpl'),
|
|
89
|
+
'cores = $self:cores\nmemory = $self:memory\ndisk = $self:disk\nstorage = "$self:storage"\n',
|
|
90
|
+
);
|
|
91
|
+
const result = await validateModuleTemplates(dir, manifest);
|
|
92
|
+
expect(result.errors).toEqual([]);
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
69
99
|
test('validates capability references', async () => {
|
|
70
100
|
const manifest = createTestManifest({
|
|
71
101
|
requires: {
|
|
@@ -73,6 +73,15 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
|
|
|
73
73
|
'gateway', // Auto-derived from zone configuration
|
|
74
74
|
'target_node', // Can be auto-derived from system config
|
|
75
75
|
'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (v2/LXC_INTERNAL_DNS.md)
|
|
76
|
+
// Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
|
|
77
|
+
// disk,storage}, which are injected during resolution from the module_systems
|
|
78
|
+
// table (falling back to requires.system.*) — see variables/context.ts. Like
|
|
79
|
+
// vmid/target_ip they are populated at generate time, not declared in the
|
|
80
|
+
// manifest, so they are auto-allocated rather than import-time validation errors.
|
|
81
|
+
'cores', // requires.system.cpu / module_systems.cpu
|
|
82
|
+
'memory', // requires.system.memory / module_systems.memory
|
|
83
|
+
'disk', // requires.system.disk / module_systems.disk
|
|
84
|
+
'storage', // storage pool name (requires.system.storage)
|
|
76
85
|
]);
|
|
77
86
|
|
|
78
87
|
/**
|