@homeflare/alchemy 0.8.0 → 0.10.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/README.md +22 -11
- package/dist/caddy/config-lifecycle.d.ts +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +221 -53
- package/dist/caddy/index.js.map +13 -9
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +1 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -1
- package/dist/launchd/host-effect.d.ts +9 -18
- package/dist/launchd/host-effect.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +345 -86
- package/dist/launchd/index.js.map +18 -12
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +7 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +31 -4
- package/dist/launchd/sudo-guard.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +3 -3
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-families.d.ts +6 -3
- package/dist/openbao/fake-families.d.ts.map +1 -1
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/index.js +541 -270
- package/dist/openbao/index.js.map +26 -21
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +6 -2
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +31 -6
- package/docs/launchd.md +7 -3
- package/docs/ownership.md +113 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +1 -1
- package/src/caddy/config-lifecycle.ts +1 -1
- package/src/caddy/config.ts +2 -1
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/host-effect.ts +1 -33
- package/src/launchd/host-file-lifecycle.ts +39 -14
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +18 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/runner.ts +7 -0
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.ts +89 -10
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner.ts +40 -59
- package/src/launchd/sudo-said.ts +58 -0
- package/src/openbao/README.md +5 -2
- package/src/openbao/REPLACE.md +13 -11
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role.ts +14 -4
- package/src/openbao/cloudflare-role.ts +17 -4
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-families.ts +11 -5
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/jwt-config.ts +10 -7
- package/src/openbao/jwt-role.ts +10 -7
- package/src/openbao/kubernetes-role.ts +13 -7
- package/src/openbao/mfa-enforcement.ts +20 -6
- package/src/openbao/mfa-totp.ts +22 -7
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +15 -4
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +17 -6
- package/src/openbao/policy.ts +17 -4
- package/src/openbao/proxmox-role.ts +13 -4
- package/src/openbao/rename-adoption.test.ts +32 -19
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role.ts +14 -4
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake PVE cluster for `Proxmox.Lxc`: containers in memory, tasks that finish at once, and the
|
|
3
|
+
* OpenBao creds endpoint the mint reads — one Bun server, reached through `FetchHttpClient.Fetch`.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ TEST-ONLY, AND NEVER A REAL HOST. No provider imports this file and it is not on the barrel.
|
|
6
|
+
* Hostnames are `.test` (RFC 2606) and are remapped to the loopback server; any other host is
|
|
7
|
+
* refused with ENOTFOUND rather than resolved (client.test.ts has the incident behind that).
|
|
8
|
+
* ★ IT ANSWERS THE WAY PVE 9 DOES WHERE THE PROVIDER DEPENDS ON IT, and each shape is a measured
|
|
9
|
+
* one: a missing config is HTTP 500 with `message` in the JSON body (pve-http-server
|
|
10
|
+
* `Formatter/Standard.pm`); a PUT with a stale `digest` is refused; a create allocates
|
|
11
|
+
* `<storage>:subvol-<vmid>-disk-<n>,size=<n>G` and generates `hwaddr` and `type=veth`, as a live
|
|
12
|
+
* guest's own config shows; tags come back sorted and lower-cased (`get_unique_tags`).
|
|
13
|
+
* ⚠️ EVERY MAC HERE IS FROM THE RFC 7042 DOCUMENTATION BLOCK (`00:00:5E:00:53:xx`), as every
|
|
14
|
+
* address is TEST-NET: PVE's own prefix is `BC:24:11`, but no value in this package may be one a
|
|
15
|
+
* real NIC could carry.
|
|
16
|
+
*/
|
|
17
|
+
import { pairs } from './lxc-wire.ts';
|
|
18
|
+
|
|
19
|
+
export type Seen = {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly form: URLSearchParams;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FakePve = {
|
|
26
|
+
/** Live configs, keyed `node/vmid`. Mutate freely between deploys to simulate hand edits. */
|
|
27
|
+
readonly guests: Map<string, Record<string, unknown>>;
|
|
28
|
+
/** Extra `GET /cluster/resources` rows — a QEMU VM, or a guest on another node. */
|
|
29
|
+
readonly others: { vmid: number | string; node: string; type: string }[];
|
|
30
|
+
/** Forced answers for `GET …/config`, keyed `node/vmid`: an HTTP status and a JSON body. */
|
|
31
|
+
readonly configErrors: Map<string, { status: number; body: unknown }>;
|
|
32
|
+
/**
|
|
33
|
+
* Guests that read as ABSENT for the next N cluster listings (their config GET 500s meanwhile),
|
|
34
|
+
* keyed `node/vmid` — a guest that appears between a plan and its deploy.
|
|
35
|
+
*/
|
|
36
|
+
readonly vanish: Map<string, number>;
|
|
37
|
+
/** Config keys a `PUT …/config` accepts with 200 and then does not store. */
|
|
38
|
+
readonly ignore: Set<string>;
|
|
39
|
+
/** How every task ends, and whether a task-starting call answers with no UPID at all. */
|
|
40
|
+
readonly task: { exit: string; noUpid: boolean };
|
|
41
|
+
readonly seen: Seen[];
|
|
42
|
+
readonly fetch: typeof fetch;
|
|
43
|
+
readonly stop: () => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The member hostname a test target names, and the OpenBao address the mint is pointed at. */
|
|
47
|
+
export const FAKE_MEMBER = 'pve-a.test';
|
|
48
|
+
export const FAKE_BAO = 'http://bao.test:8200';
|
|
49
|
+
|
|
50
|
+
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
|
51
|
+
|
|
52
|
+
let digestCounter = 0;
|
|
53
|
+
const nextDigest = () => {
|
|
54
|
+
digestCounter += 1;
|
|
55
|
+
return `digest-${String(digestCounter)}`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** What PVE's create does to the declared form: allocate volumes, fill NIC defaults, sort tags. */
|
|
59
|
+
const created = (vmid: string, form: URLSearchParams): Record<string, unknown> => {
|
|
60
|
+
const config: Record<string, unknown> = { arch: 'amd64', ostype: 'debian', unprivileged: 1 };
|
|
61
|
+
let disk = 0;
|
|
62
|
+
for (const [key, value] of form) {
|
|
63
|
+
if (['ostemplate', 'start', 'vmid'].includes(key)) continue;
|
|
64
|
+
if (key === 'rootfs' || /^mp\d+$/.test(key)) {
|
|
65
|
+
const [first, ...rest] = value.split(',');
|
|
66
|
+
const fresh = /^([^:]+):(\d+)$/.exec(first ?? '');
|
|
67
|
+
config[key] =
|
|
68
|
+
fresh === null
|
|
69
|
+
? value
|
|
70
|
+
: [
|
|
71
|
+
`${fresh[1] ?? ''}:subvol-${vmid}-disk-${String(disk++)}`,
|
|
72
|
+
...rest,
|
|
73
|
+
`size=${fresh[2] ?? '0'}G`,
|
|
74
|
+
].join(',');
|
|
75
|
+
} else if (/^net\d+$/.test(key)) {
|
|
76
|
+
const map = new Map(pairs(value));
|
|
77
|
+
if (!map.has('hwaddr'))
|
|
78
|
+
map.set('hwaddr', `00:00:5E:00:53:${vmid.slice(-2).padStart(2, '0')}`);
|
|
79
|
+
if (!map.has('type')) map.set('type', 'veth');
|
|
80
|
+
config[key] = [...map].map(([k, v]) => `${k}=${v}`).join(',');
|
|
81
|
+
} else if (key === 'tags') {
|
|
82
|
+
config[key] = [...new Set(value.toLowerCase().split(/[;,\s]+/))].sort().join(';');
|
|
83
|
+
} else config[key] = /^\d+$/.test(value) ? Number(value) : value;
|
|
84
|
+
}
|
|
85
|
+
return config;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const fakePve = (): FakePve => {
|
|
89
|
+
const guests = new Map<string, Record<string, unknown>>();
|
|
90
|
+
const others: { vmid: number | string; node: string; type: string }[] = [];
|
|
91
|
+
const configErrors = new Map<string, { status: number; body: unknown }>();
|
|
92
|
+
const vanish = new Map<string, number>();
|
|
93
|
+
const ignore = new Set<string>();
|
|
94
|
+
const task = { exit: 'OK', noUpid: false };
|
|
95
|
+
const seen: Seen[] = [];
|
|
96
|
+
let upids = 0;
|
|
97
|
+
const upid = (node: string, kind: string, vmid: string) => {
|
|
98
|
+
upids += 1;
|
|
99
|
+
if (task.noUpid) return null;
|
|
100
|
+
return `UPID:${node}:${String(upids).padStart(8, '0')}:0:0:${kind}:${vmid}:hf-test@pve!fake:`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handle = async (request: Request): Promise<Response> => {
|
|
104
|
+
const url = new URL(request.url);
|
|
105
|
+
// ⚠️ QUERY AND BODY BOTH: a DELETE carries its parameters in the query, and a `force=1` there
|
|
106
|
+
// must be visible to the test that says there is none.
|
|
107
|
+
const form = new URLSearchParams(url.search);
|
|
108
|
+
if (request.method !== 'GET')
|
|
109
|
+
for (const pair of new URLSearchParams(await request.text())) form.append(...pair);
|
|
110
|
+
seen.push({ form, method: request.method, path: url.pathname });
|
|
111
|
+
if (url.pathname.startsWith('/v1/') && url.pathname.includes('/creds/')) {
|
|
112
|
+
return json({
|
|
113
|
+
data: { secret: 'not-a-secret', token_id: 'hf-test@pve!fake' },
|
|
114
|
+
lease_duration: 300,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const path = url.pathname.replace(/^\/api2\/json\//, '');
|
|
118
|
+
if (path === 'cluster/resources') {
|
|
119
|
+
const rows = [...guests.keys()]
|
|
120
|
+
.filter((key) => (vanish.get(key) ?? 0) <= 0)
|
|
121
|
+
.map((key) => {
|
|
122
|
+
const [node, vmid] = key.split('/');
|
|
123
|
+
return { node, type: 'lxc', vmid: Number(vmid) };
|
|
124
|
+
});
|
|
125
|
+
for (const [key, left] of vanish) vanish.set(key, left - 1);
|
|
126
|
+
return json({ data: [...rows, ...others] });
|
|
127
|
+
}
|
|
128
|
+
if (/^nodes\/[^/]+\/tasks\/.+\/status$/.test(path)) {
|
|
129
|
+
return json({ data: { exitstatus: task.exit, status: 'stopped' } });
|
|
130
|
+
}
|
|
131
|
+
const create = /^nodes\/([^/]+)\/lxc$/.exec(path);
|
|
132
|
+
if (create !== null && request.method === 'POST') {
|
|
133
|
+
const node = create[1] ?? '';
|
|
134
|
+
const vmid = form.get('vmid') ?? '';
|
|
135
|
+
guests.set(`${node}/${vmid}`, { ...created(vmid, form), digest: nextDigest() });
|
|
136
|
+
return json({ data: upid(node, 'vzcreate', vmid) });
|
|
137
|
+
}
|
|
138
|
+
const guest = /^nodes\/([^/]+)\/lxc\/(\d+)(\/config|\/resize)?$/.exec(path);
|
|
139
|
+
if (guest === null) return json({ data: null, message: `no such path ${path}\n` }, 501);
|
|
140
|
+
const [, node = '', vmid = '', tail] = guest;
|
|
141
|
+
const forced = configErrors.get(`${node}/${vmid}`);
|
|
142
|
+
if (forced !== undefined && tail === '/config' && request.method === 'GET') {
|
|
143
|
+
return json(forced.body, forced.status);
|
|
144
|
+
}
|
|
145
|
+
const config =
|
|
146
|
+
(vanish.get(`${node}/${vmid}`) ?? 0) > 0 ? undefined : guests.get(`${node}/${vmid}`);
|
|
147
|
+
if (config === undefined) {
|
|
148
|
+
const message = `Configuration file 'nodes/${node}/lxc/${vmid}.conf' does not exist\n`;
|
|
149
|
+
return json({ data: null, message }, 500);
|
|
150
|
+
}
|
|
151
|
+
if (tail === '/config' && request.method === 'GET') return json({ data: config });
|
|
152
|
+
if (tail === '/config' && request.method === 'PUT') {
|
|
153
|
+
const digest = form.get('digest');
|
|
154
|
+
if (digest !== null && digest !== config['digest']) {
|
|
155
|
+
return json({ data: null, message: 'detected modified configuration\n' }, 500);
|
|
156
|
+
}
|
|
157
|
+
for (const [key, value] of form)
|
|
158
|
+
if (key !== 'digest' && key !== 'delete' && !ignore.has(key)) config[key] = value;
|
|
159
|
+
for (const key of (form.get('delete') ?? '').split(',')) if (key !== '') delete config[key];
|
|
160
|
+
config['digest'] = nextDigest();
|
|
161
|
+
return json({ data: null });
|
|
162
|
+
}
|
|
163
|
+
if (tail === '/resize' && request.method === 'PUT') {
|
|
164
|
+
const disk = form.get('disk') ?? '';
|
|
165
|
+
const current = String(config[disk] ?? '');
|
|
166
|
+
config[disk] = current.replace(/size=[^,]+/, `size=${form.get('size') ?? ''}`);
|
|
167
|
+
config['digest'] = nextDigest();
|
|
168
|
+
return json({ data: upid(node, 'resize', vmid) });
|
|
169
|
+
}
|
|
170
|
+
if (tail === undefined && request.method === 'DELETE') {
|
|
171
|
+
guests.delete(`${node}/${vmid}`);
|
|
172
|
+
return json({ data: upid(node, 'vzdestroy', vmid) });
|
|
173
|
+
}
|
|
174
|
+
return json({ data: null, message: 'unhandled\n' }, 501);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const server = Bun.serve({ fetch: handle, port: 0 });
|
|
178
|
+
const port = String(server.port);
|
|
179
|
+
const remapped = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
180
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
181
|
+
if (url.hostname !== FAKE_MEMBER && url.hostname !== new URL(FAKE_BAO).hostname) {
|
|
182
|
+
return Promise.reject(
|
|
183
|
+
Object.assign(new Error(`getaddrinfo ENOTFOUND ${url.hostname}`), {
|
|
184
|
+
code: 'ENOTFOUND',
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
url.protocol = 'http:';
|
|
189
|
+
url.hostname = '127.0.0.1';
|
|
190
|
+
url.port = port;
|
|
191
|
+
return globalThis.fetch(url, init);
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
configErrors,
|
|
195
|
+
fetch: Object.assign(remapped, { preconnect: globalThis.fetch.preconnect }),
|
|
196
|
+
guests,
|
|
197
|
+
ignore,
|
|
198
|
+
others,
|
|
199
|
+
seen,
|
|
200
|
+
stop: () => server.stop(true),
|
|
201
|
+
task,
|
|
202
|
+
vanish,
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Every call that changed something, as `METHOD path`, in order — OpenBao mints excluded. */
|
|
207
|
+
export const writesOf = (fake: FakePve) =>
|
|
208
|
+
fake.seen
|
|
209
|
+
.filter((call) => call.method !== 'GET')
|
|
210
|
+
.map((call) => `${call.method} ${call.path.replace(/^\/api2\/json\//, '')}`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PVE cluster and the OpenBao that mints for it, both answered in-process by one stub `fetch` —
|
|
3
|
+
* so a family's REAL provider can run through Alchemy's engine with every call recorded.
|
|
4
|
+
*
|
|
5
|
+
* ★ A STUB `fetch`, NOT A SERVER. client.test.ts remaps hosts onto Bun.serve to test failover;
|
|
6
|
+
* here the question is only which calls a lifecycle makes, and a stub answers every URL without
|
|
7
|
+
* a socket, so nothing can reach a real host whatever the environment says.
|
|
8
|
+
* ⛔ AND THE ENVIRONMENT IS EMPTIED ANYWAY (`withoutBao`, fake-bao-env.ts), so a token in the
|
|
9
|
+
* shell running the tests is never read.
|
|
10
|
+
* ⚠️ `lease_duration: 0` KEEPS THE LEASE CACHE COLD (lease-cache.ts `timeToLive`), so no fake
|
|
11
|
+
* credential outlives the test that minted it.
|
|
12
|
+
* ⛔ TEST-ONLY. No provider imports this file.
|
|
13
|
+
*/
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
16
|
+
import type { PveTarget } from './credentials.ts';
|
|
17
|
+
import { withFakeBao } from './fake-bao-env.ts';
|
|
18
|
+
import { resetLastGoodForTest } from './members.ts';
|
|
19
|
+
|
|
20
|
+
export const FAKE_TARGET: PveTarget = {
|
|
21
|
+
members: ['pve.test'],
|
|
22
|
+
mount: 'proxmox-test',
|
|
23
|
+
scheme: 'pve',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface PveCall {
|
|
27
|
+
readonly method: string;
|
|
28
|
+
/** Relative to `/api2/json/`, query included — the string a family passes to `pve()`. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly form: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
|
|
34
|
+
export type PveAnswer = (call: PveCall) => unknown;
|
|
35
|
+
|
|
36
|
+
export interface FakePve {
|
|
37
|
+
readonly calls: PveCall[];
|
|
38
|
+
/** Every call that is not a GET, as `METHOD path`. */
|
|
39
|
+
readonly writes: () => string[];
|
|
40
|
+
/** Provide over a provider layer: `Provider().pipe(Layer.provideMerge(fake.layer))`. */
|
|
41
|
+
readonly layer: ReturnType<typeof transport>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Effect's own fetch client over a stub — client.test.ts wires failover the same way. */
|
|
45
|
+
const transport = (stub: typeof fetch) =>
|
|
46
|
+
FetchHttpClient.layer.pipe(Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, stub)));
|
|
47
|
+
|
|
48
|
+
export const fakePve = (answer: PveAnswer): FakePve => {
|
|
49
|
+
const calls: PveCall[] = [];
|
|
50
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
51
|
+
const request =
|
|
52
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
if (url.pathname.includes('/creds/')) {
|
|
55
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
56
|
+
return Response.json({ data, lease_duration: 0 });
|
|
57
|
+
}
|
|
58
|
+
const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
|
|
59
|
+
const form = Object.fromEntries(new URLSearchParams(await request.text()));
|
|
60
|
+
const call = { form, method: request.method, path };
|
|
61
|
+
calls.push(call);
|
|
62
|
+
return Response.json({ data: answer(call) ?? null });
|
|
63
|
+
};
|
|
64
|
+
// ⚠️ `typeof fetch` carries `preconnect` on bun's lib — client.test.ts has the note.
|
|
65
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
66
|
+
return {
|
|
67
|
+
calls,
|
|
68
|
+
layer: transport(fetchStub as typeof fetch),
|
|
69
|
+
writes: () => calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Run `body` against the stub's OpenBao (fake-bao-env.ts), then forget the member failover state. */
|
|
74
|
+
export const withoutBao = <A>(body: () => Promise<A>): Promise<A> =>
|
|
75
|
+
withFakeBao('http://bao.invalid', body).finally(resetLastGoodForTest);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public barrel: control-plane Resource constructors
|
|
2
|
+
* Public barrel: control-plane Resource constructors and `ProxmoxLxc`, not QEMU or NIC apply.
|
|
3
3
|
*
|
|
4
|
-
* ⛔
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* ⛔ Vm stays Provider-only. NodeNetwork/NetworkApply stay off the barrel — they stage
|
|
5
|
+
* interfaces.new. ★ Lxc is a Resource since 2026-09-21: adopt-first, refuse-not-replace,
|
|
6
|
+
* retain on destroy (lxc.ts).
|
|
7
7
|
*/
|
|
8
8
|
import { expect, test } from 'bun:test';
|
|
9
9
|
import { PbsDatastore, ProxmoxStorage } from './index.ts';
|
|
@@ -20,6 +20,7 @@ const resourceExports = [
|
|
|
20
20
|
'ProxmoxGroup',
|
|
21
21
|
'ProxmoxHaResource',
|
|
22
22
|
'ProxmoxHaRule',
|
|
23
|
+
'ProxmoxLxc',
|
|
23
24
|
'ProxmoxMetricServer',
|
|
24
25
|
'ProxmoxNotificationTarget',
|
|
25
26
|
'ProxmoxRole',
|
|
@@ -39,8 +40,7 @@ test('control-plane Resource constructors are on the public barrel', () => {
|
|
|
39
40
|
}
|
|
40
41
|
});
|
|
41
42
|
|
|
42
|
-
test('
|
|
43
|
-
expect(src).not.toMatch(/export \{ ProxmoxLxc[, }]/);
|
|
43
|
+
test('QEMU and NIC apply stay Provider-only', () => {
|
|
44
44
|
expect(src).not.toMatch(/export \{ ProxmoxVm[, }]/);
|
|
45
45
|
expect(src).not.toMatch(/export \{ ProxmoxNodeNetwork[, }]/);
|
|
46
46
|
expect(src).not.toMatch(/export \{ ProxmoxNetworkApply[, }]/);
|
package/src/proxmox/index.ts
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* provider needs but a consumer should not depend on. An `export *` here would publish
|
|
7
7
|
* every helper as API and make the next refactor a breaking change.
|
|
8
8
|
* ★ Control-plane Resource constructors (Storage, SDN, access, HA, backup,
|
|
9
|
-
* metrics, PBS) sit next to their Providers.
|
|
9
|
+
* metrics, PBS) sit next to their Providers. NIC apply and QEMU stay
|
|
10
10
|
* Provider-only so a stack cannot declare them by accident.
|
|
11
|
+
* ★ `ProxmoxLxc` JOINED THEM 2026-09-21, AND IT WAS KEPT OFF UNTIL IT COULD NOT HURT A GUEST.
|
|
12
|
+
* The provider-only version diffed four fields and would have planned a create over any read
|
|
13
|
+
* failure. The Resource now adopts from the live config, refuses every change PVE cannot make
|
|
14
|
+
* in place, and retains on destroy (lxc.ts).
|
|
11
15
|
* ★ Anything else unlisted is still reachable by path if you genuinely need
|
|
12
16
|
* it — that is a deliberate, visible act rather than an accident of barrelling.
|
|
13
17
|
*/
|
|
@@ -23,7 +27,7 @@ export { ProxmoxFirewallAliasProvider } from './firewall-alias.ts';
|
|
|
23
27
|
export { ProxmoxGroup, ProxmoxGroupProvider } from './group.ts';
|
|
24
28
|
export { ProxmoxHaResource, ProxmoxHaResourceProvider } from './ha-resource.ts';
|
|
25
29
|
export { ProxmoxHaRule, ProxmoxHaRuleProvider } from './ha-rule.ts';
|
|
26
|
-
export { ProxmoxLxcProvider } from './lxc.ts';
|
|
30
|
+
export { type LxcAttributes, type LxcProps, ProxmoxLxc, ProxmoxLxcProvider } from './lxc.ts';
|
|
27
31
|
export { ProxmoxMetricServer, ProxmoxMetricServerProvider } from './metric-server.ts';
|
|
28
32
|
export { ProxmoxNetworkApplyProvider } from './network-apply.ts';
|
|
29
33
|
export { ProxmoxNodeNetworkProvider } from './node-network.ts';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adoption gate, through Alchemy's real plan and apply over a fake PVE holding a
|
|
3
|
+
* production-shaped guest (placeholders only).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHAT THESE PIN: a declaration pasted from the live config plans `adopted` with no warning,
|
|
6
|
+
* its deploy writes NOTHING, and the next plan is `noop`; the create spellings (`storage:size`,
|
|
7
|
+
* a NIC without its MAC) adopt as cleanly; drift is named at plan and written as one PUT that
|
|
8
|
+
* keeps the live MAC and carries the digest; and every change PVE cannot make in place fails the
|
|
9
|
+
* plan with nothing written. Deleting both empty-change guards (reconcile's in lxc.ts and
|
|
10
|
+
* `updateGuest`'s in lxc-lifecycle.ts), or the hwaddr carry in lxc-wire.ts, fails a test here —
|
|
11
|
+
* each MEASURED by making that edit and running this file, 2026-09-21.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
15
|
+
import { type FakePve, fakePve, writesOf } from './fake-pve-lxc.ts';
|
|
16
|
+
import { LIVE, TARGET, lxcEngine, seed } from './lxc-harness.ts';
|
|
17
|
+
import { ProxmoxLxc } from './lxc.ts';
|
|
18
|
+
import type { LxcProps } from './lxc-props.ts';
|
|
19
|
+
|
|
20
|
+
const NODE = 'pve1';
|
|
21
|
+
const VMID = 100;
|
|
22
|
+
|
|
23
|
+
/** The live config as a declaration: every key, minus what pvesh adds that is not a prop. */
|
|
24
|
+
const pasted = (): LxcProps => {
|
|
25
|
+
const { lxc: _raw, ...config } = LIVE;
|
|
26
|
+
return { ...(config as Partial<LxcProps>), node: NODE, target: TARGET, vmid: VMID };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ct = (props: LxcProps) => ProxmoxLxc('ct', props).pipe(adopt(true));
|
|
30
|
+
|
|
31
|
+
let fake: FakePve | undefined;
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fake?.stop();
|
|
34
|
+
fake = undefined;
|
|
35
|
+
});
|
|
36
|
+
const live = () => {
|
|
37
|
+
fake = fakePve();
|
|
38
|
+
seed(fake, NODE, VMID);
|
|
39
|
+
return fake;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('adopting a live container', () => {
|
|
43
|
+
test('the pasted config plans adopted, deploys with no write, and then plans noop', async () => {
|
|
44
|
+
const pve = live();
|
|
45
|
+
const stack = lxcEngine(pve);
|
|
46
|
+
const first = await stack.plan(ct(pasted()));
|
|
47
|
+
expect(first).toEqual({ actions: { ct: 'adopted' }, failure: '', warnings: [] });
|
|
48
|
+
const deployed = await stack.deploy(ct(pasted()));
|
|
49
|
+
expect(deployed.failure).toBe('');
|
|
50
|
+
expect(writesOf(pve)).toEqual([]);
|
|
51
|
+
expect(stack.status('ct')).toBe('updated');
|
|
52
|
+
expect(await stack.plan(ct(pasted()))).toEqual({
|
|
53
|
+
actions: { ct: 'noop' },
|
|
54
|
+
failure: '',
|
|
55
|
+
warnings: [],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('create spellings and PVE defaults compare equal to the live config', async () => {
|
|
60
|
+
const stack = lxcEngine(live());
|
|
61
|
+
const props: LxcProps = {
|
|
62
|
+
...pasted(),
|
|
63
|
+
mp0: 'tank:200,mp=/data',
|
|
64
|
+
net0: 'bridge=vmbr0,name=eth0,gw=192.0.2.1,ip=192.0.2.10/24,mtu=1500,tag=42',
|
|
65
|
+
net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=1500,firewall=0',
|
|
66
|
+
onboot: true,
|
|
67
|
+
rootfs: 'local-zfs:40',
|
|
68
|
+
tags: 'WEB',
|
|
69
|
+
unprivileged: true,
|
|
70
|
+
};
|
|
71
|
+
expect(await stack.plan(ct(props))).toEqual({
|
|
72
|
+
actions: { ct: 'adopted' },
|
|
73
|
+
failure: '',
|
|
74
|
+
warnings: [],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a declaration naming only node and vmid adopts without touching anything', async () => {
|
|
79
|
+
const pve = live();
|
|
80
|
+
const stack = lxcEngine(pve);
|
|
81
|
+
const deployed = await stack.deploy(ct({ node: NODE, target: TARGET, vmid: VMID }));
|
|
82
|
+
expect(deployed.failure).toBe('');
|
|
83
|
+
expect(writesOf(pve)).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('drift at adoption is named at plan and written once', () => {
|
|
88
|
+
test('memory and an MTU change: one PUT, the live MAC kept, the digest carried', async () => {
|
|
89
|
+
const pve = live();
|
|
90
|
+
const stack = lxcEngine(pve);
|
|
91
|
+
const props: LxcProps = {
|
|
92
|
+
...pasted(),
|
|
93
|
+
memory: 4096,
|
|
94
|
+
net1: 'name=eth1,bridge=vmbr1,ip=198.51.100.4/24,mtu=9000',
|
|
95
|
+
};
|
|
96
|
+
const planned = await stack.plan(ct(props));
|
|
97
|
+
expect(planned.actions).toEqual({ ct: 'adopted' });
|
|
98
|
+
expect(planned.warnings.join('\n')).toContain('differs from the declaration in memory, net1');
|
|
99
|
+
expect((await stack.deploy(ct(props))).failure).toBe('');
|
|
100
|
+
expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/config`]);
|
|
101
|
+
const put = pve.seen.find((call) => call.method === 'PUT')?.form;
|
|
102
|
+
expect(put?.get('memory')).toBe('4096');
|
|
103
|
+
expect(put?.get('net1')).toContain('hwaddr=00:00:5E:00:53:02');
|
|
104
|
+
expect(put?.get('digest')).toBe('digest-live');
|
|
105
|
+
expect((await stack.plan(ct(props))).actions).toEqual({ ct: 'noop' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a larger rootfs is a resize after the config, never a new volume', async () => {
|
|
109
|
+
const pve = live();
|
|
110
|
+
const stack = lxcEngine(pve);
|
|
111
|
+
const props: LxcProps = { ...pasted(), rootfs: 'local-zfs:subvol-100-disk-0,size=64G' };
|
|
112
|
+
expect((await stack.deploy(ct(props))).failure).toBe('');
|
|
113
|
+
expect(writesOf(pve)).toEqual([`PUT nodes/${NODE}/lxc/${String(VMID)}/resize`]);
|
|
114
|
+
expect(pve.guests.get(`${NODE}/${String(VMID)}`)?.['rootfs']).toContain('size=64G');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('changes PVE cannot make in place fail the plan and write nothing', () => {
|
|
119
|
+
test.each([
|
|
120
|
+
['a smaller mount point', { mp0: 'tank:subvol-100-disk-0,mp=/data,size=100G' }, /shrink/],
|
|
121
|
+
['a storage move', { rootfs: 'other:40' }, /pct move-volume/],
|
|
122
|
+
['an unprivileged flip', { unprivileged: 0 as const }, /read-only option/],
|
|
123
|
+
['a device passthrough change', { dev1: '/dev/fuse' }, /pct set 100 --dev1/],
|
|
124
|
+
['a feature beyond nesting', { features: 'nesting=1,keyctl=1' }, /root@pam/],
|
|
125
|
+
['detaching a mount point', { mp0: '' }, /unusedN/],
|
|
126
|
+
])('%s', async (_, change, reason) => {
|
|
127
|
+
const pve = live();
|
|
128
|
+
const run = await lxcEngine(pve).deploy(ct({ ...pasted(), ...change } as LxcProps));
|
|
129
|
+
expect(run.failure).toMatch(reason);
|
|
130
|
+
expect(writesOf(pve)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a vmid held elsewhere in the cluster fails the plan instead of planning a create', async () => {
|
|
134
|
+
const pve = live();
|
|
135
|
+
pve.others.push({ node: 'pve2', type: 'qemu', vmid: 101 });
|
|
136
|
+
const run = await lxcEngine(pve).plan(ct({ ...pasted(), vmid: 101 }));
|
|
137
|
+
expect(run.failure).toContain('vmid 101 is a qemu on pve2');
|
|
138
|
+
expect(writesOf(pve)).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('a guest another tool moved', () => {
|
|
143
|
+
const migrate = (pve: FakePve, to: string) => {
|
|
144
|
+
const config = pve.guests.get(`${NODE}/${String(VMID)}`) ?? {};
|
|
145
|
+
pve.guests.delete(`${NODE}/${String(VMID)}`);
|
|
146
|
+
pve.guests.set(`${to}/${String(VMID)}`, config);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
test('after HA moves it, the old node fails the plan; the new node is a write-free update', async () => {
|
|
150
|
+
const pve = live();
|
|
151
|
+
const stack = lxcEngine(pve);
|
|
152
|
+
await stack.deploy(ct(pasted()));
|
|
153
|
+
migrate(pve, 'pve2');
|
|
154
|
+
expect((await stack.plan(ct(pasted()))).failure).toContain('is a lxc on pve2');
|
|
155
|
+
const follow = await stack.deploy(ct({ ...pasted(), node: 'pve2' }));
|
|
156
|
+
expect(follow.actions).toEqual({ ct: 'update' });
|
|
157
|
+
expect(follow.warnings.join('\n')).toContain('on pve2 now, not pve1');
|
|
158
|
+
expect(writesOf(pve)).toEqual([]);
|
|
159
|
+
expect((await stack.plan(ct({ ...pasted(), node: 'pve2' }))).actions).toEqual({ ct: 'noop' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('naming another node the guest is not on is refused, never a second guest', async () => {
|
|
163
|
+
const pve = live();
|
|
164
|
+
const stack = lxcEngine(pve);
|
|
165
|
+
await stack.deploy(ct(pasted()));
|
|
166
|
+
const run = await stack.deploy(ct({ ...pasted(), node: 'pve2' }));
|
|
167
|
+
expect(run.failure).toContain('is a lxc on pve1, not a container on pve2');
|
|
168
|
+
expect(writesOf(pve)).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating a container: what an API token may create, and the body it POSTs.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT FROM lxc-judge.ts AT A REAL SEAM: the judge compares a declaration with a guest that
|
|
5
|
+
* exists; this file answers for a guest that does not, where there is nothing to compare with
|
|
6
|
+
* and the only safe answer to a doubtful key is to refuse it before the POST.
|
|
7
|
+
* ⛔ THE ROOT@PAM RULES ARE THE JUDGE'S OWN (`rootOnly`, `featuresNeedRoot`), imported rather than
|
|
8
|
+
* restated, so the create and the update cannot disagree about what a token may write.
|
|
9
|
+
*/
|
|
10
|
+
import { featureWhy, featuresNeedRoot, rootOnly } from './lxc-judge.ts';
|
|
11
|
+
import {
|
|
12
|
+
type LxcProps,
|
|
13
|
+
declaredKeys,
|
|
14
|
+
declaredValue,
|
|
15
|
+
indexedKey,
|
|
16
|
+
isManagedKey,
|
|
17
|
+
wireValue,
|
|
18
|
+
} from './lxc-props.ts';
|
|
19
|
+
import { isHostPath, parseVolume } from './lxc-volume.ts';
|
|
20
|
+
import { scalarForm } from './lxc-wire.ts';
|
|
21
|
+
import { bool } from './values.ts';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ⛔ A CREATE ALLOCATES NEW VOLUMES ONLY. pve-container's `create_disks` allocates `storage:GiB`
|
|
25
|
+
* and takes anything else as "use specified/existing volid", then `restore_archive` unpacks the
|
|
26
|
+
* template into the mounted rootfs. So a create naming `local-zfs:subvol-100-disk-0` — a pasted
|
|
27
|
+
* adoption, re-created after its config vanished while the volume survived (a `pct destroy`
|
|
28
|
+
* only WARNS when a volume will not delete) — unpacks a template over the data on it.
|
|
29
|
+
*/
|
|
30
|
+
const existingVolume = (key: string, value: string) =>
|
|
31
|
+
`${key}: ${value} names an existing volume, and PVE would unpack the template onto it. A ` +
|
|
32
|
+
'create allocates new volumes only: declare `storage:GiB`, and attach an existing volume by ' +
|
|
33
|
+
'hand after the create.';
|
|
34
|
+
|
|
35
|
+
/** Why this declaration cannot be CREATED by an API token, if it cannot. */
|
|
36
|
+
export const createRefusals = (props: LxcProps): string[] => {
|
|
37
|
+
const refuse: string[] = [];
|
|
38
|
+
if (props.ostemplate === undefined || props.ostemplate === '') {
|
|
39
|
+
refuse.push(
|
|
40
|
+
`CT ${String(props.vmid)} does not exist on ${props.node}, and without \`ostemplate\` ` +
|
|
41
|
+
'there is nothing to create it from. To adopt a guest, declare the node it is on.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const unprivileged = props.unprivileged === undefined ? true : bool(props.unprivileged);
|
|
45
|
+
for (const key of declaredKeys(props)) {
|
|
46
|
+
const value = wireValue(declaredValue(props, key));
|
|
47
|
+
const family = indexedKey(key)?.[0];
|
|
48
|
+
// ⛔ THE SAME RULE AS `judge`: createForm sends every declared key, so an unmanaged one (a
|
|
49
|
+
// cast-in `password`, `restore`/`force`, `hookscript`) is refused here or it is POSTed.
|
|
50
|
+
if (!isManagedKey(key)) refuse.push(`${key}: not a config key this resource manages.`);
|
|
51
|
+
if (value === '') continue;
|
|
52
|
+
if (family === 'dev') refuse.push(rootOnly(props.vmid, key, value, 'Device passthrough.'));
|
|
53
|
+
const volume = family === 'mp' || key === 'rootfs' ? parseVolume(value) : undefined;
|
|
54
|
+
if (volume !== undefined && isHostPath(volume)) {
|
|
55
|
+
refuse.push(rootOnly(props.vmid, key, value, 'A bind or device mount point.'));
|
|
56
|
+
}
|
|
57
|
+
if (volume?.kind === 'volume') refuse.push(existingVolume(key, value));
|
|
58
|
+
if (key === 'features' && featuresNeedRoot('', value, unprivileged)) {
|
|
59
|
+
refuse.push(rootOnly(props.vmid, key, value, featureWhy(unprivileged)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return refuse;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The create body: every declared key with a value, plus the create-time instructions. */
|
|
66
|
+
export const createForm = (props: LxcProps): Record<string, string> => {
|
|
67
|
+
const form: Record<string, string> = { vmid: String(props.vmid) };
|
|
68
|
+
if (props.ostemplate !== undefined) form['ostemplate'] = props.ostemplate;
|
|
69
|
+
if (props.start !== undefined) form['start'] = wireValue(props.start);
|
|
70
|
+
for (const key of declaredKeys(props)) {
|
|
71
|
+
const value = wireValue(declaredValue(props, key));
|
|
72
|
+
if (value === '') continue;
|
|
73
|
+
form[key] = key === 'features' ? scalarForm(key, value) : value;
|
|
74
|
+
}
|
|
75
|
+
return form;
|
|
76
|
+
};
|