@homeflare/alchemy 0.37.5 → 0.37.7

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 (126) hide show
  1. package/dist/openbao/auth-role-form.d.ts +0 -6
  2. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  3. package/dist/openbao/auth-role-identity.d.ts +11 -0
  4. package/dist/openbao/auth-role-identity.d.ts.map +1 -0
  5. package/dist/openbao/auth-role-wire.d.ts +23 -0
  6. package/dist/openbao/auth-role-wire.d.ts.map +1 -0
  7. package/dist/openbao/auth-role.d.ts.map +1 -1
  8. package/dist/openbao/bao-http.d.ts +34 -13
  9. package/dist/openbao/bao-http.d.ts.map +1 -1
  10. package/dist/openbao/distilled.d.ts +25 -0
  11. package/dist/openbao/distilled.d.ts.map +1 -0
  12. package/dist/openbao/fake-engines-roles.d.ts +5 -1
  13. package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
  14. package/dist/openbao/index.js +358 -305
  15. package/dist/openbao/index.js.map +13 -9
  16. package/dist/openbao/policy-identity.d.ts +12 -0
  17. package/dist/openbao/policy-identity.d.ts.map +1 -0
  18. package/dist/openbao/policy-wire.d.ts +9 -8
  19. package/dist/openbao/policy-wire.d.ts.map +1 -1
  20. package/dist/openbao/policy.d.ts.map +1 -1
  21. package/dist/openbao/rename.d.ts +5 -0
  22. package/dist/openbao/rename.d.ts.map +1 -1
  23. package/dist/proxmox/firewall-alias-config.d.ts +36 -0
  24. package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
  25. package/dist/proxmox/firewall-alias-form.d.ts +3 -1
  26. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
  27. package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
  28. package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
  29. package/dist/proxmox/firewall-alias.d.ts +2 -8
  30. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  31. package/dist/proxmox/index.js +795 -526
  32. package/dist/proxmox/index.js.map +24 -11
  33. package/dist/proxmox/lxc-errors.d.ts +12 -0
  34. package/dist/proxmox/lxc-errors.d.ts.map +1 -0
  35. package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
  36. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  37. package/dist/proxmox/lxc-read.d.ts +4 -0
  38. package/dist/proxmox/lxc-read.d.ts.map +1 -0
  39. package/dist/proxmox/lxc-task.d.ts +15 -0
  40. package/dist/proxmox/lxc-task.d.ts.map +1 -0
  41. package/dist/proxmox/network-apply-read.d.ts +7 -14
  42. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  43. package/dist/proxmox/network-apply-run.d.ts +7 -0
  44. package/dist/proxmox/network-apply-run.d.ts.map +1 -0
  45. package/dist/proxmox/network-apply.d.ts +2 -3
  46. package/dist/proxmox/network-apply.d.ts.map +1 -1
  47. package/dist/proxmox/node-network-wire.d.ts +14 -10
  48. package/dist/proxmox/node-network-wire.d.ts.map +1 -1
  49. package/dist/proxmox/qemu-errors.d.ts +5 -0
  50. package/dist/proxmox/qemu-errors.d.ts.map +1 -0
  51. package/dist/proxmox/qemu-form.d.ts +22 -0
  52. package/dist/proxmox/qemu-form.d.ts.map +1 -0
  53. package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
  54. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
  55. package/dist/proxmox/qemu-read.d.ts +5 -0
  56. package/dist/proxmox/qemu-read.d.ts.map +1 -0
  57. package/dist/proxmox/qemu-task.d.ts +9 -0
  58. package/dist/proxmox/qemu-task.d.ts.map +1 -0
  59. package/dist/proxmox/qemu.d.ts +9 -14
  60. package/dist/proxmox/qemu.d.ts.map +1 -1
  61. package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
  62. package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
  63. package/dist/proxmox/replication-job-config.d.ts +56 -0
  64. package/dist/proxmox/replication-job-config.d.ts.map +1 -0
  65. package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
  66. package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
  67. package/dist/proxmox/replication-job.d.ts +5 -6
  68. package/dist/proxmox/replication-job.d.ts.map +1 -1
  69. package/dist/release/archive.d.ts.map +1 -1
  70. package/dist/release/index.js +3 -2
  71. package/dist/release/index.js.map +3 -3
  72. package/docs/proxmox-lxc-distilled.md +56 -0
  73. package/docs/proxmox-replication-firewall-distilled.md +53 -0
  74. package/docs/proxmox-sdk-migration.md +23 -18
  75. package/docs/upstream-conformance.md +5 -3
  76. package/package.json +3 -2
  77. package/src/openbao/auth-role-form.test.ts +11 -9
  78. package/src/openbao/auth-role-form.ts +0 -19
  79. package/src/openbao/auth-role-identity.ts +24 -0
  80. package/src/openbao/auth-role-sdk.test.ts +155 -0
  81. package/src/openbao/auth-role-wire.test.ts +128 -0
  82. package/src/openbao/auth-role-wire.ts +89 -0
  83. package/src/openbao/auth-role.ts +9 -9
  84. package/src/openbao/bao-http.ts +9 -7
  85. package/src/openbao/distilled-retry.test.ts +164 -0
  86. package/src/openbao/distilled.test.ts +146 -0
  87. package/src/openbao/distilled.ts +88 -0
  88. package/src/openbao/fake-engines-roles.ts +9 -2
  89. package/src/openbao/policy-distilled.test.ts +98 -0
  90. package/src/openbao/policy-identity.ts +24 -0
  91. package/src/openbao/policy-rename.test.ts +2 -2
  92. package/src/openbao/policy-wire.test.ts +5 -5
  93. package/src/openbao/policy-wire.ts +31 -11
  94. package/src/openbao/policy.ts +10 -13
  95. package/src/openbao/rename.ts +17 -1
  96. package/src/proxmox/firewall-alias-config.ts +39 -0
  97. package/src/proxmox/firewall-alias-form.ts +1 -1
  98. package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
  99. package/src/proxmox/firewall-alias.ts +4 -67
  100. package/src/proxmox/lxc-create.test.ts +4 -1
  101. package/src/proxmox/lxc-distilled.test.ts +239 -0
  102. package/src/proxmox/lxc-errors.ts +9 -0
  103. package/src/proxmox/lxc-lifecycle.ts +31 -113
  104. package/src/proxmox/lxc-read.test.ts +29 -6
  105. package/src/proxmox/lxc-read.ts +88 -0
  106. package/src/proxmox/lxc-task.ts +71 -0
  107. package/src/proxmox/network-apply-distilled.test.ts +76 -0
  108. package/src/proxmox/network-apply-read.ts +18 -39
  109. package/src/proxmox/network-apply-run.ts +97 -0
  110. package/src/proxmox/network-apply.ts +5 -94
  111. package/src/proxmox/node-network-read-failure.test.ts +24 -0
  112. package/src/proxmox/node-network-wire.ts +15 -17
  113. package/src/proxmox/qemu-errors.ts +7 -0
  114. package/src/proxmox/qemu-form.ts +15 -0
  115. package/src/proxmox/qemu-lifecycle.test.ts +171 -0
  116. package/src/proxmox/qemu-lifecycle.ts +134 -0
  117. package/src/proxmox/qemu-read.ts +73 -0
  118. package/src/proxmox/qemu-task.ts +64 -0
  119. package/src/proxmox/qemu.ts +11 -44
  120. package/src/proxmox/replication-firewall-fixtures.ts +71 -0
  121. package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
  122. package/src/proxmox/replication-firewall-wire.test.ts +173 -0
  123. package/src/proxmox/replication-job-config.ts +79 -0
  124. package/src/proxmox/replication-job-lifecycle.ts +113 -0
  125. package/src/proxmox/replication-job.ts +7 -84
  126. package/src/release/archive.ts +4 -1
