@homeflare/alchemy 0.37.8 → 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.
Files changed (183) hide show
  1. package/README.md +22 -0
  2. package/dist/caddy/admin.d.ts +42 -8
  3. package/dist/caddy/admin.d.ts.map +1 -1
  4. package/dist/caddy/config.d.ts +9 -3
  5. package/dist/caddy/config.d.ts.map +1 -1
  6. package/dist/caddy/fake-caddy.d.ts +5 -0
  7. package/dist/caddy/fake-caddy.d.ts.map +1 -1
  8. package/dist/caddy/index.js +10 -6
  9. package/dist/caddy/index.js.map +5 -5
  10. package/dist/caddy/providers.d.ts +19 -12
  11. package/dist/caddy/providers.d.ts.map +1 -1
  12. package/dist/forgejo/index.js +4 -2
  13. package/dist/forgejo/index.js.map +3 -3
  14. package/dist/forgejo/team-member.d.ts +25 -0
  15. package/dist/forgejo/team-member.d.ts.map +1 -1
  16. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  17. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  18. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  20. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  21. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  22. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  23. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  24. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  25. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  26. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  27. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  28. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  29. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  32. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  33. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  34. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  36. package/dist/proxmox/index.d.ts +14 -3
  37. package/dist/proxmox/index.d.ts.map +1 -1
  38. package/dist/proxmox/index.js +565 -133
  39. package/dist/proxmox/index.js.map +33 -23
  40. package/dist/proxmox/qemu-form.d.ts +27 -18
  41. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  42. package/dist/proxmox/qemu-judge.d.ts +30 -0
  43. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  44. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  45. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  46. package/dist/proxmox/qemu-net.d.ts +9 -0
  47. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  48. package/dist/proxmox/qemu-props.d.ts +112 -0
  49. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  50. package/dist/proxmox/qemu-volume.d.ts +17 -0
  51. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  52. package/dist/proxmox/qemu.d.ts +14 -27
  53. package/dist/proxmox/qemu.d.ts.map +1 -1
  54. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  55. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  56. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  57. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  58. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  59. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  60. package/dist/proxmox/storage-download-props.d.ts +36 -0
  61. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  62. package/dist/proxmox/storage-download-task.d.ts +17 -0
  63. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  64. package/dist/proxmox/storage-download.d.ts +44 -0
  65. package/dist/proxmox/storage-download.d.ts.map +1 -0
  66. package/dist/telemetry/fake-collector.d.ts +36 -0
  67. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  68. package/dist/telemetry/index.d.ts +11 -0
  69. package/dist/telemetry/index.d.ts.map +1 -0
  70. package/dist/telemetry/index.js +223 -0
  71. package/dist/telemetry/index.js.map +12 -0
  72. package/dist/telemetry/layer.d.ts +86 -0
  73. package/dist/telemetry/layer.d.ts.map +1 -0
  74. package/dist/telemetry/redact-serialization.d.ts +21 -0
  75. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  76. package/dist/telemetry/redact.d.ts +46 -0
  77. package/dist/telemetry/redact.d.ts.map +1 -0
  78. package/dist/unifi/drift.d.ts +27 -0
  79. package/dist/unifi/drift.d.ts.map +1 -0
  80. package/dist/unifi/fake-unifi.d.ts +5 -1
  81. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  82. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  83. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  84. package/dist/unifi/firewall-zone.d.ts +1 -1
  85. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  86. package/dist/unifi/index.d.ts +4 -3
  87. package/dist/unifi/index.d.ts.map +1 -1
  88. package/dist/unifi/index.js +138 -58
  89. package/dist/unifi/index.js.map +10 -8
  90. package/dist/unifi/network-drift.d.ts +26 -0
  91. package/dist/unifi/network-drift.d.ts.map +1 -0
  92. package/dist/unifi/network-form.d.ts +40 -7
  93. package/dist/unifi/network-form.d.ts.map +1 -1
  94. package/dist/unifi/network.d.ts +1 -0
  95. package/dist/unifi/network.d.ts.map +1 -1
  96. package/dist/unifi/paginate.d.ts +36 -0
  97. package/dist/unifi/paginate.d.ts.map +1 -0
  98. package/dist/unifi/resource.d.ts +36 -1
  99. package/dist/unifi/resource.d.ts.map +1 -1
  100. package/docs/telemetry-spike.md +94 -0
  101. package/docs/telemetry.md +109 -0
  102. package/docs/unifi.md +56 -0
  103. package/package.json +6 -2
  104. package/src/caddy/admin-transport-scope.test.ts +127 -0
  105. package/src/caddy/admin.ts +45 -9
  106. package/src/caddy/config-adopt-provider.test.ts +2 -2
  107. package/src/caddy/config-format-warning.test.ts +9 -3
  108. package/src/caddy/config.ts +20 -4
  109. package/src/caddy/fake-caddy.ts +10 -1
  110. package/src/caddy/providers.test.ts +3 -2
  111. package/src/caddy/providers.ts +18 -11
  112. package/src/forgejo/team-member.test.ts +14 -0
  113. package/src/forgejo/team-member.ts +28 -1
  114. package/src/proxmox/constraints-required.test.ts +1 -0
  115. package/src/proxmox/generated/constraints/index.ts +3 -1
  116. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  117. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  118. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  119. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  120. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  121. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  122. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  123. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  124. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  125. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  126. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  127. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  128. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  129. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  130. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  131. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  132. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  133. package/src/proxmox/index.test.ts +11 -6
  134. package/src/proxmox/index.ts +19 -3
  135. package/src/proxmox/qemu-form.ts +49 -13
  136. package/src/proxmox/qemu-identity.test.ts +30 -0
  137. package/src/proxmox/qemu-judge.test.ts +65 -0
  138. package/src/proxmox/qemu-judge.ts +84 -0
  139. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  140. package/src/proxmox/qemu-lifecycle.ts +131 -32
  141. package/src/proxmox/qemu-net.test.ts +48 -0
  142. package/src/proxmox/qemu-net.ts +78 -0
  143. package/src/proxmox/qemu-ownership.test.ts +44 -0
  144. package/src/proxmox/qemu-props.test.ts +87 -0
  145. package/src/proxmox/qemu-props.ts +157 -0
  146. package/src/proxmox/qemu-volume.test.ts +80 -0
  147. package/src/proxmox/qemu-volume.ts +121 -0
  148. package/src/proxmox/qemu.ts +15 -29
  149. package/src/proxmox/storage-download-errors.ts +7 -0
  150. package/src/proxmox/storage-download-identity.test.ts +54 -0
  151. package/src/proxmox/storage-download-identity.ts +50 -0
  152. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  153. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  154. package/src/proxmox/storage-download-props.ts +38 -0
  155. package/src/proxmox/storage-download-task.ts +72 -0
  156. package/src/proxmox/storage-download.test.ts +216 -0
  157. package/src/proxmox/storage-download.ts +58 -0
  158. package/src/telemetry/fake-collector.ts +59 -0
  159. package/src/telemetry/http-client-sealing.test.ts +82 -0
  160. package/src/telemetry/index.ts +10 -0
  161. package/src/telemetry/layer.test.ts +204 -0
  162. package/src/telemetry/layer.ts +152 -0
  163. package/src/telemetry/redact-serialization.ts +178 -0
  164. package/src/telemetry/redact.test.ts +124 -0
  165. package/src/telemetry/redact.ts +147 -0
  166. package/src/unifi/drift.test.ts +78 -0
  167. package/src/unifi/drift.ts +56 -0
  168. package/src/unifi/errors-and-secrets.test.ts +127 -0
  169. package/src/unifi/fake-unifi.ts +5 -1
  170. package/src/unifi/firewall-zone-form.ts +31 -2
  171. package/src/unifi/firewall-zone.test.ts +21 -1
  172. package/src/unifi/firewall-zone.ts +1 -1
  173. package/src/unifi/get-only-guard.test.ts +165 -0
  174. package/src/unifi/index.ts +15 -1
  175. package/src/unifi/network-drift.test.ts +207 -0
  176. package/src/unifi/network-drift.ts +80 -0
  177. package/src/unifi/network-form.ts +11 -39
  178. package/src/unifi/network.test.ts +7 -82
  179. package/src/unifi/network.ts +6 -6
  180. package/src/unifi/paginate.test.ts +228 -0
  181. package/src/unifi/paginate.ts +129 -0
  182. package/src/unifi/resource.ts +73 -3
  183. 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, shape } from './qemu-form.ts';
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
- live: {
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
- attributes.name === (props.name ?? `vm${String(props.vmid)}`) &&
46
- attributes.memory === (props.memory ?? 512) &&
47
- attributes.cores === (props.cores ?? 1) &&
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: shape,
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) => (live === undefined ? undefined : attributesOf(live, props))),
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
- read: ({ olds }: { olds: VmProps }) => readQemu(olds),
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
- return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
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* ({ news }: { news: VmProps }) {
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
- const form = formToSend(spec.matches, live, news, spec.updateForm(news));
114
- if (form !== undefined) {
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({ ...form, node: news.node, vmid: String(news.vmid) }),
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
+ };