@celilo/cli 0.25.1 → 0.26.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1109.0",
|
|
60
60
|
"@aws-sdk/lib-storage": "^3.1101.0",
|
|
61
|
-
"@celilo/capabilities": "^1.
|
|
61
|
+
"@celilo/capabilities": "^1.3.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
63
|
"@celilo/core": "^0.8.0",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
@@ -75,10 +75,12 @@
|
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@biomejs/biome": "^1.9.4",
|
|
78
|
+
"@celilo/terraform-fake": "^0.3.0",
|
|
78
79
|
"@types/bun": "^1.1.14",
|
|
79
80
|
"@types/react": "^19.2.14",
|
|
80
81
|
"drizzle-kit": "^0.30.0",
|
|
81
82
|
"ink-testing-library": "^4.0.0",
|
|
83
|
+
"msw": "^2.15.0",
|
|
82
84
|
"typescript": "^5.9.3",
|
|
83
85
|
"zod-to-json-schema": "^3.25.2"
|
|
84
86
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Proxmox client, exercised over a real socket — against the SAME handlers
|
|
3
|
+
* the e2e rig serves (D8).
|
|
4
|
+
*
|
|
5
|
+
* `proxmox.ts` hand-rolls `node:https` across 1,169 lines, and its existing
|
|
6
|
+
* 240-line test covers only the pure helpers: not one assertion reaches the
|
|
7
|
+
* wire. So the auth header, the `{data}` envelope, the 401 path and every
|
|
8
|
+
* non-2xx branch have gone unasserted — in the file that talks to the
|
|
9
|
+
* hypervisor.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a real server rather than `setupServer`
|
|
12
|
+
*
|
|
13
|
+
* D8 proposed MSW's in-process `setupServer` for exactly this. **It does not
|
|
14
|
+
* work under Bun**, measured rather than assumed: `setupServer` intercepts
|
|
15
|
+
* global `fetch`, but a `node:https.request` goes straight past it and fails
|
|
16
|
+
* with `ECONNREFUSED` from Bun's own `node:_http_client`. `proxmox.ts` uses
|
|
17
|
+
* `node:https` directly, so the interceptor never sees it.
|
|
18
|
+
*
|
|
19
|
+
* `@celilo/terraform-fake` already exposes the same handlers as a real HTTPS
|
|
20
|
+
* server, so that is what these use. The D8 goal is met either way — ONE handler
|
|
21
|
+
* set behind both the client's unit tests and the rig's simulator, so the two
|
|
22
|
+
* cannot come to believe different things about what Proxmox returns — but it is
|
|
23
|
+
* met over a socket instead of an interceptor. The cost is a port and a
|
|
24
|
+
* self-signed certificate; the client already sets `rejectUnauthorized: false`,
|
|
25
|
+
* as every real consumer does.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
29
|
+
import https from 'node:https';
|
|
30
|
+
import type { AddressInfo } from 'node:net';
|
|
31
|
+
import { type ProxmoxFake, createProxmoxFake } from '@celilo/terraform-fake';
|
|
32
|
+
import {
|
|
33
|
+
ProxmoxClient,
|
|
34
|
+
type ProxmoxCredentials,
|
|
35
|
+
buildProxmoxApiUrl,
|
|
36
|
+
getNodeForVmid,
|
|
37
|
+
listNodeStorage,
|
|
38
|
+
testProxmoxConnection,
|
|
39
|
+
} from './proxmox';
|
|
40
|
+
|
|
41
|
+
let fake: ProxmoxFake;
|
|
42
|
+
let credentials: ProxmoxCredentials;
|
|
43
|
+
let tls: { key: string; cert: string };
|
|
44
|
+
|
|
45
|
+
const TOKEN_ID = 'celilo@pve!e2e';
|
|
46
|
+
const TOKEN_SECRET = '00000000-0000-0000-0000-000000000000';
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
tls = await generateSelfSigned();
|
|
50
|
+
fake = createProxmoxFake({
|
|
51
|
+
tls,
|
|
52
|
+
nodes: [{ name: 'pve1', cores: 8, memoryBytes: 16 * 1024 ** 3, diskBytes: 500 * 1024 ** 3 }],
|
|
53
|
+
storages: [{ name: 'local-lvm', content: 'images,rootdir' }],
|
|
54
|
+
});
|
|
55
|
+
const port = await fake.listen(0);
|
|
56
|
+
credentials = {
|
|
57
|
+
api_url: buildProxmoxApiUrl('127.0.0.1', port),
|
|
58
|
+
api_token_id: TOKEN_ID,
|
|
59
|
+
api_token_secret: TOKEN_SECRET,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await fake.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run one request against a throwaway server that answers however the test
|
|
69
|
+
* needs, for the branches a well-behaved Proxmox never exercises.
|
|
70
|
+
*/
|
|
71
|
+
async function withStubServer<T>(
|
|
72
|
+
reply: (res: import('node:http').ServerResponse) => void,
|
|
73
|
+
run: (credentials: ProxmoxCredentials) => Promise<T>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
const server = https.createServer(tls, (_req, res) => reply(res));
|
|
76
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
77
|
+
const { port } = server.address() as AddressInfo;
|
|
78
|
+
try {
|
|
79
|
+
return await run({
|
|
80
|
+
api_url: buildProxmoxApiUrl('127.0.0.1', port),
|
|
81
|
+
api_token_id: TOKEN_ID,
|
|
82
|
+
api_token_secret: TOKEN_SECRET,
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('the request the client actually sends', () => {
|
|
90
|
+
test('carries the PVEAPIToken header Proxmox requires', async () => {
|
|
91
|
+
// Never asserted before. Proxmox rejects the session outright if this is
|
|
92
|
+
// malformed, and the client builds it by string concatenation.
|
|
93
|
+
let seen: string | undefined;
|
|
94
|
+
const server = https.createServer(tls, (req, res) => {
|
|
95
|
+
seen = req.headers.authorization;
|
|
96
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({ data: [] }));
|
|
98
|
+
});
|
|
99
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
100
|
+
const { port } = server.address() as AddressInfo;
|
|
101
|
+
|
|
102
|
+
await new ProxmoxClient({
|
|
103
|
+
...credentials,
|
|
104
|
+
api_url: buildProxmoxApiUrl('127.0.0.1', port),
|
|
105
|
+
}).clusterResources();
|
|
106
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
107
|
+
|
|
108
|
+
expect(seen).toBe(`PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('unwraps the `{data}` envelope rather than handing back the whole body', async () => {
|
|
112
|
+
const result = await listNodeStorage(credentials, 'pve1');
|
|
113
|
+
|
|
114
|
+
expect(result.success).toBe(true);
|
|
115
|
+
expect(Array.isArray(result.success && result.data)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a query string is DROPPED — the constraint every caller works around', async () => {
|
|
119
|
+
// `makeProxmoxRequest` passes `url.pathname` and never `url.search`, so a
|
|
120
|
+
// filter silently does not arrive. `getNodeForVmid` documents this and
|
|
121
|
+
// fetches the whole inventory instead. Pinned here so that adding `?type=vm`
|
|
122
|
+
// somewhere and quietly getting unfiltered results is a test failure rather
|
|
123
|
+
// than a puzzling runtime one.
|
|
124
|
+
let seenUrl: string | undefined;
|
|
125
|
+
const server = https.createServer(tls, (req, res) => {
|
|
126
|
+
seenUrl = req.url;
|
|
127
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
128
|
+
res.end(JSON.stringify({ data: [] }));
|
|
129
|
+
});
|
|
130
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
131
|
+
const { port } = server.address() as AddressInfo;
|
|
132
|
+
|
|
133
|
+
await getNodeForVmid({ ...credentials, api_url: buildProxmoxApiUrl('127.0.0.1', port) }, 1);
|
|
134
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
135
|
+
|
|
136
|
+
expect(seenUrl).toBe('/api2/json/cluster/resources');
|
|
137
|
+
expect(seenUrl).not.toContain('?');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('a POST — the other half of the wire contract', () => {
|
|
142
|
+
test('sends form-encoded parameters, not JSON', async () => {
|
|
143
|
+
// Proxmox only accepts `application/x-www-form-urlencoded`; sending JSON
|
|
144
|
+
// gets a 400 that names no field. Never asserted before.
|
|
145
|
+
//
|
|
146
|
+
// The stub answers BOTH paths, because `setGuestPower` first resolves the
|
|
147
|
+
// node via `/cluster/resources` and only then issues the POST.
|
|
148
|
+
let contentType: string | undefined;
|
|
149
|
+
let postedTo: string | undefined;
|
|
150
|
+
let body = '';
|
|
151
|
+
const server = https.createServer(tls, (req, res) => {
|
|
152
|
+
if (req.method === 'GET') {
|
|
153
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
154
|
+
res.end(JSON.stringify({ data: [{ vmid: 403, node: 'pve1', type: 'lxc' }] }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
contentType = req.headers['content-type'];
|
|
158
|
+
postedTo = req.url;
|
|
159
|
+
req.on('data', (c) => {
|
|
160
|
+
body += c;
|
|
161
|
+
});
|
|
162
|
+
req.on('end', () => {
|
|
163
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
164
|
+
res.end(JSON.stringify({ data: 'UPID:pve1:00000001:0:66BF0000:vzshutdown:403:root@pam:' }));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
168
|
+
const { port } = server.address() as AddressInfo;
|
|
169
|
+
|
|
170
|
+
const result = await new ProxmoxClient({
|
|
171
|
+
...credentials,
|
|
172
|
+
api_url: buildProxmoxApiUrl('127.0.0.1', port),
|
|
173
|
+
}).setGuestPower(403, 'lxc', 'shutdown');
|
|
174
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
175
|
+
|
|
176
|
+
expect(result.success).toBe(true);
|
|
177
|
+
expect(contentType).toBe('application/x-www-form-urlencoded');
|
|
178
|
+
expect(body).not.toStartWith('{');
|
|
179
|
+
// `shutdown`, not `stop`: a pause is planned, so the guest is asked to go
|
|
180
|
+
// quietly rather than having its power pulled.
|
|
181
|
+
expect(postedTo).toBe('/api2/json/nodes/pve1/lxc/403/status/shutdown');
|
|
182
|
+
}, 30_000);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('what the client does with a response it did not want', () => {
|
|
186
|
+
test('401 says the credentials are wrong, not merely that something failed', async () => {
|
|
187
|
+
const result = await withStubServer(
|
|
188
|
+
(res) => {
|
|
189
|
+
res.writeHead(401);
|
|
190
|
+
res.end();
|
|
191
|
+
},
|
|
192
|
+
(creds) => new ProxmoxClient(creds).clusterResources(),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(result.success).toBe(false);
|
|
196
|
+
expect(result.success === false && result.message).toContain('Authentication failed');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('a 500 reports the status, so a caller can tell it from a refusal', async () => {
|
|
200
|
+
const result = await withStubServer(
|
|
201
|
+
(res) => {
|
|
202
|
+
res.writeHead(500);
|
|
203
|
+
res.end('boom');
|
|
204
|
+
},
|
|
205
|
+
(creds) => new ProxmoxClient(creds).clusterResources(),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.success).toBe(false);
|
|
209
|
+
expect(result.success === false && result.message).toContain('500');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('a non-JSON body is a parse failure, not a crash', async () => {
|
|
213
|
+
// Proxmox behind a misconfigured proxy answers HTML. The client must still
|
|
214
|
+
// return a result, because every caller branches on `.success`.
|
|
215
|
+
const result = await withStubServer(
|
|
216
|
+
(res) => {
|
|
217
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
218
|
+
res.end('<html>nope</html>');
|
|
219
|
+
},
|
|
220
|
+
(creds) => new ProxmoxClient(creds).clusterResources(),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(result.success).toBe(false);
|
|
224
|
+
expect(result.success === false && result.message).toContain('parse');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('an unreachable host resolves to a failure instead of rejecting', async () => {
|
|
228
|
+
// The whole function is a `new Promise(resolve)` with no reject path, so a
|
|
229
|
+
// throw here would surface as an unhandled rejection inside a CLI command.
|
|
230
|
+
const result = await new ProxmoxClient({
|
|
231
|
+
...credentials,
|
|
232
|
+
// Reserved for documentation (RFC 5737) and never routable.
|
|
233
|
+
api_url: buildProxmoxApiUrl('192.0.2.1', 9),
|
|
234
|
+
}).clusterResources();
|
|
235
|
+
|
|
236
|
+
expect(result.success).toBe(false);
|
|
237
|
+
}, 30_000);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('against the real handler set', () => {
|
|
241
|
+
test('listNodeStorage returns the storages the fake declares', async () => {
|
|
242
|
+
const result = await listNodeStorage(credentials, 'pve1');
|
|
243
|
+
const names = result.success
|
|
244
|
+
? (result.data as Array<{ storage: string }>).map((s) => s.storage)
|
|
245
|
+
: [];
|
|
246
|
+
|
|
247
|
+
expect(names).toContain('local-lvm');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('listClusterResources gets node rows AND guest rows from one unfiltered call', async () => {
|
|
251
|
+
// Why the fake returns both when no `?type=` is given, even though the
|
|
252
|
+
// Terraform provider needs the filtered form: this client cannot ask for a
|
|
253
|
+
// filter (see the dropped-query test) and needs both kinds at once.
|
|
254
|
+
fake.state.addGuest({
|
|
255
|
+
vmid: 401,
|
|
256
|
+
node: 'pve1',
|
|
257
|
+
kind: 'lxc',
|
|
258
|
+
hostname: 'client-test',
|
|
259
|
+
status: 'running',
|
|
260
|
+
config: { cores: '2', memory: '1024' },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await new ProxmoxClient(credentials).clusterResources();
|
|
264
|
+
const kinds = new Set(result.success ? result.data.map((r) => r.type) : []);
|
|
265
|
+
|
|
266
|
+
expect(kinds.has('node')).toBe(true);
|
|
267
|
+
expect(kinds.has('lxc')).toBe(true);
|
|
268
|
+
|
|
269
|
+
fake.state.removeGuest(401);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('getNodeForVmid finds the node a guest lives on', async () => {
|
|
273
|
+
fake.state.addGuest({
|
|
274
|
+
vmid: 402,
|
|
275
|
+
node: 'pve1',
|
|
276
|
+
kind: 'lxc',
|
|
277
|
+
hostname: 'placed',
|
|
278
|
+
status: 'running',
|
|
279
|
+
config: {},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const result = await getNodeForVmid(credentials, 402);
|
|
283
|
+
|
|
284
|
+
expect(result.success && result.data).toBe('pve1');
|
|
285
|
+
|
|
286
|
+
fake.state.removeGuest(402);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('getNodeForVmid returns null for a vmid that does not exist yet', async () => {
|
|
290
|
+
// First deploy: the container has not been created. Distinct from an error,
|
|
291
|
+
// and the callers rely on the difference.
|
|
292
|
+
const result = await getNodeForVmid(credentials, 9999);
|
|
293
|
+
|
|
294
|
+
expect(result.success).toBe(true);
|
|
295
|
+
expect(result.success && result.data).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('testProxmoxConnection', () => {
|
|
300
|
+
test('succeeds against a token with full permissions', async () => {
|
|
301
|
+
const result = await testProxmoxConnection(credentials);
|
|
302
|
+
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('a 401 on the probe fails the connection test', async () => {
|
|
307
|
+
const result = await withStubServer(
|
|
308
|
+
(res) => {
|
|
309
|
+
res.writeHead(401);
|
|
310
|
+
res.end();
|
|
311
|
+
},
|
|
312
|
+
(creds) => testProxmoxConnection(creds),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(result.success).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/** A throwaway pair. Validity beyond "parses as a cert" is not the point. */
|
|
320
|
+
async function generateSelfSigned(): Promise<{ key: string; cert: string }> {
|
|
321
|
+
const dir = `/tmp/proxmox-client-test-${process.pid}`;
|
|
322
|
+
await Bun.$`mkdir -p ${dir}`.quiet();
|
|
323
|
+
await Bun.$`openssl req -x509 -newkey rsa:2048 -keyout ${dir}/key.pem -out ${dir}/cert.pem -days 1 -nodes -subj /CN=proxmox.test`.quiet();
|
|
324
|
+
return {
|
|
325
|
+
key: await Bun.file(`${dir}/key.pem`).text(),
|
|
326
|
+
cert: await Bun.file(`${dir}/cert.pem`).text(),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -109,6 +109,10 @@ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFacto
|
|
|
109
109
|
script: 'scripts/publish-functions.ts',
|
|
110
110
|
legacyFactoryName: 'default',
|
|
111
111
|
},
|
|
112
|
+
control_plane_vpn: {
|
|
113
|
+
script: 'scripts/control-plane-vpn-functions.ts',
|
|
114
|
+
legacyFactoryName: 'default',
|
|
115
|
+
},
|
|
112
116
|
};
|
|
113
117
|
|
|
114
118
|
/**
|
|
@@ -76,22 +76,19 @@ export function addCIDR(ip: string, maskBits: number): string {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* Check if an IP address belongs to a subnet
|
|
80
|
-
*
|
|
79
|
+
* Check if an IP address belongs to a subnet.
|
|
80
|
+
*
|
|
81
|
+
* Re-exported from `@celilo/capabilities` rather than implemented here, so the
|
|
82
|
+
* one answer serves both celilo and module scripts — which cannot import from
|
|
83
|
+
* the backend and would otherwise carry a second copy (celilo#809).
|
|
84
|
+
*
|
|
85
|
+
* The implementation that used to live here compared the first three octets and
|
|
86
|
+
* threw away the prefix it had parsed, so it was right for /24 and wrong in both
|
|
87
|
+
* directions for anything else. It also routed through `parseSubnet`, which
|
|
88
|
+
* REFUSES anything smaller than a /24 — an allocation rule that has no business
|
|
89
|
+
* constraining a containment question.
|
|
81
90
|
*/
|
|
82
|
-
export
|
|
83
|
-
const ip = stripCIDR(ipWithMask);
|
|
84
|
-
const subnetInfo = parseSubnet(subnet);
|
|
85
|
-
|
|
86
|
-
const ipOctets = ip.split('.').map((o) => Number.parseInt(o, 10));
|
|
87
|
-
|
|
88
|
-
// For /24 subnet, just check first 3 octets
|
|
89
|
-
return (
|
|
90
|
-
ipOctets[0] === subnetInfo.octets[0] &&
|
|
91
|
-
ipOctets[1] === subnetInfo.octets[1] &&
|
|
92
|
-
ipOctets[2] === subnetInfo.octets[2]
|
|
93
|
-
);
|
|
94
|
-
}
|
|
91
|
+
export { isInSubnet } from '@celilo/capabilities';
|
|
95
92
|
|
|
96
93
|
/**
|
|
97
94
|
* Generate all possible IPs in a subnet range
|