@@ -0,0 +1,97 @@
1
+ /** The reload itself: one node, one lease, one task, then a health check. */
2
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
3
+ import * as Effect from 'effect/Effect';
4
+ import { guardNetworkApply } from './apply-endpoints.ts';
5
+ import { runPveWith } from './distilled-pve.ts';
6
+ import { mint } from './mint.ts';
7
+ import { awaitTask, degradedReason, pendingCount } from './network-apply-read.ts';
8
+ import type { NetworkApplyProps } from './network-apply.ts';
9
+ import { text } from './values.ts';
10
+
11
+ /** Stop the deploy, loudly, with the node in the message. ⚠️ A die here is the SAFE outcome. */
12
+ const refuse = (node: string, why: string) =>
13
+ Effect.die(new Error(`Proxmox.NetworkApply ${node}: ${why}`));
14
+
15
+ export const applyNetwork = (props: NetworkApplyProps) =>
16
+ Effect.gen(function* () {
17
+ const node = props.node;
18
+ const staged = yield* pendingCount(props.target, node);
19
+ /**
20
+ * ⛔ NOTHING STAGED MEANS NOTHING TO APPLY, AND THIS BRANCH IS A SAFETY PROPERTY RATHER THAN AN
21
+ * OPTIMISATION. `reconcile` also runs on CREATE — the first time this resource appears in a
22
+ * stack, before `diff` has ever been consulted — and the PVE worker runs `ifreload -a`
23
+ * UNCONDITIONALLY, staged file or not. Without this branch, adding `Proxmox.NetworkApply` to
24
+ * a stack describing the cluster as it already is would reload networking on every node it
25
+ * names, for nothing. Adoption must cost nothing; that is true here in the strongest sense.
26
+ */
27
+ if (staged === 0) return { node, pending: 0 };
28
+
29
+ const before = yield* degradedReason(props.target);
30
+ if (before !== undefined) {
31
+ return yield* refuse(
32
+ node,
33
+ `refusing to reload networking on a cluster that is already degraded (${before}). ` +
34
+ 'Ceph spans these nodes -- fix the cluster first, then re-run the deploy.',
35
+ );
36
+ }
37
+
38
+ /**
39
+ * ⚠️ ONE LEASE FOR BOTH CALLS, VIA `runPveWith` RATHER THAN `runPve`. Each mint returns a NEW token
40
+ * id, and the task-status endpoint only skips its `Sys.Audit` check for the task's OWNER —
41
+ * so polling with a fresh mint is a different identity and needs a privilege this role does
42
+ * not have. The `provision` lease is 300s and non-renewable, which is far longer than a
43
+ * reload and is the reason no renew path is reached for.
44
+ */
45
+ // ⛔ BEFORE THE MINT AND THE RELOAD. The apply takes no body, so this is the lookup rather
46
+ // than a value check: a vendor that moved this endpoint fails here, not mid-ifreload.
47
+ yield* guardNetworkApply;
48
+ const credential = yield* mint(props.target, 'provision');
49
+ const upid = text(
50
+ yield* runPveWith(props.target, credential, true, nodes.putNodeNetwork({ node })),
51
+ );
52
+ if (upid === '') {
53
+ return yield* refuse(
54
+ node,
55
+ 'the apply returned no UPID. PVE wraps every answer in {"data":...} and can report success ' +
56
+ 'on a call that did nothing -- the reload cannot be confirmed, so it is not claimed.',
57
+ );
58
+ }
59
+
60
+ /**
61
+ * ⛔ POLLING `pending` INSTEAD OF THE TASK WOULD REPORT SUCCESS BEFORE THE NETWORK WAS TOUCHED.
62
+ * MEASURED in the worker above: the `rename` happens FIRST and `ifreload -a` second, so the
63
+ * staged file — and therefore `changes`, and therefore `pending` — disappears while the
64
+ * reload has not yet started. A provider that watched the count would call a reload that had
65
+ * not run, and a reload that then FAILED, a success. The task's exitstatus is the only
66
+ * honest answer.
67
+ */
68
+ const exitstatus = yield* awaitTask(props.target, credential, node, upid);
69
+ if (exitstatus !== 'OK') {
70
+ return yield* refuse(
71
+ node,
72
+ `the reload ended "${exitstatus}" (${upid}). Read it from ANOTHER node -- ` +
73
+ `\`pvesh get /nodes/${node}/tasks/${upid}/log\` -- and do not apply the next node until ` +
74
+ 'this one is understood.',
75
+ );
76
+ }
77
+
78
+ const after = yield* degradedReason(props.target);
79
+ if (after !== undefined) {
80
+ return yield* refuse(
81
+ node,
82
+ `the reload reported OK but the cluster is now degraded (${after}). The next ` +
83
+ 'node has NOT been applied. Restore this one before continuing -- Ceph runs over these ' +
84
+ 'links and a second reload now would take pools below min_size.',
85
+ );
86
+ }
87
+
88
+ const left = yield* pendingCount(props.target, node);
89
+ if (left !== 0) {
90
+ return yield* refuse(
91
+ node,
92
+ `the reload reported OK but ${String(left)} staged line(s) remain. The running config does ` +
93
+ 'not match the declared one, and saying otherwise is the lie this resource exists to stop.',
94
+ );
95
+ }
96
+ return { node, pending: left };
97
+ });
@@ -45,19 +45,16 @@
45
45
  * GRANT — it also carries that node's DNS, hosts file, time and service configuration. Granting
