@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.
- package/dist/openbao/auth-role-form.d.ts +0 -6
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-identity.d.ts +11 -0
- package/dist/openbao/auth-role-identity.d.ts.map +1 -0
- package/dist/openbao/auth-role-wire.d.ts +23 -0
- package/dist/openbao/auth-role-wire.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/bao-http.d.ts +34 -13
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/distilled.d.ts +25 -0
- package/dist/openbao/distilled.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +5 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +358 -305
- package/dist/openbao/index.js.map +13 -9
- package/dist/openbao/policy-identity.d.ts +12 -0
- package/dist/openbao/policy-identity.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +9 -8
- package/dist/openbao/policy-wire.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +5 -0
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-config.d.ts +36 -0
- package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +3 -1
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
- package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +2 -8
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/index.js +795 -526
- package/dist/proxmox/index.js.map +24 -11
- package/dist/proxmox/lxc-errors.d.ts +12 -0
- package/dist/proxmox/lxc-errors.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/lxc-read.d.ts +4 -0
- package/dist/proxmox/lxc-read.d.ts.map +1 -0
- package/dist/proxmox/lxc-task.d.ts +15 -0
- package/dist/proxmox/lxc-task.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +7 -14
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply-run.d.ts +7 -0
- package/dist/proxmox/network-apply-run.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +2 -3
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/dist/proxmox/qemu-errors.d.ts +5 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -0
- package/dist/proxmox/qemu-form.d.ts +22 -0
- package/dist/proxmox/qemu-form.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/qemu-read.d.ts +5 -0
- package/dist/proxmox/qemu-read.d.ts.map +1 -0
- package/dist/proxmox/qemu-task.d.ts +9 -0
- package/dist/proxmox/qemu-task.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +9 -14
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
- package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
- package/dist/proxmox/replication-job-config.d.ts +56 -0
- package/dist/proxmox/replication-job-config.d.ts.map +1 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +5 -6
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/index.js +3 -2
- package/dist/release/index.js.map +3 -3
- package/docs/proxmox-lxc-distilled.md +56 -0
- package/docs/proxmox-replication-firewall-distilled.md +53 -0
- package/docs/proxmox-sdk-migration.md +23 -18
- package/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/openbao/auth-role-form.test.ts +11 -9
- package/src/openbao/auth-role-form.ts +0 -19
- package/src/openbao/auth-role-identity.ts +24 -0
- package/src/openbao/auth-role-sdk.test.ts +155 -0
- package/src/openbao/auth-role-wire.test.ts +128 -0
- package/src/openbao/auth-role-wire.ts +89 -0
- package/src/openbao/auth-role.ts +9 -9
- package/src/openbao/bao-http.ts +9 -7
- package/src/openbao/distilled-retry.test.ts +164 -0
- package/src/openbao/distilled.test.ts +146 -0
- package/src/openbao/distilled.ts +88 -0
- package/src/openbao/fake-engines-roles.ts +9 -2
- package/src/openbao/policy-distilled.test.ts +98 -0
- package/src/openbao/policy-identity.ts +24 -0
- package/src/openbao/policy-rename.test.ts +2 -2
- package/src/openbao/policy-wire.test.ts +5 -5
- package/src/openbao/policy-wire.ts +31 -11
- package/src/openbao/policy.ts +10 -13
- package/src/openbao/rename.ts +17 -1
- package/src/proxmox/firewall-alias-config.ts +39 -0
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
- package/src/proxmox/firewall-alias.ts +4 -67
- package/src/proxmox/lxc-create.test.ts +4 -1
- package/src/proxmox/lxc-distilled.test.ts +239 -0
- package/src/proxmox/lxc-errors.ts +9 -0
- package/src/proxmox/lxc-lifecycle.ts +31 -113
- package/src/proxmox/lxc-read.test.ts +29 -6
- package/src/proxmox/lxc-read.ts +88 -0
- package/src/proxmox/lxc-task.ts +71 -0
- package/src/proxmox/network-apply-distilled.test.ts +76 -0
- package/src/proxmox/network-apply-read.ts +18 -39
- package/src/proxmox/network-apply-run.ts +97 -0
- package/src/proxmox/network-apply.ts +5 -94
- package/src/proxmox/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
- package/src/proxmox/qemu-errors.ts +7 -0
- package/src/proxmox/qemu-form.ts +15 -0
- package/src/proxmox/qemu-lifecycle.test.ts +171 -0
- package/src/proxmox/qemu-lifecycle.ts +134 -0
- package/src/proxmox/qemu-read.ts +73 -0
- package/src/proxmox/qemu-task.ts +64 -0
- package/src/proxmox/qemu.ts +11 -44
- package/src/proxmox/replication-firewall-fixtures.ts +71 -0
- package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
- package/src/proxmox/replication-firewall-wire.test.ts +173 -0
- package/src/proxmox/replication-job-config.ts +79 -0
- package/src/proxmox/replication-job-lifecycle.ts +113 -0
- package/src/proxmox/replication-job.ts +7 -84
- 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
|
-
*
|
|
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 {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
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 }) =>
|
|
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
|
-
* `
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
|
202
|
-
* absence
|
|
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,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
|
+
};
|