@homeflare/alchemy 0.38.0 → 0.39.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 -0
- package/dist/forgejo/index.js +4 -2
- package/dist/forgejo/index.js.map +3 -3
- package/dist/forgejo/team-member.d.ts +25 -0
- package/dist/forgejo/team-member.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +14 -3
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +565 -133
- package/dist/proxmox/index.js.map +33 -23
- package/dist/proxmox/qemu-form.d.ts +27 -18
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts +30 -0
- package/dist/proxmox/qemu-judge.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-net.d.ts +9 -0
- package/dist/proxmox/qemu-net.d.ts.map +1 -0
- package/dist/proxmox/qemu-props.d.ts +112 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -0
- package/dist/proxmox/qemu-volume.d.ts +17 -0
- package/dist/proxmox/qemu-volume.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +14 -27
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/storage-download-errors.d.ts +5 -0
- package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
- package/dist/proxmox/storage-download-identity.d.ts +27 -0
- package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
- package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/storage-download-props.d.ts +36 -0
- package/dist/proxmox/storage-download-props.d.ts.map +1 -0
- package/dist/proxmox/storage-download-task.d.ts +17 -0
- package/dist/proxmox/storage-download-task.d.ts.map +1 -0
- package/dist/proxmox/storage-download.d.ts +44 -0
- package/dist/proxmox/storage-download.d.ts.map +1 -0
- package/dist/telemetry/fake-collector.d.ts +36 -0
- package/dist/telemetry/fake-collector.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +223 -0
- package/dist/telemetry/index.js.map +12 -0
- package/dist/telemetry/layer.d.ts +86 -0
- package/dist/telemetry/layer.d.ts.map +1 -0
- package/dist/telemetry/redact-serialization.d.ts +21 -0
- package/dist/telemetry/redact-serialization.d.ts.map +1 -0
- package/dist/telemetry/redact.d.ts +46 -0
- package/dist/telemetry/redact.d.ts.map +1 -0
- package/dist/unifi/drift.d.ts +27 -0
- package/dist/unifi/drift.d.ts.map +1 -0
- package/dist/unifi/fake-unifi.d.ts +5 -1
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-zone-form.d.ts +6 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +1 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +4 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +138 -58
- package/dist/unifi/index.js.map +10 -8
- package/dist/unifi/network-drift.d.ts +26 -0
- package/dist/unifi/network-drift.d.ts.map +1 -0
- package/dist/unifi/network-form.d.ts +40 -7
- package/dist/unifi/network-form.d.ts.map +1 -1
- package/dist/unifi/network.d.ts +1 -0
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/paginate.d.ts +36 -0
- package/dist/unifi/paginate.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +36 -1
- package/dist/unifi/resource.d.ts.map +1 -1
- package/docs/telemetry-spike.md +94 -0
- package/docs/telemetry.md +109 -0
- package/docs/unifi.md +56 -0
- package/package.json +6 -2
- package/src/forgejo/team-member.test.ts +14 -0
- package/src/forgejo/team-member.ts +28 -1
- package/src/proxmox/constraints-required.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +3 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.test.ts +11 -6
- package/src/proxmox/index.ts +19 -3
- package/src/proxmox/qemu-form.ts +49 -13
- package/src/proxmox/qemu-identity.test.ts +30 -0
- package/src/proxmox/qemu-judge.test.ts +65 -0
- package/src/proxmox/qemu-judge.ts +84 -0
- package/src/proxmox/qemu-lifecycle.test.ts +78 -19
- package/src/proxmox/qemu-lifecycle.ts +131 -32
- package/src/proxmox/qemu-net.test.ts +48 -0
- package/src/proxmox/qemu-net.ts +78 -0
- package/src/proxmox/qemu-ownership.test.ts +44 -0
- package/src/proxmox/qemu-props.test.ts +87 -0
- package/src/proxmox/qemu-props.ts +157 -0
- package/src/proxmox/qemu-volume.test.ts +80 -0
- package/src/proxmox/qemu-volume.ts +121 -0
- package/src/proxmox/qemu.ts +15 -29
- package/src/proxmox/storage-download-errors.ts +7 -0
- package/src/proxmox/storage-download-identity.test.ts +54 -0
- package/src/proxmox/storage-download-identity.ts +50 -0
- package/src/proxmox/storage-download-lifecycle.ts +226 -0
- package/src/proxmox/storage-download-ownership.test.ts +50 -0
- package/src/proxmox/storage-download-props.ts +38 -0
- package/src/proxmox/storage-download-task.ts +72 -0
- package/src/proxmox/storage-download.test.ts +216 -0
- package/src/proxmox/storage-download.ts +58 -0
- package/src/telemetry/fake-collector.ts +59 -0
- package/src/telemetry/http-client-sealing.test.ts +82 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/layer.test.ts +204 -0
- package/src/telemetry/layer.ts +152 -0
- package/src/telemetry/redact-serialization.ts +178 -0
- package/src/telemetry/redact.test.ts +124 -0
- package/src/telemetry/redact.ts +147 -0
- package/src/unifi/drift.test.ts +78 -0
- package/src/unifi/drift.ts +56 -0
- package/src/unifi/errors-and-secrets.test.ts +127 -0
- package/src/unifi/fake-unifi.ts +5 -1
- package/src/unifi/firewall-zone-form.ts +31 -2
- package/src/unifi/firewall-zone.test.ts +21 -1
- package/src/unifi/firewall-zone.ts +1 -1
- package/src/unifi/get-only-guard.test.ts +165 -0
- package/src/unifi/index.ts +15 -1
- package/src/unifi/network-drift.test.ts +207 -0
- package/src/unifi/network-drift.ts +80 -0
- package/src/unifi/network-form.ts +11 -39
- package/src/unifi/network.test.ts +7 -82
- package/src/unifi/network.ts +6 -6
- package/src/unifi/paginate.test.ts +228 -0
- package/src/unifi/paginate.ts +129 -0
- package/src/unifi/resource.ts +73 -3
- package/src/unifi/write-op-reference.test.ts +236 -0
|
@@ -3,50 +3,56 @@
|
|
|
3
3
|
* ⛔ GET and PUT use `/nodes/{node}/qemu/{vmid}/config`. DELETE uses `/nodes/{node}/qemu/{vmid}`.
|
|
4
4
|
* qemu-server Qemu.pm names that second route `destroy_vm`. Sending DELETE to the config
|
|
5
5
|
* route does not destroy the guest.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ WRITES GO THROUGH `qemu-judge.ts`, NOT A WHOLE-FORM PUT (2026-09-26, red-team C1/C2/I1 on
|
|
8
|
+
* PR 297). `judge` decides per key, on the live volume id and live MAC (qemu-volume.ts,
|
|
9
|
+
* qemu-net.ts); only drifted keys are sent. `ownedRead`/`refuseTakeover` (C2) refuse to adopt or
|
|
10
|
+
* write a live VM this stack holds no state for, unless `--adopt` / `adopt(true)` says so —
|
|
11
|
+
* ownership.md's rule, ported here the way openbao/policy.ts already does it. `identityRefusal`
|
|
12
|
+
* (I1) refuses a changed vmid rather than building a second VM.
|
|
6
13
|
*/
|
|
7
14
|
import { isResolved } from 'alchemy/Diff';
|
|
8
15
|
import type { Input } from 'alchemy/Input';
|
|
9
16
|
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
10
17
|
import * as Effect from 'effect/Effect';
|
|
18
|
+
import { refuseTakeover } from '../ownership/adopt.ts';
|
|
19
|
+
import { ownedRead } from '../ownership/probe.ts';
|
|
11
20
|
import { runPve } from './distilled-pve.ts';
|
|
12
|
-
import { createForm,
|
|
21
|
+
import { createForm, formRefusals, updateForm } from './qemu-form.ts';
|
|
13
22
|
import { QemuRefusedError } from './qemu-errors.ts';
|
|
23
|
+
import { identityRefusal, judge } from './qemu-judge.ts';
|
|
24
|
+
import {
|
|
25
|
+
type VmAttributes,
|
|
26
|
+
type VmProps,
|
|
27
|
+
declaredKeys,
|
|
28
|
+
declaredValue,
|
|
29
|
+
isManagedKey,
|
|
30
|
+
storedConfig,
|
|
31
|
+
wireValue,
|
|
32
|
+
} from './qemu-props.ts';
|
|
14
33
|
import { readVm } from './qemu-read.ts';
|
|
15
34
|
import { qemuTask } from './qemu-task.ts';
|
|
16
|
-
import type { VmAttributes, VmProps } from './qemu.ts';
|
|
17
35
|
import type { PveSpec } from './resource-spec.ts';
|
|
18
36
|
import { specGuards } from './resource-guard.ts';
|
|
19
|
-
import { formToSend } from './update-guard.ts';
|
|
20
|
-
import { int } from './values.ts';
|
|
21
37
|
|
|
22
38
|
const CREATE_POLLS = 180;
|
|
23
39
|
const DELETE_POLLS = 60;
|
|
24
40
|
|
|
25
|
-
const attributesOf = (
|
|
26
|
-
|
|
27
|
-
cores?: unknown;
|
|
28
|
-
memory?: unknown;
|
|
29
|
-
name?: unknown;
|
|
30
|
-
onboot?: unknown;
|
|
31
|
-
sockets?: unknown;
|
|
32
|
-
},
|
|
33
|
-
props: VmProps,
|
|
34
|
-
): VmAttributes => ({
|
|
35
|
-
cores: int(live.cores, 1),
|
|
36
|
-
memory: int(live.memory, 512),
|
|
37
|
-
name: typeof live.name === 'string' ? live.name : '',
|
|
41
|
+
const attributesOf = (live: Record<string, unknown>, props: VmProps): VmAttributes => ({
|
|
42
|
+
config: storedConfig(live),
|
|
38
43
|
node: props.node,
|
|
39
|
-
onboot: live.onboot === 1 || live.onboot === true,
|
|
40
|
-
sockets: int(live.sockets, 1),
|
|
41
44
|
vmid: props.vmid,
|
|
42
45
|
});
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* ⚠️ KEPT ONLY TO SATISFY `PveSpec`'S REQUIRED FIELD — `qemu-judge.ts`'s `judge` is what diff and
|
|
49
|
+
* reconcile actually decide by, since a plain string compare is exactly the bug C1 found (it
|
|
50
|
+
* never matches the "new disk" or MAC-less spellings a declaration may use).
|
|
51
|
+
*/
|
|
44
52
|
const matches = (attributes: VmAttributes, props: VmProps) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
attributes.sockets === (props.sockets ?? 1) &&
|
|
49
|
-
attributes.onboot === (props.onboot === true);
|
|
53
|
+
declaredKeys(props)
|
|
54
|
+
.filter(isManagedKey)
|
|
55
|
+
.every((key) => attributes.config[key] === wireValue(declaredValue(props, key)));
|
|
50
56
|
|
|
51
57
|
const spec = {
|
|
52
58
|
attributes: attributesOf,
|
|
@@ -58,14 +64,24 @@ const spec = {
|
|
|
58
64
|
},
|
|
59
65
|
matches,
|
|
60
66
|
path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
|
|
61
|
-
updateForm
|
|
67
|
+
updateForm,
|
|
62
68
|
} satisfies PveSpec<VmProps, VmAttributes>;
|
|
63
69
|
|
|
64
70
|
const { guardCreate, guardUpdate } = specGuards(spec);
|
|
65
71
|
|
|
66
72
|
export const readQemu = (props: VmProps) =>
|
|
67
73
|
readVm(props).pipe(
|
|
68
|
-
Effect.map((live) =>
|
|
74
|
+
Effect.map((live) =>
|
|
75
|
+
live === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: // ⚠️ WIDENED ON PURPOSE. `GetNodeQemuConfigResponse` types only the keys pve-manager's
|
|
78
|
+
// apidoc names as SCALARS; it cannot express `scsi[n]`/`net[n]`/`ipconfig[n]` at all
|
|
79
|
+
// (qemu-props.ts's header), so it carries no index signature. `storedConfig` reads
|
|
80
|
+
// those keys anyway, because the ACTUAL wire response has them (protocol-http.ts's
|
|
81
|
+
// `mapKeys` passes an unrecognised key through undecoded, both directions) — this
|
|
82
|
+
// cast only tells TypeScript what runtime already knows.
|
|
83
|
+
attributesOf(live as unknown as Record<string, unknown>, props),
|
|
84
|
+
),
|
|
69
85
|
);
|
|
70
86
|
|
|
71
87
|
/** No purge, no skiplock, no destroy-unreferenced-disks. A missing config is already gone. */
|
|
@@ -77,28 +93,95 @@ export const deleteQemu = (props: VmProps) =>
|
|
|
77
93
|
DELETE_POLLS,
|
|
78
94
|
).pipe(Effect.catchTag('QemuConfigNotFound', () => Effect.void));
|
|
79
95
|
|
|
96
|
+
/**
|
|
97
|
+
* ★ ONE PLACE, RUN BEFORE EVERY DIFF AND EVERY RECONCILE. A key smuggled past `VmProps`'s types
|
|
98
|
+
* (a cast, a JS caller: `cipassword`, `machine`, `hookscript`) is refused here rather than
|
|
99
|
+
* silently dropped from the form or silently sent — the same rule `lxc-create-form.ts` enforces
|
|
100
|
+
* for `Proxmox.Lxc`, and the check `qemu-props.ts`'s `cipassword?: never`/`machine?: never`
|
|
101
|
+
* cannot make by itself once a caller casts around the type.
|
|
102
|
+
*/
|
|
103
|
+
const refuseUnmanaged = (props: VmProps) => {
|
|
104
|
+
const refused = formRefusals(props);
|
|
105
|
+
return refused.length === 0 ? Effect.void : Effect.fail(new QemuRefusedError(refused.join('\n')));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const refuseChange = (change: { readonly refuse: readonly string[] }) =>
|
|
109
|
+
change.refuse.length === 0
|
|
110
|
+
? Effect.void
|
|
111
|
+
: Effect.fail(new QemuRefusedError(change.refuse.join('\n')));
|
|
112
|
+
|
|
80
113
|
export const qemuHandlers = {
|
|
81
114
|
list: () => Effect.succeed([]),
|
|
82
|
-
|
|
115
|
+
/**
|
|
116
|
+
* ⛔ `Unowned` UNLESS STATE ALREADY VOUCHES FOR IT (C2). With no attributes this is Alchemy's
|
|
117
|
+
* adoption probe or the recovery read for an interrupted create (ownership/probe.ts); `settled`
|
|
118
|
+
* is the same `judge` reconcile uses, so "proven ours" and "reconcile would write nothing" are
|
|
119
|
+
* the same question asked once.
|
|
120
|
+
*/
|
|
121
|
+
read: Effect.fn(function* ({
|
|
122
|
+
fqn,
|
|
123
|
+
instanceId,
|
|
124
|
+
olds,
|
|
125
|
+
output,
|
|
126
|
+
}: {
|
|
127
|
+
fqn: string;
|
|
128
|
+
instanceId: string;
|
|
129
|
+
olds: VmProps;
|
|
130
|
+
output: VmAttributes | undefined;
|
|
131
|
+
}) {
|
|
132
|
+
const found = yield* readQemu(olds);
|
|
133
|
+
const settled = Effect.sync(
|
|
134
|
+
() => found !== undefined && judge(olds, found.config).drift.length === 0,
|
|
135
|
+
);
|
|
136
|
+
return yield* ownedRead({ fqn, instanceId, output }, found, settled);
|
|
137
|
+
}),
|
|
83
138
|
diff: Effect.fn(function* ({
|
|
84
139
|
news,
|
|
140
|
+
olds,
|
|
85
141
|
output,
|
|
86
142
|
}: {
|
|
87
143
|
news: Input<VmProps>;
|
|
144
|
+
olds: VmProps;
|
|
88
145
|
output: VmAttributes | undefined;
|
|
89
146
|
}) {
|
|
90
147
|
if (!isResolved(news)) return undefined;
|
|
148
|
+
const identity = identityRefusal(news.vmid, output?.vmid ?? olds.vmid);
|
|
149
|
+
if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
|
|
150
|
+
yield* refuseUnmanaged(news);
|
|
91
151
|
yield* guardCreate(news, output === undefined);
|
|
92
152
|
yield* guardUpdate(news);
|
|
93
153
|
if (output === undefined) return undefined;
|
|
94
154
|
const live = yield* readQemu(news);
|
|
95
155
|
if (live === undefined) {
|
|
156
|
+
// ⚠️ `update`, not `create`: state exists and the VM does not. reconcile rebuilds it from the
|
|
157
|
+
// declaration -- said here too, so an operator reading the plan sees it before the deploy.
|
|
158
|
+
yield* Effect.logWarning(
|
|
159
|
+
`Proxmox.Vm ${news.node}/${String(news.vmid)}: the cluster lists no VM with this vmid -- ` +
|
|
160
|
+
'a deploy CREATES it again, with new, empty volumes.',
|
|
161
|
+
);
|
|
96
162
|
yield* guardCreate(news, true);
|
|
97
163
|
return { action: 'update' } as const;
|
|
98
164
|
}
|
|
99
|
-
|
|
165
|
+
const change = judge(news, live.config);
|
|
166
|
+
yield* refuseChange(change);
|
|
167
|
+
return { action: change.drift.length === 0 ? 'noop' : 'update' } as const;
|
|
100
168
|
}),
|
|
101
|
-
reconcile: Effect.fn(function* ({
|
|
169
|
+
reconcile: Effect.fn(function* ({
|
|
170
|
+
fqn,
|
|
171
|
+
instanceId,
|
|
172
|
+
news,
|
|
173
|
+
olds,
|
|
174
|
+
output,
|
|
175
|
+
}: {
|
|
176
|
+
fqn: string;
|
|
177
|
+
instanceId: string;
|
|
178
|
+
news: VmProps;
|
|
179
|
+
olds: VmProps | undefined;
|
|
180
|
+
output: VmAttributes | undefined;
|
|
181
|
+
}) {
|
|
182
|
+
const identity = identityRefusal(news.vmid, output?.vmid ?? olds?.vmid);
|
|
183
|
+
if (identity !== undefined) return yield* Effect.fail(new QemuRefusedError(identity));
|
|
184
|
+
yield* refuseUnmanaged(news);
|
|
102
185
|
const live = yield* readQemu(news);
|
|
103
186
|
yield* guardCreate(news, live === undefined);
|
|
104
187
|
yield* guardUpdate(news);
|
|
@@ -110,13 +193,20 @@ export const qemuHandlers = {
|
|
|
110
193
|
CREATE_POLLS,
|
|
111
194
|
);
|
|
112
195
|
} else {
|
|
113
|
-
|
|
114
|
-
|
|
196
|
+
// ⛔ C2: a live VM this stack holds no state for is refused unless adoption is on — dies
|
|
197
|
+
// (never returns) for a fresh create/replace generation; a no-op otherwise (adopt.ts).
|
|
198
|
+
yield* refuseTakeover(
|
|
199
|
+
{ fqn, instanceId, output },
|
|
200
|
+
`Proxmox.Vm VM ${String(news.vmid)} on ${news.node}`,
|
|
201
|
+
);
|
|
202
|
+
const change = judge(news, live.config);
|
|
203
|
+
yield* refuseChange(change);
|
|
204
|
+
if (Object.keys(change.put).length > 0) {
|
|
115
205
|
yield* runPve(
|
|
116
206
|
news.target,
|
|
117
207
|
'provision',
|
|
118
208
|
true,
|
|
119
|
-
nodes.putNodeQemuConfig({ ...
|
|
209
|
+
nodes.putNodeQemuConfig({ ...change.put, node: news.node, vmid: String(news.vmid) }),
|
|
120
210
|
);
|
|
121
211
|
}
|
|
122
212
|
}
|
|
@@ -128,6 +218,15 @@ export const qemuHandlers = {
|
|
|
128
218
|
),
|
|
129
219
|
);
|
|
130
220
|
}
|
|
221
|
+
const left = judge(news, after.config);
|
|
222
|
+
if (left.drift.length > 0) {
|
|
223
|
+
return yield* Effect.fail(
|
|
224
|
+
new QemuRefusedError(
|
|
225
|
+
`${spec.path(news)}: after the write, ${left.drift.join(', ')} still differ from the ` +
|
|
226
|
+
'declaration. PVE may have normalised the value -- declare what it stores.',
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
131
230
|
return after;
|
|
132
231
|
}),
|
|
133
232
|
delete: ({ olds }: { olds: VmProps }) => deleteQemu(olds),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for `sameNet`/`netWrite` — the red-team C1 fix on PR 297. No network, no fake PVE.
|
|
3
|
+
* ⛔ WHAT THESE PIN: a MAC-less (or bare-model) declaration matches a live NIC with any MAC, and
|
|
4
|
+
* never generates a new one when written; an explicit MAC is compared and written as declared; a
|
|
5
|
+
* real drift elsewhere on the line (VLAN tag, bridge) still shows up.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { netWrite, sameNet } from './qemu-net.ts';
|
|
9
|
+
|
|
10
|
+
const LIVE = 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=20';
|
|
11
|
+
|
|
12
|
+
describe('a MAC-less declaration', () => {
|
|
13
|
+
test('bare model token matches a live NIC with any MAC: the measured C1 regression', () => {
|
|
14
|
+
expect(sameNet('virtio,bridge=vmbr0,tag=20', LIVE)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('empty-value spelling matches too', () => {
|
|
18
|
+
expect(sameNet('virtio=,bridge=vmbr0,tag=20', LIVE)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('writing it carries the LIVE mac forward, never regenerating one', () => {
|
|
22
|
+
expect(netWrite('virtio,bridge=vmbr0,tag=21', LIVE)).toBe(
|
|
23
|
+
'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,tag=21',
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('an explicit MAC', () => {
|
|
29
|
+
test('a matching MAC is a noop', () => {
|
|
30
|
+
expect(sameNet(LIVE, LIVE)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a different MAC is real drift and is written as declared', () => {
|
|
34
|
+
const declared = 'virtio=11:22:33:44:55:66,bridge=vmbr0,tag=20';
|
|
35
|
+
expect(sameNet(declared, LIVE)).toBe(false);
|
|
36
|
+
expect(netWrite(declared, LIVE)).toBe(declared);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('a real change elsewhere on the line', () => {
|
|
41
|
+
test('a different VLAN tag is drift even with the MAC substituted', () => {
|
|
42
|
+
expect(sameNet('virtio,bridge=vmbr0,tag=42', LIVE)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a different bridge is drift', () => {
|
|
46
|
+
expect(sameNet('virtio,bridge=vmbr1,tag=20', LIVE)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `netN`: comparing a declared NIC against a live one without regenerating its MAC every deploy.
|
|
3
|
+
*
|
|
4
|
+
* ★ PORTED FROM lxc-wire.ts's `netMaps`/`netWrite` (2026-09-26, red-team C1 on PR 297), adapted for
|
|
5
|
+
* QEMU's spelling. A container's `net0` carries a `hwaddr=` sub-key; a VM's carries the MAC as the
|
|
6
|
+
* VALUE of whichever NIC-model key is present — `virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0` — with the
|
|
7
|
+
* model itself (`e1000`, `virtio`, …) chosen by pve-qemu-server's own model list, not a fixed key
|
|
8
|
+
* name. A declaration with no MAC spells the model bare (`virtio,bridge=vmbr0,tag=20`) or with an
|
|
9
|
+
* empty value (`virtio=,bridge=vmbr0`); PVE generates one on create either way.
|
|
10
|
+
* ⛔ WITHOUT THIS, A PUT OF AN UNCHANGED NIC GENERATES A NEW MAC — a new DHCP lease and a new IPv6
|
|
11
|
+
* link-local address for a guest that only had, say, its VLAN tag edited. Measured by the
|
|
12
|
+
* builder's red team against a MAC-less `net0` declaration.
|
|
13
|
+
* ⚠️ OTHER SUB-KEYS (`firewall`, `link_down`, `mtu`, `queues`, `rate`, `tag`, `trunks`) COMPARE
|
|
14
|
+
* RAW, NO DEFAULTS DROPPED — that table is unverified here, unlike lxc-wire.ts's `DEFAULTS`. A
|
|
15
|
+
* difference in one is real drift and gets written, exactly as the whole-string comparison this
|
|
16
|
+
* replaces already did; only the MAC is special-cased, the minimum that fixes the measured bug.
|
|
17
|
+
*/
|
|
18
|
+
/** pve-qemu-server's own `$nic_model_list` — whichever of these is present is the MAC-bearing key. */
|
|
19
|
+
const NIC_MODELS = [
|
|
20
|
+
'e1000',
|
|
21
|
+
'e1000e',
|
|
22
|
+
'i82551',
|
|
23
|
+
'i82557b',
|
|
24
|
+
'i82559er',
|
|
25
|
+
'i82562',
|
|
26
|
+
'i82801',
|
|
27
|
+
'ne2k_isa',
|
|
28
|
+
'ne2k_pci',
|
|
29
|
+
'pcnet',
|
|
30
|
+
'rtl8139',
|
|
31
|
+
'virtio',
|
|
32
|
+
'vmxnet3',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/** `a=1,b=2` or a bare `a` (→ `['a', '']`) — the one-off parser this file needs, not lxc-wire's. */
|
|
36
|
+
const netPairs = (value: string): Map<string, string> =>
|
|
37
|
+
new Map(
|
|
38
|
+
value
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((part) => part.trim())
|
|
41
|
+
.filter((part) => part !== '')
|
|
42
|
+
.map((part): [string, string] => {
|
|
43
|
+
const at = part.indexOf('=');
|
|
44
|
+
return at === -1 ? [part, ''] : [part.slice(0, at), part.slice(at + 1)];
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const modelKey = (map: ReadonlyMap<string, string>): string | undefined =>
|
|
49
|
+
NIC_MODELS.find((model) => map.has(model));
|
|
50
|
+
|
|
51
|
+
/** The MAC-substituted comparison maps: an undeclared/blank MAC is PVE's, not drift. */
|
|
52
|
+
export const netMaps = (declared: string, live: string) => {
|
|
53
|
+
const want = netPairs(declared);
|
|
54
|
+
const have = netPairs(live);
|
|
55
|
+
const wantModel = modelKey(want);
|
|
56
|
+
const haveModel = modelKey(have);
|
|
57
|
+
if (wantModel !== undefined && (want.get(wantModel) ?? '') === '' && haveModel !== undefined) {
|
|
58
|
+
want.set(wantModel, have.get(haveModel) ?? '');
|
|
59
|
+
}
|
|
60
|
+
return { have, want };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const sameNet = (declared: string, live: string): boolean => {
|
|
64
|
+
const { have, want } = netMaps(declared, live);
|
|
65
|
+
return have.size === want.size && [...want].every(([k, v]) => have.get(k) === v);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The value to PUT: the declaration, plus the live MAC when it names none. */
|
|
69
|
+
export const netWrite = (declared: string, live: string): string => {
|
|
70
|
+
const want = netPairs(declared);
|
|
71
|
+
const wantModel = modelKey(want);
|
|
72
|
+
const have = netPairs(live);
|
|
73
|
+
const haveModel = modelKey(have);
|
|
74
|
+
if (wantModel !== undefined && (want.get(wantModel) ?? '') === '' && haveModel !== undefined) {
|
|
75
|
+
want.set(wantModel, have.get(haveModel) ?? '');
|
|
76
|
+
}
|
|
77
|
+
return [...want].map(([k, v]) => (v === '' ? k : `${k}=${v}`)).join(',');
|
|
78
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose VM it is — docs/ownership.md applied to `Proxmox.Vm` (red-team C2 on PR 297), through the
|
|
3
|
+
* real engine over a stub fetch (fake-pve.ts).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHAT THIS PINS: a live VM this stack holds no state for is never adopted or written silently
|
|
6
|
+
* (the measured regression: `adopt(false)` on an existing VM still planned `adopted` and PUT it).
|
|
7
|
+
* `adopt(false)` on the declaration wins over the test harness's own forced `AdoptPolicy: true`
|
|
8
|
+
* (fake-engine.ts) — a resource-scoped `adopt(...)` overrides the ambient policy both ways
|
|
9
|
+
* (ownership/adopt.ts), which is what lets this test observe the refusal at all.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
15
|
+
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
16
|
+
import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
|
|
17
|
+
|
|
18
|
+
const NODE = 'pve1';
|
|
19
|
+
const VMID = 150;
|
|
20
|
+
const props = { node: NODE, target: FAKE_TARGET, vmid: VMID };
|
|
21
|
+
const live = { digest: 'a'.repeat(40), memory: '512' };
|
|
22
|
+
|
|
23
|
+
const engine = (fake: ReturnType<typeof fakePve>) =>
|
|
24
|
+
engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
25
|
+
|
|
26
|
+
describe('Proxmox.Vm ownership', () => {
|
|
27
|
+
test('adopt(false): a live VM with no state is refused, and nothing is written', async () => {
|
|
28
|
+
const fake = fakePve((call) => (call.method === 'GET' ? live : null));
|
|
29
|
+
await withoutBao(async () => {
|
|
30
|
+
await expect(engine(fake).deploy(ProxmoxVm('row', props).pipe(adopt(false)))).rejects.toThrow(
|
|
31
|
+
/adopt|exists, and this stack holds no state/i,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
expect(fake.writes()).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the harness default (adoption on) still adopts with no write — unchanged behaviour', async () => {
|
|
38
|
+
const fake = fakePve((call) => (call.method === 'GET' ? live : null));
|
|
39
|
+
await withoutBao(async () => {
|
|
40
|
+
expect(await engine(fake).deploy(ProxmoxVm('row', props))).toEqual({ row: 'adopted' });
|
|
41
|
+
});
|
|
42
|
+
expect(fake.writes()).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure unit tests for the declared-keys model: no network, no fake PVE. The engine-level proof
|
|
3
|
+
* that an unmanaged key is refused before any write lives in qemu-lifecycle.test.ts, alongside
|
|
4
|
+
* the node-pinned-refusal and 5-default-PUT regression tests.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { formRefusals } from './qemu-form.ts';
|
|
8
|
+
import {
|
|
9
|
+
type VmProps,
|
|
10
|
+
declaredKeys,
|
|
11
|
+
indexedKey,
|
|
12
|
+
isManagedKey,
|
|
13
|
+
storedConfig,
|
|
14
|
+
wireValue,
|
|
15
|
+
} from './qemu-props.ts';
|
|
16
|
+
import { FAKE_TARGET } from './fake-pve.ts';
|
|
17
|
+
|
|
18
|
+
const base = { node: 'pve1', target: FAKE_TARGET, vmid: 150 } as const;
|
|
19
|
+
|
|
20
|
+
describe('declaredKeys', () => {
|
|
21
|
+
test('excludes identity fields and undeclared ones, and sorts what is left', () => {
|
|
22
|
+
const props: VmProps = { ...base, cores: 2, memory: 4096 };
|
|
23
|
+
expect(declaredKeys(props)).toEqual(['cores', 'memory']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('an empty declaration manages nothing — the base for every "adopt as-is" test', () => {
|
|
27
|
+
expect(declaredKeys(base as unknown as VmProps)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('isManagedKey / indexedKey', () => {
|
|
32
|
+
test('the scalar and indexed families Talos needs are all managed', () => {
|
|
33
|
+
for (const key of [
|
|
34
|
+
'agent',
|
|
35
|
+
'boot',
|
|
36
|
+
'cpu',
|
|
37
|
+
'memory',
|
|
38
|
+
'scsihw',
|
|
39
|
+
'scsi0',
|
|
40
|
+
'net0',
|
|
41
|
+
'ide2',
|
|
42
|
+
'ipconfig0',
|
|
43
|
+
'serial0',
|
|
44
|
+
]) {
|
|
45
|
+
expect(isManagedKey(key)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('⛔ no secret prop exists: cipassword and machine are never managed keys', () => {
|
|
50
|
+
expect(isManagedKey('cipassword')).toBe(false);
|
|
51
|
+
expect(isManagedKey('machine')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('an out-of-range index is not a PVE key this resource recognises', () => {
|
|
55
|
+
expect(indexedKey('scsi31')).toBeUndefined(); // pve-qemu-server's own 0-30
|
|
56
|
+
expect(indexedKey('ide4')).toBeUndefined(); // 0-3
|
|
57
|
+
expect(indexedKey('scsi03')).toBeUndefined(); // not how PVE spells it
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('wireValue / storedConfig', () => {
|
|
62
|
+
test('booleans go as 1/0, everything else as String()', () => {
|
|
63
|
+
expect(wireValue(true)).toBe('1');
|
|
64
|
+
expect(wireValue(false)).toBe('0');
|
|
65
|
+
expect(wireValue(4096)).toBe('4096');
|
|
66
|
+
expect(wireValue('local-zfs:32')).toBe('local-zfs:32');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('storedConfig is an allowlist: an unmanaged live key never reaches state', () => {
|
|
70
|
+
const live = { cipassword: 'leaked', cores: 2, digest: 'a'.repeat(40), scsi0: 'local:32' };
|
|
71
|
+
expect(storedConfig(live)).toEqual({ cores: '2', scsi0: 'local:32' });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('formRefusals — the runtime backstop behind the never-typed props', () => {
|
|
76
|
+
test('a clean declaration is refused nothing', () => {
|
|
77
|
+
expect(formRefusals({ ...base, agent: true, net0: 'bridge=vmbr0,tag=42' })).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a key smuggled past the types is named, not silently dropped or silently sent', () => {
|
|
81
|
+
const smuggled = { ...base, cipassword: 'x', machine: 'q35' } as unknown as VmProps;
|
|
82
|
+
expect(formRefusals(smuggled)).toEqual([
|
|
83
|
+
'cipassword: not a config key this resource manages (vmid 150).',
|
|
84
|
+
'machine: not a config key this resource manages (vmid 150).',
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a `Proxmox.Vm` declaration says, and what its state keeps.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE SAME "DECLARED KEYS" MODEL AS `Proxmox.Lxc` (lxc-props.ts, joined 2026-09-21): an
|
|
5
|
+
* undeclared key is UNMANAGED — never sent, never compared, never overwritten. Adopting a live
|
|
6
|
+
* VM is then a paste of its config, not a translation of it, and the wire spelling is PVE's own
|
|
7
|
+
* (`scsi0`, `net0`, `ipconfig0`, as `pvesh get .../qemu/{vmid}/config` prints them).
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THIS REPLACES THE "5-DEFAULT PUT". Before 2026-09-26, `qemu-form.ts`'s `shape()` sent
|
|
10
|
+
* `cores`/`memory`/`name`/`onboot`/`sockets` on EVERY write with a hard default substituted for
|
|
11
|
+
* whichever of the five was left undeclared, so declaring only `memory: 4096` on an adopted VM
|
|
12
|
+
* still PUT `name: 'vm101'` and `sockets: '1'` over whatever the guest actually had. Harmless
|
|
13
|
+
* while `ProxmoxVm` stayed Provider-only (nothing declared it), load-bearing the moment Talos
|
|
14
|
+
* does. `declaredKeys` below is the fix: a field absent from the declaration is never read,
|
|
15
|
+
* never compared and never written — see qemu-form.ts's `form()`.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NO SECRET IS A PROP. `cipassword` (cloud-init's password) is write-only and Alchemy persists
|
|
18
|
+
* props in plaintext (this estate's state store is dumped nightly) — typed `never`, exactly
|
|
19
|
+
* like lxc-props.ts's `password`. A VM that needs a login uses `sshkeys`-free image defaults or
|
|
20
|
+
* an out-of-band secret; this resource never carries one.
|
|
21
|
+
* ⛔ `machine` IS NEVER A PROP EITHER. It pins the emulated chipset/firmware revision a guest boots
|
|
22
|
+
* against; PVE already resolves it from the node's installed QEMU version, and declaring it here
|
|
23
|
+
* would fight every future node upgrade instead of tracking it. Typed `never` for the same
|
|
24
|
+
* reason `cipassword` is: a cast is the only way in, and this file's helpers below never read
|
|
25
|
+
* an unmanaged key onto the wire regardless.
|
|
26
|
+
*/
|
|
27
|
+
import type { WithTarget } from './resource.ts';
|
|
28
|
+
|
|
29
|
+
/** A PVE boolean as pvesh prints it (`1`) or as TypeScript writes it (`true`). The same value. */
|
|
30
|
+
export type PveFlag = boolean | 0 | 1;
|
|
31
|
+
|
|
32
|
+
export interface VmProps extends WithTarget {
|
|
33
|
+
/** ⛔ This resource never migrates: qemu-read.ts refuses a VM found on another node. */
|
|
34
|
+
node: string;
|
|
35
|
+
/** ⛔ Cluster-wide, and shared with LXC. See qemu.ts's header. */
|
|
36
|
+
vmid: number;
|
|
37
|
+
name?: string;
|
|
38
|
+
/** MiB. */
|
|
39
|
+
memory?: number;
|
|
40
|
+
cores?: number;
|
|
41
|
+
sockets?: number;
|
|
42
|
+
/** Emulated CPU type, e.g. `host`. */
|
|
43
|
+
cpu?: string;
|
|
44
|
+
onboot?: PveFlag;
|
|
45
|
+
/** The QEMU guest agent. Talos reports its addresses through it once this is enabled. */
|
|
46
|
+
agent?: PveFlag;
|
|
47
|
+
/** `order=scsi0;ide2;net0`. */
|
|
48
|
+
boot?: string;
|
|
49
|
+
/** e.g. `virtio-scsi-single`. */
|
|
50
|
+
scsihw?: string;
|
|
51
|
+
/**
|
|
52
|
+
* `local-zfs:32` to create a new volume, or the live volume string as adopted —
|
|
53
|
+
* `cephtb4:vm-101-disk-0,size=32G` (n is 0 to 30, pve-qemu-server's own `$MAX_SCSI_DISKS`).
|
|
54
|
+
*/
|
|
55
|
+
[scsi: `scsi${number}`]: string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* `virtio=<mac>,bridge=vmbr0,tag=<vlan>` — a VLAN-aware bridge with an 802.1q tag is a bridge
|
|
58
|
+
* plus this one sub-property; PVE's SDN stays out of it entirely (n is 0 to 31).
|
|
59
|
+
*/
|
|
60
|
+
[nic: `net${number}`]: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The cloud-init drive, e.g. `cephtb4:cloudinit`. PVE's own web UI puts it on `ide2`, but any
|
|
63
|
+
* free `ide`/`sata`/`scsi` slot works; declaring one is what gives `ipconfig0` below somewhere
|
|
64
|
+
* to write the generated seed image (n is 0 to 3).
|
|
65
|
+
*/
|
|
66
|
+
[cloudinit: `ide${number}`]: string | undefined;
|
|
67
|
+
/** cloud-init network config: `ip=dhcp`, or `ip=<cidr>,gw=<gw>` (n is 0 to 31). */
|
|
68
|
+
[ipconfig: `ipconfig${number}`]: string | undefined;
|
|
69
|
+
/** `socket` — a serial console, the way Talos exposes its own (n is 0 to 3). */
|
|
70
|
+
[serial: `serial${number}`]: string | undefined;
|
|
71
|
+
/** ⛔ A secret. See the header. */
|
|
72
|
+
cipassword?: never;
|
|
73
|
+
/** ⛔ Machine config. See the header. */
|
|
74
|
+
machine?: never;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface VmAttributes {
|
|
78
|
+
node: string;
|
|
79
|
+
vmid: number;
|
|
80
|
+
/** The live config, MANAGED keys only — see `storedConfig`. An allowlist, not a mirror. */
|
|
81
|
+
config: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Props that identify the object rather than configure it: never sent, never compared. */
|
|
85
|
+
const NOT_CONFIG: ReadonlySet<string> = new Set(['node', 'target', 'vmid']);
|
|
86
|
+
|
|
87
|
+
/** The indexed families this resource manages, with pve-qemu-server's own published range. */
|
|
88
|
+
export const INDEXED = {
|
|
89
|
+
ide: 4,
|
|
90
|
+
ipconfig: 32,
|
|
91
|
+
net: 32,
|
|
92
|
+
scsi: 31,
|
|
93
|
+
serial: 4,
|
|
94
|
+
} as const;
|
|
95
|
+
export type IndexedFamily = keyof typeof INDEXED;
|
|
96
|
+
|
|
97
|
+
/** `scsi3` → `['scsi', 3]`; anything else, including an out-of-range index, → undefined. */
|
|
98
|
+
export const indexedKey = (key: string): readonly [IndexedFamily, number] | undefined => {
|
|
99
|
+
const match = /^(ide|ipconfig|net|scsi|serial)(0|[1-9]\d*)$/.exec(key);
|
|
100
|
+
if (match === null) return undefined;
|
|
101
|
+
const family = match[1] as IndexedFamily;
|
|
102
|
+
const index = Number(match[2]);
|
|
103
|
+
return index < INDEXED[family] ? [family, index] : undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The scalar (non-indexed) keys this resource manages. */
|
|
107
|
+
const SCALAR_KEYS: ReadonlySet<string> = new Set([
|
|
108
|
+
'agent',
|
|
109
|
+
'boot',
|
|
110
|
+
'cores',
|
|
111
|
+
'cpu',
|
|
112
|
+
'memory',
|
|
113
|
+
'name',
|
|
114
|
+
'onboot',
|
|
115
|
+
'scsihw',
|
|
116
|
+
'sockets',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A key this resource compares and may write. ⛔ EVERYTHING ELSE IS REFUSED, NEVER SILENTLY
|
|
121
|
+
* DROPPED OR SILENTLY SENT — `qemu-form.ts`'s `formRefusals` is what a declared key smuggled
|
|
122
|
+
* past the types (a cast, a JS caller: `cipassword`, `machine`, `hookscript`) meets before any
|
|
123
|
+
* write, the same rule `lxc-create-form.ts` enforces for `Proxmox.Lxc`.
|
|
124
|
+
*/
|
|
125
|
+
export const isManagedKey = (key: string): boolean =>
|
|
126
|
+
SCALAR_KEYS.has(key) || indexedKey(key) !== undefined;
|
|
127
|
+
|
|
128
|
+
/** A declaration read by key. ⚠️ The cast is the one place a template-literal index meets a plain string. */
|
|
129
|
+
export const declaredValue = (props: VmProps, key: string): unknown =>
|
|
130
|
+
(props as unknown as Record<string, unknown>)[key];
|
|
131
|
+
|
|
132
|
+
/** The declared keys with a value, in a stable order — never `node`/`target`/`vmid`. */
|
|
133
|
+
export const declaredKeys = (props: VmProps): string[] =>
|
|
134
|
+
Object.keys(props)
|
|
135
|
+
.filter((key) => !NOT_CONFIG.has(key) && declaredValue(props, key) !== undefined)
|
|
136
|
+
.sort();
|
|
137
|
+
|
|
138
|
+
/** One declared value as PVE's wire string. Booleans go as `1`/`0`, numbers as their decimal. */
|
|
139
|
+
export const wireValue = (value: unknown): string =>
|
|
140
|
+
value === true ? '1' : value === false ? '0' : String(value);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The live config as strings: managed keys only.
|
|
144
|
+
* ⛔ AN ALLOWLIST, NOT "EVERYTHING BUT A DENYLIST" — same reasoning as `lxc-props.ts`'s
|
|
145
|
+
* `storedConfig`. `digest` covers the whole file, `lock`/`pending` are transient, and a future
|
|
146
|
+
* PVE release's new key does not land in this estate's unencrypted state store until this
|
|
147
|
+
* resource is taught to manage it.
|
|
148
|
+
*/
|
|
149
|
+
export const storedConfig = (live: Record<string, unknown>): Record<string, string> => {
|
|
150
|
+
const out: Record<string, string> = {};
|
|
151
|
+
for (const [key, value] of Object.entries(live)) {
|
|
152
|
+
if (!isManagedKey(key)) continue;
|
|
153
|
+
if (value === null || value === undefined || typeof value === 'object') continue;
|
|
154
|
+
out[key] = wireValue(value);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
};
|