46
46
  * it cluster-wide to run this resource widens the provisioning role well beyond networking.
47
47
  *
48
- * ⛔ STAYS ON `client.ts` (2026-09-24) — network-apply-read.ts's header has the two MEASURED reasons.
48
+ * Named operations: `listNodeNetwork` keeps the diff sibling, `putNodeNetwork` reloads.
49
49
  */
50
50
  import { Resource } from 'alchemy';
51
51
  import { isResolved } from 'alchemy/Diff';
52
52
  import type { Input } from 'alchemy/Input';
53
53
  import * as Provider from 'alchemy/Provider';
54
54
  import * as Effect from 'effect/Effect';
55
- import { guardNetworkApply } from './apply-endpoints.ts';
56
- import { pveWith } from './client.ts';
57
- import { mint } from './mint.ts';
58
- import { awaitTask, degradedReason, pendingCount } from './network-apply-read.ts';
59
- import type { PveRequirements } from './resource.ts';
60
- import type { WithTarget } from './resource.ts';
55
+ import { applyNetwork } from './network-apply-run.ts';
56
+ import { pendingCount } from './network-apply-read.ts';
57
+ import type { PveRequirements, WithTarget } from './resource.ts';
61
58
 
62
59
  export interface NetworkApplyProps extends WithTarget {
63
60
  /**
@@ -141,92 +138,6 @@ const diff = (news: Input<NetworkApplyProps>) =>
141
138
  return pending === 0 ? ({ action: 'noop' } as const) : ({ action: 'update' } as const);
142
139
  });
143
140
 
144
- /** Stop the deploy, loudly, with the node in the message. ⚠️ A die here is the SAFE outcome. */
145
- const refuse = (node: string, why: string) =>
146
- Effect.die(new Error(`Proxmox.NetworkApply ${node}: ${why}`));
147
-
148
- const apply = (props: NetworkApplyProps) =>
149
- Effect.gen(function* () {
150
- const node = props.node;
151
- const staged = yield* pendingCount(props.target, node);
152
- /**
153
- * ⛔ NOTHING STAGED MEANS NOTHING TO APPLY, AND THIS BRANCH IS A SAFETY PROPERTY RATHER THAN AN
154
- * OPTIMISATION. `reconcile` also runs on CREATE — the first time this resource appears in a
155
- * stack, before `diff` has ever been consulted — and the PVE worker runs `ifreload -a`
156
- * UNCONDITIONALLY, staged file or not. Without this branch, adding `Proxmox.NetworkApply` to
157
- * a stack describing the cluster as it already is would reload networking on every node it
158
- * names, for nothing. Adoption must cost nothing; that is true here in the strongest sense.
159
- */
160
- if (staged === 0) return { node, pending: 0 };
161
-
162
- const before = yield* degradedReason(props.target);
163
- if (before !== undefined) {
164
- return yield* refuse(
165
- node,
166
- `refusing to reload networking on a cluster that is already degraded (${before}). ` +
167
- 'Ceph spans these nodes -- fix the cluster first, then re-run the deploy.',
168
- );
169
- }
170
-
171
- /**
172
- * ⚠️ ONE LEASE FOR BOTH CALLS, VIA `pveWith` RATHER THAN `pve`. Each mint returns a NEW token
173
- * id, and the task-status endpoint only skips its `Sys.Audit` check for the task's OWNER —
174
- * so polling with a fresh mint is a different identity and needs a privilege this role does
175
- * not have. The `provision` lease is 300s and non-renewable, which is far longer than a
176
- * reload and is the reason no renew path is reached for.
177
- */
178
- // ⛔ BEFORE THE MINT AND THE RELOAD. The apply takes no body, so this is the lookup rather
179
- // than a value check: a vendor that moved this endpoint fails here, not mid-ifreload.
180
- yield* guardNetworkApply;
181
- const credential = yield* mint(props.target, 'provision');
182
- const upid = yield* pveWith<string>(props.target, credential, 'PUT', `nodes/${node}/network`);
183
- if (upid === undefined) {
184
- return yield* refuse(
185
- node,
186
- 'the apply returned no UPID. PVE wraps every answer in {"data":...} and can report success ' +
187
- 'on a call that did nothing -- the reload cannot be confirmed, so it is not claimed.',
188
- );
189
- }
190
-
191
- /**
192
- * ⛔ POLLING `pending` INSTEAD OF THE TASK WOULD REPORT SUCCESS BEFORE THE NETWORK WAS TOUCHED.
193
- * MEASURED in the worker above: the `rename` happens FIRST and `ifreload -a` second, so the
194
- * staged file — and therefore `changes`, and therefore `pending` — disappears while the
195
- * reload has not yet started. A provider that watched the count would call a reload that had
196
- * not run, and a reload that then FAILED, a success. The task's exitstatus is the only
197
- * honest answer.
198
- */
199
- const exitstatus = yield* awaitTask(props.target, credential, node, upid);
200
- if (exitstatus !== 'OK') {
201
- return yield* refuse(
202
- node,
203
- `the reload ended "${exitstatus}" (${upid}). Read it from ANOTHER node -- ` +
204
- `\`pvesh get /nodes/${node}/tasks/${upid}/log\` -- and do not apply the next node until ` +
205
- 'this one is understood.',
206
- );
207
- }
208
-
209
- const after = yield* degradedReason(props.target);
210
- if (after !== undefined) {
211
- return yield* refuse(
212
- node,
213
- `the reload reported OK but the cluster is now degraded (${after}). The next ` +
214
- 'node has NOT been applied. Restore this one before continuing -- Ceph runs over these ' +
215
- 'links and a second reload now would take pools below min_size.',
216
- );
217
- }
218
-
219
- const left = yield* pendingCount(props.target, node);
220
- if (left !== 0) {
221
- return yield* refuse(
222
- node,
223
- `the reload reported OK but ${String(left)} staged line(s) remain. The running config does ` +
224
- 'not match the declared one, and saying otherwise is the lie this resource exists to stop.',
225
- );
226
- }
227
- return { node, pending: left };
228
- });
229
-
230
141
  /**
231
142
  * ⛔ HAND-WRITTEN RATHER THAN `pveHandlers`, FOR THE SAME REASON `sdn-apply.ts` IS. The factory's
232
143
  * spec is create/update/delete over ONE object at ONE path, and an apply is not an object: there
@@ -243,7 +154,7 @@ const handlers = {
243
154
  diff: ({ news }: { news: Input<NetworkApplyProps> }) => diff(news),
244
155
  list: () => Effect.succeed([]),
245
156
  read: ({ olds }: { olds: NetworkApplyProps }) => read(olds),
246
- reconcile: ({ news }: { news: NetworkApplyProps }) => apply(news),
157
+ reconcile: ({ news }: { news: NetworkApplyProps }) => applyNetwork(news),
247
158
  };
248
159
 
249
160
  export const ProxmoxNetworkApplyProvider = () =>
@@ -139,6 +139,30 @@ describe('only the measured "interface does not exist" 400 means absent', () =>
139
139
  });
140
140
  });
141
141
 
142
+ /**
143
+ * ⛔ THE RETAG REGRESSION'S OWN TEST — RED-TEAM FINDING, 2026-09-25: `@distilled.cloud/proxmox`
144
+ * retagged this exact 400 from the generic `ParameterVerificationFailed` to a specific
145
+ * `NetworkInterfaceNotFound` (`nodes.ts`), and `readInterfaceOrFail`'s `catchTag` still named
146
+ * the OLD tag — so on an already-adopted row (this is `diff`'s and `verify --all`'s `read`'s
147
+ * `output !== undefined` branch, the non-folding one) the absence signal went UNCAUGHT and
148
+ * threw straight through `engine.verify`, instead of resolving a `diff`/`missing` report.
149
+ * Proven by temporarily reverting the `catchTag` to `'ParameterVerificationFailed'`: only this
150
+ * test fails, with an unhandled `NetworkInterfaceNotFound`.
151
+ */
152
+ test('an already-adopted interface whose live GET now answers genuinely absent resolves via diff as missing, never throws', async () => {
153
+ const fake = clusterOverInterface('n2', 'vmbr0', live);
154
+ await withoutBao(async () => {
155
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
156
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
157
+ fake.setAnswer('genuinely-absent');
158
+ const report = await engine.verify(declare(), { all: true });
159
+ // ⛔ `update` IS THE RIGHT ANSWER HERE, NOT A THROW: this resource's `diff` treats a
160
+ // vanished interface as something `reconcile` recreates (node-network.ts's `diff`,
161
+ // `live === undefined` branch), the same as any other drifted-away row.
162
+ expect(report.rows[0]).toMatchObject({ diff: 'update' });
163
+ });
164
+ });
165
+
142
166
  /**
143
167
  * ★ WHY `read` FOLDS AT ALL, SPELLED OUT ON A BRAND NEW DECLARATION. Plan.ts's cold-start
144
168
  * adoption probe (no prior Alchemy state for this declaration) calls `provider.read` with no
@@ -156,14 +156,17 @@ const attributesOf = (
156
156
  * `{"errors":{"iface":"interface does not exist"},"data":null,"message":"Parameter verification
157
157
  * failed.\n"}` at HTTP 400 — a THIRD shape, different from every other migrated family's
158
158
  * measured 500. `@distilled.cloud/proxmox` types this as a genuine typed error,
159
- * `ParameterVerificationFailed` (`errors.ts`), carrying the per-field `errors` object rather
160
- * than collapsing it to an opaque `BadRequest` — PVE's OWN 400 is never one shape (it is
161
- * whatever the endpoint's parameter verification rejected), so `errors.ts`'s own header spells
162
- * out why this ONE typed error is global rather than per-operation.
163
- * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason, a
164
- * permission gap, a network blip, or ANY OTHER `ParameterVerificationFailed` reason is never
165
- * read as "this interface is not there" — `diff` calls this directly and propagates every one
166
- * of them loudly.
159
+ * `NetworkInterfaceNotFound` (`nodes.ts`, generated from `patches/nodes/network-errors.json`),
160
+ * attached ONLY to `getNodeNetwork`'s error union and matched by the SDK's own
161
+ * `errorEnvelope`/`matchTypedError` against the EXACT sole-field body `errors.iface ===
162
+ * "interface does not exist"` — the precision the code here used to check by hand
163
+ * (`error.errors['iface'] === '…'`) is now the SDK's own matcher contract, not this file's.
164
+ * Any 400 that fails that exact match (a different field, a different message, more than one
165
+ * field) decodes as the generic `ParameterVerificationFailed` instead, never this tag.
166
+ * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason
167
+ * (`ParameterVerificationFailed`), a permission gap, a network blip, or any other failure is
168
+ * never read as "this interface is not there" — `diff` calls this directly and propagates
169
+ * every one of them loudly.
167
170
  */
168
171
  export const readInterfaceOrFail = (props: NodeNetworkProps) =>
169
172
  readOrUnreadable(
@@ -172,13 +175,7 @@ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
172
175
  'read',
173
176
  false,
174
177
  nodes.getNodeNetwork({ iface: props.iface, node: props.node }),
175
- ).pipe(
176
- Effect.catchTag('ParameterVerificationFailed', (error) =>
177
- error.errors['iface'] === 'interface does not exist'
178
- ? Effect.succeed(undefined)
179
- : Effect.fail(error),
180
- ),
181
- ),
178
+ ).pipe(Effect.catchTag('NetworkInterfaceNotFound', () => Effect.succeed(undefined))),
182
179
  ).pipe(
183
180
  Effect.map((live) => {
184
181
  if (live === UNREADABLE) return UNREADABLE;
@@ -198,8 +195,9 @@ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
198
195
  * interrupted-create recovery, and Apply.ts's delete recovery, all call `read` with
199
196
  * `output: undefined` and NO catch of their own (`Plan.ts` v2.0.0-beta.79 ~line 1308) — and
200
197
  * Plan.ts fails the WHOLE PLAN, every other resource included, if that call throws. A malformed
201
- * `iface` in ONE brand-new declaration (a DIFFERENT `ParameterVerificationFailed` than the
202
- * absence one) would abort `bun run plan` for the entire stack under the precise-only design,
198
+ * `iface` in ONE brand-new declaration (a `ParameterVerificationFailed`, DIFFERENT from the
199
+ * `NetworkInterfaceNotFound` absence tag) would abort `bun run plan` for the entire stack under
200
+ * the precise-only design,
203
201
  * where every other migrated family instead defers that same malformed declaration to a scoped,
204
202
  * loud refusal at apply. This restores that same fold for those three call sites.
205
203
  */
@@ -0,0 +1,7 @@
1
+ /** A QEMU write or observation this provider will not turn into a create or a delete. */
2
+ export class QemuRefusedError extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = 'QemuRefusedError';
6
+ }
7
+ }
@@ -0,0 +1,15 @@
1
+ /** QEMU create and update forms. Path identity stays out of the body. */
2
+ import type { VmProps } from './qemu.ts';
3
+
4
+ /** Fields both create and update send. `net0` is optional and is not compared. */
5
+ export const shape = (props: VmProps) => ({
6
+ cores: String(props.cores ?? 1),
7
+ memory: String(props.memory ?? 512),
8
+ name: props.name ?? `vm${String(props.vmid)}`,
9
+ onboot: props.onboot === true ? '1' : '0',
10
+ sockets: String(props.sockets ?? 1),
11
+ ...(props.net0 === undefined ? {} : { net0: props.net0 }),
12
+ });
13
+
14
+ /** ★ Exported so the constraint proof can run the REAL create form, not a retyped copy of it. */
15
+ export const createForm = (props: VmProps) => ({ ...shape(props), vmid: String(props.vmid) });
@@ -0,0 +1,171 @@
1
+ /** QEMU engine tests over a stub fetch. No live host is contacted. */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import * as RemovalPolicy from 'alchemy/RemovalPolicy';
4
+ import * as Effect from 'effect/Effect';
5
+ import * as Layer from 'effect/Layer';
6
+ import { engineOver } from '../verify/fake-engine.ts';
7
+ import { FAKE_TARGET, type FakePve, fakePve, withoutBao } from './fake-pve.ts';
8
+ import { ProxmoxVm, ProxmoxVmProvider } from './qemu.ts';
9
+
10
+ const NODE = 'pve1';
11
+ const VMID = 150;
12
+ const props = { target: FAKE_TARGET, node: NODE, vmid: VMID };
13
+ const digest = 'a'.repeat(40);
14
+ const live = { digest, name: 'vm150', memory: '512', cores: 1, sockets: 1, onboot: 0 };
15
+ const missing = () =>
16
+ Response.json(
17
+ {
18
+ data: null,
19
+ message: `Configuration file 'nodes/${NODE}/qemu-server/${String(VMID)}.conf' does not exist\n`,
20
+ },
21
+ { status: 500 },
22
+ );
23
+ const UPID = `UPID:${NODE}:00000001:0:0:qmcreate:${String(VMID)}:test@pve!fake:`;
24
+
25
+ const engine = (fake: FakePve) =>
26
+ engineOver(ProxmoxVmProvider().pipe(Layer.provideMerge(fake.layer)));
27
+
28
+ const isIndex = (call: { method: string; path: string }) =>
29
+ call.method === 'GET' && call.path.startsWith('cluster/resources');
30
+
31
+ describe('Proxmox.Vm distilled transport', () => {
32
+ test('adoption and a second plan write nothing', async () => {
33
+ const fake = fakePve((call) => (call.method === 'GET' ? live : null));
34
+ await withoutBao(async () => {
35
+ const stack = engine(fake);
36
+ expect(await stack.deploy(ProxmoxVm('row', props))).toEqual({ row: 'adopted' });
37
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows).toEqual([
38
+ expect.objectContaining({ diff: 'noop', ok: true }),
39
+ ]);
40
+ });
41
+ expect(fake.writes()).toEqual([]);
42
+ });
43
+
44
+ test('absence creates on the collection route, waits, then settles', async () => {
45
+ let exists = false;
46
+ const fake = fakePve((call) => {
47
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
48
+ return { status: 'stopped', exitstatus: 'OK' };
49
+ }
50
+ if (isIndex(call)) return [];
51
+ if (call.method === 'GET') return exists ? live : missing();
52
+ exists = true;
53
+ return UPID;
54
+ });
55
+ await withoutBao(async () => {
56
+ const stack = engine(fake);
57
+ await stack.deploy(
58
+ ProxmoxVm('row', { ...props, net0: 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0' }),
59
+ );
60
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
61
+ diff: 'noop',
62
+ });
63
+ });
64
+ expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
65
+ expect(fake.calls.find((call) => call.method === 'POST')?.form['net0']).toBe(
66
+ 'virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0',
67
+ );
68
+ expect(fake.calls.some((call) => call.path.includes('/tasks/'))).toBe(true);
69
+ });
70
+
71
+ test('a transitional task status keeps polling instead of aborting the create', async () => {
72
+ // ⚠️ `GetNodeTaskStatusResponseStatus` types `status` as the closed union
73
+ // "running" | "stopped", but the runtime validator is `S.String` (distilled-proxmox's
74
+ // nodes.ts) -- a status word this endpoint's contract was never proven to exclude must
75
+ // keep polling rather than abort the whole create, since only "stopped" ends the task
76
+ // (2026-09-25 adversarial review; matches lxc-task.ts's own regression test).
77
+ let exists = false;
78
+ let polls = 0;
79
+ const fake = fakePve((call) => {
80
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
81
+ polls++;
82
+ return polls === 1 ? { status: 'starting' } : { status: 'stopped', exitstatus: 'OK' };
83
+ }
84
+ if (isIndex(call)) return [];
85
+ if (call.method === 'GET') return exists ? live : missing();
86
+ exists = true;
87
+ return UPID;
88
+ });
89
+ await withoutBao(async () => {
90
+ const stack = engine(fake);
91
+ await stack.deploy(ProxmoxVm('row', props));
92
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
93
+ diff: 'noop',
94
+ });
95
+ });
96
+ expect(polls).toBe(2);
97
+ expect(fake.writes()).toEqual([`POST nodes/${NODE}/qemu`]);
98
+ });
99
+
100
+ test('a vmid held by a container is not created', async () => {
101
+ const fake = fakePve((call) => {
102
+ if (isIndex(call)) {
103
+ return [{ id: 'lxc/150', type: 'lxc', vmid: 150, node: 'pve2' }];
104
+ }
105
+ if (call.method === 'GET') return missing();
106
+ return UPID;
107
+ });
108
+ await withoutBao(async () => {
109
+ await expect(engine(fake).deploy(ProxmoxVm('row', props))).rejects.toThrow(
110
+ /container|lxc|pve2/,
111
+ );
112
+ });
113
+ expect(fake.writes()).toEqual([]);
114
+ });
115
+
116
+ test('drift updates the config route and the next plan is a no-op', async () => {
117
+ let current: unknown = { ...live, memory: '256' };
118
+ const fake = fakePve((call) => {
119
+ if (call.method === 'GET') return current;
120
+ current = live;
121
+ return null;
122
+ });
123
+ await withoutBao(async () => {
124
+ const stack = engine(fake);
125
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
126
+ diff: 'update',
127
+ });
128
+ await stack.deploy(ProxmoxVm('row', props));
129
+ expect((await stack.verify(ProxmoxVm('row', props), { all: true })).rows[0]).toMatchObject({
130
+ diff: 'noop',
131
+ });
132
+ });
133
+ expect(fake.writes()).toEqual([`PUT nodes/${NODE}/qemu/${String(VMID)}/config`]);
134
+ });
135
+
136
+ test('unrelated read failures and a digest-less body do not write', async () => {
137
+ for (const body of [
138
+ Response.json({ data: null, message: 'unrelated failure' }, { status: 500 }),
139
+ Response.json({ data: null, message: 'permission denied' }, { status: 403 }),
140
+ { name: 'vm150' },
141
+ ]) {
142
+ const fake = fakePve(() => body);
143
+ await withoutBao(async () => {
144
+ await expect(engine(fake).deploy(ProxmoxVm('row', props))).rejects.toBeDefined();
145
+ });
146
+ expect(fake.writes()).toEqual([]);
147
+ }
148
+ });
149
+
150
+ test('destroy deletes the guest route and waits for its task', async () => {
151
+ let exists = true;
152
+ const fake = fakePve((call) => {
153
+ if (call.path.includes('/tasks/') && call.path.endsWith('/status')) {
154
+ return { status: 'stopped', exitstatus: 'OK' };
155
+ }
156
+ if (call.method === 'GET') return exists ? live : missing();
157
+ if (call.method === 'DELETE') {
158
+ exists = false;
159
+ return UPID;
160
+ }
161
+ return null;
162
+ });
163
+ await withoutBao(async () => {
164
+ const stack = engine(fake);
165
+ await stack.deploy(ProxmoxVm('row', props).pipe(RemovalPolicy.destroy()));
166
+ await stack.deploy(Effect.void);
167
+ });
168
+ expect(fake.writes()).toEqual([`DELETE nodes/${NODE}/qemu/${String(VMID)}`]);
169
+ expect(fake.writes().some((line) => line.includes('/config'))).toBe(false);
170
+ });
171
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * QEMU lifecycle on named operations.
3
+ * ⛔ GET and PUT use `/nodes/{node}/qemu/{vmid}/config`. DELETE uses `/nodes/{node}/qemu/{vmid}`.
4
+ * qemu-server Qemu.pm names that second route `destroy_vm`. Sending DELETE to the config
5
+ * route does not destroy the guest.
6
+ */
7
+ import { isResolved } from 'alchemy/Diff';
8
+ import type { Input } from 'alchemy/Input';
9
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
10
+ import * as Effect from 'effect/Effect';
11
+ import { runPve } from './distilled-pve.ts';
12
+ import { createForm, shape } from './qemu-form.ts';
13
+ import { QemuRefusedError } from './qemu-errors.ts';
14
+ import { readVm } from './qemu-read.ts';
15
+ import { qemuTask } from './qemu-task.ts';
16
+ import type { VmAttributes, VmProps } from './qemu.ts';
17
+ import type { PveSpec } from './resource-spec.ts';
18
+ import { specGuards } from './resource-guard.ts';
19
+ import { formToSend } from './update-guard.ts';
20
+ import { int } from './values.ts';
21
+
22
+ const CREATE_POLLS = 180;
23
+ const DELETE_POLLS = 60;
24
+
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 : '',
38
+ node: props.node,
39
+ onboot: live.onboot === 1 || live.onboot === true,
40
+ sockets: int(live.sockets, 1),
41
+ vmid: props.vmid,
42
+ });
43
+
44
+ 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);
50
+
51
+ const spec = {
52
+ attributes: attributesOf,
53
+ collection: (props: VmProps) => `nodes/${props.node}/qemu`,
54
+ createForm,
55
+ endpoint: {
56
+ create: 'pve:POST /nodes/{node}/qemu',
57
+ update: 'pve:PUT /nodes/{node}/qemu/{vmid}/config',
58
+ },
59
+ matches,
60
+ path: (props: VmProps) => `nodes/${props.node}/qemu/${String(props.vmid)}/config`,
61
+ updateForm: shape,
62
+ } satisfies PveSpec<VmProps, VmAttributes>;
63
+
64
+ const { guardCreate, guardUpdate } = specGuards(spec);
65
+
66
+ export const readQemu = (props: VmProps) =>
67
+ readVm(props).pipe(
68
+ Effect.map((live) => (live === undefined ? undefined : attributesOf(live, props))),
69
+ );
70
+
71
+ /** No purge, no skiplock, no destroy-unreferenced-disks. A missing config is already gone. */
72
+ export const deleteQemu = (props: VmProps) =>
73
+ qemuTask(
74
+ props,
75
+ nodes.deleteNodeQemu({ node: props.node, vmid: String(props.vmid) }),
76
+ `destroy VM ${String(props.vmid)}`,
77
+ DELETE_POLLS,
78
+ ).pipe(Effect.catchTag('QemuConfigNotFound', () => Effect.void));
79
+
80
+ export const qemuHandlers = {
81
+ list: () => Effect.succeed([]),
82
+ read: ({ olds }: { olds: VmProps }) => readQemu(olds),
83
+ diff: Effect.fn(function* ({
84
+ news,
85
+ output,
86
+ }: {
87
+ news: Input<VmProps>;
88
+ output: VmAttributes | undefined;
89
+ }) {
90
+ if (!isResolved(news)) return undefined;
91
+ yield* guardCreate(news, output === undefined);
92
+ yield* guardUpdate(news);
93
+ if (output === undefined) return undefined;
94
+ const live = yield* readQemu(news);
95
+ if (live === undefined) {
96
+ yield* guardCreate(news, true);
97
+ return { action: 'update' } as const;
98
+ }
99
+ return { action: spec.matches(live, news) ? 'noop' : 'update' } as const;
100
+ }),
101
+ reconcile: Effect.fn(function* ({ news }: { news: VmProps }) {
102
+ const live = yield* readQemu(news);
103
+ yield* guardCreate(news, live === undefined);
104
+ yield* guardUpdate(news);
105
+ if (live === undefined) {
106
+ yield* qemuTask(
107
+ news,
108
+ nodes.createNodeQemu({ ...createForm(news), node: news.node }),
109
+ `create VM ${String(news.vmid)}`,
110
+ CREATE_POLLS,
111
+ );
112
+ } else {
113
+ const form = formToSend(spec.matches, live, news, spec.updateForm(news));
114
+ if (form !== undefined) {
115
+ yield* runPve(
116
+ news.target,
117
+ 'provision',
118
+ true,
119
+ nodes.putNodeQemuConfig({ ...form, node: news.node, vmid: String(news.vmid) }),
120
+ );
121
+ }
122
+ }
123
+ const after = yield* readQemu(news);
124
+ if (after === undefined) {
125
+ return yield* Effect.fail(
126
+ new QemuRefusedError(
127
+ `${spec.path(news)}: write returned success but the VM is still absent`,
128
+ ),
129
+ );
130
+ }
131
+ return after;
132
+ }),
133
+ delete: ({ olds }: { olds: VmProps }) => deleteQemu(olds),
134
+ };