@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
|
@@ -1,50 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The calls a `Proxmox.Lxc` makes: read one config, create, update in place, grow, delete.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 403 or a node that did not answer plans a CREATE over a running container. PVE answers a
|
|
7
|
-
* missing guest with HTTP 500, `Configuration file 'nodes/<node>/lxc/<vmid>.conf' does not
|
|
8
|
-
* exist` (pve-guest-common `AbstractConfig::load_config`), so only a 500 may mean absent — and
|
|
9
|
-
* even then only after the cluster says so, below. Anything else fails the plan.
|
|
10
|
-
*
|
|
11
|
-
* ⛔ "ABSENT" IS DECIDED BY THE WHOLE CLUSTER, NOT BY THE ERROR TEXT. A config lives under its
|
|
12
|
-
* node, so a guest migrated to another node — or a QEMU VM holding the same cluster-wide vmid —
|
|
13
|
-
* also reads as missing at the declared path. `GET /cluster/resources?type=vm` settles it: the
|
|
14
|
-
* vmid nowhere is absent; the vmid anywhere else FAILS the read with where it is; the vmid on
|
|
15
|
-
* the declared node fails with PVE's own error. ★ Keyed on the cluster rather than on matching
|
|
16
|
-
* the message, so a PVE that words it differently still cannot turn into a create.
|
|
4
|
+
* ★ Discovery lives in lxc-read.ts; it proves precise absence with one read credential.
|
|
5
|
+
* Writes use named distilled operations, and lxc-task.ts preserves task ownership and bounds.
|
|
17
6
|
*
|
|
18
7
|
* ⚠️ CREATE, RESIZE AND DELETE ARE ASYNCHRONOUS; A CONFIG PUT IS NOT. The first three answer a
|
|
19
8
|
* UPID the moment PVE forks the worker (`NodesNodeLxcPostReturn`, `…ResizePutReturn`,
|
|
20
9
|
* `…VmidDeleteReturn` are all `string`), so each waits for its task's exitstatus. `PUT …/config`
|
|
21
10
|
* answers `null` after writing (`…ConfigPutReturn`), so it needs no wait.
|
|
22
|
-
* ⛔ EACH TASK IS POLLED WITH THE CREDENTIAL THAT STARTED IT — see `
|
|
11
|
+
* ⛔ EACH TASK IS POLLED WITH THE CREDENTIAL THAT STARTED IT — see `lxcTask`: only the owner
|
|
23
12
|
* skips `Sys.Audit`. A fresh `provision` mint per write gives the full 300s lease a create needs;
|
|
24
13
|
* a cached lease can be handed out with 60s left (lease-cache.ts).
|
|
25
14
|
*/
|
|
26
15
|
import * as Effect from 'effect/Effect';
|
|
27
|
-
import
|
|
16
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
17
|
+
import { runPve } from './distilled-pve.ts';
|
|
18
|
+
import { LxcRefusedError, type LxcWhere } from './lxc-errors.ts';
|
|
19
|
+
import { readLive } from './lxc-read.ts';
|
|
20
|
+
import { lxcTask } from './lxc-task.ts';
|
|
28
21
|
import { guardForm } from './constraint-guard.ts';
|
|
29
22
|
import type { EndpointKey } from './constraints.ts';
|
|
30
|
-
import type { PveTarget } from './credentials.ts';
|
|
31
|
-
import { mint } from './mint.ts';
|
|
32
23
|
import { createForm } from './lxc-create-form.ts';
|
|
33
24
|
import { type LxcChange, judge } from './lxc-judge.ts';
|
|
34
25
|
import type { LxcAttributes, LxcProps } from './lxc-props.ts';
|
|
35
26
|
import { rawKeysOf, storedConfig } from './lxc-props.ts';
|
|
36
|
-
import { awaitTask } from './network-apply-read.ts';
|
|
37
27
|
import { text, withClears } from './values.ts';
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
export
|
|
41
|
-
override readonly name = 'LxcRefusedError';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Where one guest lives. Its identity, and nothing else. */
|
|
45
|
-
export type LxcWhere = { readonly target: PveTarget; readonly node: string; readonly vmid: number };
|
|
46
|
-
|
|
47
|
-
export const guestPath = (where: LxcWhere) => `nodes/${where.node}/lxc/${String(where.vmid)}`;
|
|
29
|
+
export { LxcRefusedError, type LxcWhere } from './lxc-errors.ts';
|
|
30
|
+
export { readLive } from './lxc-read.ts';
|
|
48
31
|
|
|
49
32
|
/**
|
|
50
33
|
* The three vendor endpoints a guest is written through, as the generated tables key them.
|
|
@@ -70,52 +53,6 @@ const LXC_ENDPOINTS = {
|
|
|
70
53
|
const CREATE_POLLS = 180;
|
|
71
54
|
const TASK_POLLS = 60;
|
|
72
55
|
|
|
73
|
-
const isServerError = (error: unknown): error is PveError =>
|
|
74
|
-
error instanceof PveError && error.status === 500;
|
|
75
|
-
|
|
76
|
-
/** One `GET /cluster/resources?type=vm` row. */
|
|
77
|
-
type GuestRow = { readonly vmid?: unknown; readonly node?: unknown; readonly type?: unknown };
|
|
78
|
-
|
|
79
|
-
/** The live config, or undefined when no guest holds this vmid anywhere in the cluster. */
|
|
80
|
-
export const readLive = (where: LxcWhere) =>
|
|
81
|
-
Effect.gen(function* () {
|
|
82
|
-
const answer = yield* pve<Record<string, unknown> | null>(
|
|
83
|
-
where.target,
|
|
84
|
-
'read',
|
|
85
|
-
'GET',
|
|
86
|
-
`${guestPath(where)}/config`,
|
|
87
|
-
).pipe(
|
|
88
|
-
Effect.map((data) => ({ data, error: undefined })),
|
|
89
|
-
Effect.catchIf(isServerError, (error) => Effect.succeed({ data: undefined, error })),
|
|
90
|
-
);
|
|
91
|
-
if (answer.data !== undefined && answer.data !== null) return answer.data;
|
|
92
|
-
const rows = yield* pve<readonly GuestRow[]>(
|
|
93
|
-
where.target,
|
|
94
|
-
'read',
|
|
95
|
-
'GET',
|
|
96
|
-
'cluster/resources?type=vm',
|
|
97
|
-
);
|
|
98
|
-
// ⚠️ `Number()`, NOT `===` ON THE RAW VALUE: a vmid that came back as `"900"` would otherwise
|
|
99
|
-
// match nothing, and "listed nowhere" is the one answer that turns into a create.
|
|
100
|
-
const found = (rows ?? []).find((row) => Number(row.vmid) === where.vmid);
|
|
101
|
-
if (found !== undefined && found.node === where.node && found.type === 'lxc') {
|
|
102
|
-
return yield* Effect.fail(
|
|
103
|
-
answer.error ??
|
|
104
|
-
new LxcRefusedError(`CT ${String(where.vmid)} is listed but its config read empty.`),
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
if (found !== undefined) {
|
|
108
|
-
return yield* Effect.fail(
|
|
109
|
-
new LxcRefusedError(
|
|
110
|
-
`vmid ${String(where.vmid)} is a ${text(found.type, 'guest')} on ` +
|
|
111
|
-
`${text(found.node, '?')}, not a container on ${where.node}. A migration is not an ` +
|
|
112
|
-
'update and vmids are cluster-wide: declare the node it is on, or pick a free vmid.',
|
|
113
|
-
),
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
return undefined;
|
|
117
|
-
});
|
|
118
|
-
|
|
119
56
|
export const attributesOf = (where: LxcWhere, live: Record<string, unknown>): LxcAttributes => ({
|
|
120
57
|
config: storedConfig(live),
|
|
121
58
|
node: where.node,
|
|
@@ -123,36 +60,6 @@ export const attributesOf = (where: LxcWhere, live: Record<string, unknown>): Lx
|
|
|
123
60
|
vmid: where.vmid,
|
|
124
61
|
});
|
|
125
62
|
|
|
126
|
-
/** Run one task-starting call and wait for it on the same credential. Fails unless `OK`. */
|
|
127
|
-
const task = (
|
|
128
|
-
where: LxcWhere,
|
|
129
|
-
method: 'POST' | 'PUT' | 'DELETE',
|
|
130
|
-
path: string,
|
|
131
|
-
what: string,
|
|
132
|
-
polls: number,
|
|
133
|
-
form?: Record<string, string>,
|
|
134
|
-
) =>
|
|
135
|
-
Effect.gen(function* () {
|
|
136
|
-
const credential = yield* mint(where.target, 'provision');
|
|
137
|
-
const upid = text(yield* pveWith<string>(where.target, credential, method, path, form));
|
|
138
|
-
if (upid === '') {
|
|
139
|
-
return yield* Effect.fail(
|
|
140
|
-
new LxcRefusedError(
|
|
141
|
-
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
142
|
-
),
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
const exit = yield* awaitTask(where.target, credential, where.node, upid, polls);
|
|
146
|
-
if (exit !== 'OK') {
|
|
147
|
-
return yield* Effect.fail(
|
|
148
|
-
new LxcRefusedError(
|
|
149
|
-
`${what}: task ${upid} ended "${exit}". Read \`pvesh get /nodes/${where.node}/tasks/` +
|
|
150
|
-
`${upid}/log\` before running the deploy again.`,
|
|
151
|
-
),
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
63
|
/** POST the create and wait for the template to unpack. ⛔ Refusals are the caller's to check. */
|
|
157
64
|
export const createGuest = (props: LxcProps) =>
|
|
158
65
|
Effect.gen(function* () {
|
|
@@ -165,13 +72,16 @@ export const createGuest = (props: LxcProps) =>
|
|
|
165
72
|
* knew: `cores` 1..8192, `memory` ≥ 16, `hostname` as a dns-name, `tags`, `startup`.
|
|
166
73
|
*/
|
|
167
74
|
yield* guardForm(LXC_ENDPOINTS.create, form, true);
|
|
168
|
-
return yield*
|
|
75
|
+
return yield* lxcTask(
|
|
169
76
|
props,
|
|
170
|
-
|
|
171
|
-
|
|
77
|
+
nodes.createNodeLxc({
|
|
78
|
+
...form,
|
|
79
|
+
node: props.node,
|
|
80
|
+
vmid: String(props.vmid),
|
|
81
|
+
ostemplate: text(form['ostemplate']),
|
|
82
|
+
}),
|
|
172
83
|
`create CT ${String(props.vmid)}`,
|
|
173
84
|
CREATE_POLLS,
|
|
174
|
-
form,
|
|
175
85
|
);
|
|
176
86
|
});
|
|
177
87
|
|
|
@@ -194,20 +104,23 @@ export const updateGuest = (props: LxcProps, change: LxcChange, digest: string)
|
|
|
194
104
|
);
|
|
195
105
|
/** ⚠️ NO PRESENCE: this form is partial by construction — it is `judge`'s answer. */
|
|
196
106
|
yield* guardForm(LXC_ENDPOINTS.update, form, false);
|
|
197
|
-
yield*
|
|
107
|
+
yield* runPve(
|
|
108
|
+
props.target,
|
|
109
|
+
'provision',
|
|
110
|
+
true,
|
|
111
|
+
nodes.putNodeLxcConfig({ ...form, node: props.node, vmid: String(props.vmid) }),
|
|
112
|
+
);
|
|
198
113
|
}
|
|
199
114
|
for (const grow of change.resize) {
|
|
200
115
|
const grown = { disk: grow.disk, size: grow.size };
|
|
201
116
|
// ⛔ WITH PRESENCE: `disk` and `size` are both required and both always sent, so this is a
|
|
202
117
|
// value check over PVE's own `+<n>` size pattern and its `rootfs|mp0..mp255` enum.
|
|
203
118
|
yield* guardForm(LXC_ENDPOINTS.resize, grown, true);
|
|
204
|
-
yield*
|
|
119
|
+
yield* lxcTask(
|
|
205
120
|
props,
|
|
206
|
-
|
|
207
|
-
`${guestPath(props)}/resize`,
|
|
121
|
+
nodes.putNodeLxcResize({ ...grown, node: props.node, vmid: String(props.vmid) }),
|
|
208
122
|
`grow ${grow.disk} of CT ${String(props.vmid)} to ${grow.size}`,
|
|
209
123
|
TASK_POLLS,
|
|
210
|
-
grown,
|
|
211
124
|
);
|
|
212
125
|
}
|
|
213
126
|
});
|
|
@@ -236,5 +149,10 @@ export const destroyGuest = (where: LxcWhere, declared: LxcProps) =>
|
|
|
236
149
|
),
|
|
237
150
|
);
|
|
238
151
|
}
|
|
239
|
-
yield*
|
|
152
|
+
yield* lxcTask(
|
|
153
|
+
where,
|
|
154
|
+
nodes.deleteNodeLxc({ node: where.node, vmid: String(where.vmid) }),
|
|
155
|
+
`destroy CT ${String(where.vmid)}`,
|
|
156
|
+
TASK_POLLS,
|
|
157
|
+
);
|
|
240
158
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* When a container read counts as "absent" — the question that decides between adopt and create.
|
|
3
3
|
*
|
|
4
|
-
* ⛔ WHAT THESE PIN: only
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* ⛔ WHAT THESE PIN: only the SDK's exact missing-config tag may consult the same credential's
|
|
5
|
+
* cluster index for absence. A 403 or any other 500 FAILS the plan with PVE's own error;
|
|
6
|
+
* nothing is written in any case. resource.ts's fold of every error
|
|
7
7
|
* into "absent" is exactly what this resource must not inherit — under it, a lease without
|
|
8
8
|
* VM.Audit plans a CREATE over a running guest.
|
|
9
9
|
*/
|
|
@@ -31,11 +31,14 @@ const declared = (vmid: number) =>
|
|
|
31
31
|
}).pipe(adopt(true));
|
|
32
32
|
|
|
33
33
|
describe('absent means the cluster does not list the vmid', () => {
|
|
34
|
-
test('a 500 with no message
|
|
34
|
+
test('a 500 with no message never uses a filtered empty index to authorize creation', async () => {
|
|
35
35
|
fake = fakePve();
|
|
36
36
|
fake.configErrors.set(`${NODE}/150`, { body: { data: null }, status: 500 });
|
|
37
37
|
const run = await lxcEngine(fake).plan(declared(150));
|
|
38
|
-
expect(run).
|
|
38
|
+
expect(run.failure).toContain('InternalServerError');
|
|
39
|
+
expect(run.actions).toEqual({});
|
|
40
|
+
expect(writesOf(fake)).toEqual([]);
|
|
41
|
+
expect(fake.seen.some((call) => call.path.endsWith('/cluster/resources'))).toBe(false);
|
|
39
42
|
});
|
|
40
43
|
|
|
41
44
|
test('a 500 for a guest the cluster lists on this node fails with PVE’s error', async () => {
|
|
@@ -54,7 +57,27 @@ describe('absent means the cluster does not list the vmid', () => {
|
|
|
54
57
|
const message = 'Permission check failed (/vms/150, VM.Audit)\n';
|
|
55
58
|
fake.configErrors.set(`${NODE}/150`, { body: { data: null, message }, status: 403 });
|
|
56
59
|
const run = await lxcEngine(fake).plan(declared(150));
|
|
57
|
-
expect(run.failure).toContain('
|
|
60
|
+
expect(run.failure).toContain('Forbidden');
|
|
58
61
|
expect(run.actions).toEqual({});
|
|
62
|
+
expect(writesOf(fake)).toEqual([]);
|
|
63
|
+
expect(fake.seen.some((call) => call.path.endsWith('/cluster/resources'))).toBe(false);
|
|
59
64
|
});
|
|
65
|
+
|
|
66
|
+
test('exact missing config plus an empty index permits the existing create plan', async () => {
|
|
67
|
+
fake = fakePve();
|
|
68
|
+
const run = await lxcEngine(fake).plan(declared(150));
|
|
69
|
+
expect(run).toMatchObject({ actions: { ct: 'create' }, failure: '' });
|
|
70
|
+
expect(writesOf(fake)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
for (const data of [null, {}, { hostname: 'example' }]) {
|
|
74
|
+
test(`malformed config success ${JSON.stringify(data)} is not absence`, async () => {
|
|
75
|
+
fake = fakePve();
|
|
76
|
+
fake.configErrors.set(`${NODE}/150`, { body: { data }, status: 200 });
|
|
77
|
+
const run = await lxcEngine(fake).plan(declared(150));
|
|
78
|
+
expect(run.failure).toContain('digest-bearing config');
|
|
79
|
+
expect(run.actions).toEqual({});
|
|
80
|
+
expect(writesOf(fake)).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
60
83
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Precise container discovery, with cluster-wide vmid collision checks.
|
|
3
|
+
* ⛔ A config path missing on one node does not mean its vmid is free: migration or a QEMU
|
|
4
|
+
* guest may hold it. Only the SDK's exact LxcConfigNotFound permits the second observation.
|
|
5
|
+
* Generic 500s, permission failures and malformed successes never authorize creation.
|
|
6
|
+
* ★ SAME CREDENTIAL FOR BOTH READS. Installed pve-container6.1.13 Config.pm:31 checks
|
|
7
|
+
* VM.Audit on /vms/{vmid} before AbstractConfig.pm:58 raises the missing-config error.
|
|
8
|
+
* The pinned pve-manager9.2.11 Cluster.pm:588 filters rows by that SAME permission. Keeping
|
|
9
|
+
* the credential makes an absent row meaningful for this vmid without claiming the entire
|
|
10
|
+
* cluster list is unfiltered. A newly minted credential would not carry that evidence.
|
|
11
|
+
*/
|
|
12
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
13
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { runPveWith } from './distilled-pve.ts';
|
|
16
|
+
import { leased } from './lease-cache.ts';
|
|
17
|
+
import { LxcRefusedError, type LxcWhere } from './lxc-errors.ts';
|
|
18
|
+
import { text } from './values.ts';
|
|
19
|
+
|
|
20
|
+
export const readLive = (where: LxcWhere) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const credential = yield* leased(where.target, 'read');
|
|
23
|
+
const answer = yield* runPveWith(
|
|
24
|
+
where.target,
|
|
25
|
+
credential,
|
|
26
|
+
false,
|
|
27
|
+
nodes.getNodeLxcConfig({ node: where.node, vmid: String(where.vmid) }),
|
|
28
|
+
).pipe(
|
|
29
|
+
Effect.map((data) => ({ data, error: undefined })),
|
|
30
|
+
Effect.catchTag('LxcConfigNotFound', (error) => Effect.succeed({ data: undefined, error })),
|
|
31
|
+
);
|
|
32
|
+
if (answer.error === undefined) {
|
|
33
|
+
const live: unknown = answer.data;
|
|
34
|
+
// ⛔ The vendor requires digest on a real config. Null/empty output is not absence.
|
|
35
|
+
if (
|
|
36
|
+
live === null ||
|
|
37
|
+
typeof live !== 'object' ||
|
|
38
|
+
Array.isArray(live) ||
|
|
39
|
+
!('digest' in live) ||
|
|
40
|
+
typeof live.digest !== 'string' ||
|
|
41
|
+
live.digest === ''
|
|
42
|
+
) {
|
|
43
|
+
return yield* Effect.fail(
|
|
44
|
+
new LxcRefusedError(
|
|
45
|
+
`CT ${String(where.vmid)}: config read did not return a valid digest-bearing config.`,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return live as Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
const rows = yield* runPveWith(
|
|
52
|
+
where.target,
|
|
53
|
+
credential,
|
|
54
|
+
false,
|
|
55
|
+
cluster.listClusterResources({ type: 'vm' }),
|
|
56
|
+
);
|
|
57
|
+
// ⛔ Unknown row identities cannot establish that this cluster-wide vmid is free.
|
|
58
|
+
if (
|
|
59
|
+
!Array.isArray(rows) ||
|
|
60
|
+
rows.some(
|
|
61
|
+
(row) =>
|
|
62
|
+
!Number.isSafeInteger(Number(row?.vmid)) ||
|
|
63
|
+
Number(row?.vmid) <= 0 ||
|
|
64
|
+
typeof row?.node !== 'string' ||
|
|
65
|
+
row.node === '' ||
|
|
66
|
+
!['lxc', 'qemu'].includes(row.type),
|
|
67
|
+
)
|
|
68
|
+
) {
|
|
69
|
+
return yield* Effect.fail(
|
|
70
|
+
new LxcRefusedError('The cluster guest index did not identify its rows.'),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
// Number() preserves the vendor's numeric/string vmid spellings.
|
|
74
|
+
const found = rows.find((row) => Number(row.vmid) === where.vmid);
|
|
75
|
+
if (found !== undefined && found.node === where.node && found.type === 'lxc') {
|
|
76
|
+
return yield* Effect.fail(answer.error);
|
|
77
|
+
}
|
|
78
|
+
if (found !== undefined) {
|
|
79
|
+
return yield* Effect.fail(
|
|
80
|
+
new LxcRefusedError(
|
|
81
|
+
`vmid ${String(where.vmid)} is a ${text(found.type, 'guest')} on ` +
|
|
82
|
+
`${text(found.node, '?')}, not a container on ${where.node}. A migration is not an ` +
|
|
83
|
+
'update and vmids are cluster-wide: declare the node it is on, or pick a free vmid.',
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** LXC task observation is bounded and uses the exact credential that started the worker. */
|
|
2
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
3
|
+
import type { ProxmoxOpContext } from '@distilled.cloud/proxmox/Protocol';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import { runPveWith } from './distilled-pve.ts';
|
|
6
|
+
import { LxcRefusedError, type LxcWhere } from './lxc-errors.ts';
|
|
7
|
+
import { mint } from './mint.ts';
|
|
8
|
+
import { text } from './values.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ⛔ Fresh provision mint for each write, then retain it for every task poll. PVE's task
|
|
12
|
+
* owner skips Sys.Audit; another mint is another token identity. This also gives a create
|
|
13
|
+
* the full lease rather than a cached lease with only 60 seconds left.
|
|
14
|
+
* ⚠️ A failed post-write poll is uncertainty, never absence or permission to retry the write.
|
|
15
|
+
* Preserve the existing one-second bound and the UPID diagnostic. NetworkApply keeps its
|
|
16
|
+
* separate raw helper; migrating this caller does not alter reload behavior.
|
|
17
|
+
* ★ SDK labels are percent-encoded. Installed APIServer/AnyEvent.pm uri_unescape runs before
|
|
18
|
+
* routing, so an encoded UPID reaches the same task as its old literal spelling.
|
|
19
|
+
*/
|
|
20
|
+
export const lxcTask = <E>(
|
|
21
|
+
where: LxcWhere,
|
|
22
|
+
operation: Effect.Effect<string, E, ProxmoxOpContext>,
|
|
23
|
+
what: string,
|
|
24
|
+
polls: number,
|
|
25
|
+
) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
const credential = yield* mint(where.target, 'provision');
|
|
28
|
+
const upid = text(yield* runPveWith(where.target, credential, true, operation));
|
|
29
|
+
if (upid === '')
|
|
30
|
+
return yield* Effect.fail(
|
|
31
|
+
new LxcRefusedError(
|
|
32
|
+
`${what}: PVE returned no task id, so the write cannot be confirmed and is not claimed.`,
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
let exit = 'still running when this provider stopped waiting';
|
|
36
|
+
for (let attempt = 0; attempt < polls; attempt++) {
|
|
37
|
+
const status = yield* runPveWith(
|
|
38
|
+
where.target,
|
|
39
|
+
credential,
|
|
40
|
+
false,
|
|
41
|
+
nodes.getNodeTaskStatus({ node: where.node, upid }),
|
|
42
|
+
).pipe(Effect.orElseSucceed(() => undefined));
|
|
43
|
+
// ⚠️ "COULD NOT BE READ" IS A MISSING ANSWER OR A MISSING `status` FIELD, NOT AN UNEXPECTED
|
|
44
|
+
// ONE. `GetNodeTaskStatusResponseStatus` types `status` as the closed union
|
|
45
|
+
// `"running" | "stopped"`, but the runtime validator is `S.String` (measured in
|
|
46
|
+
// distilled-proxmox's nodes.ts) and MEASURED here to accept an empty `{}` for a malformed
|
|
47
|
+
// `data: null` answer (status.status undefined, no decode error) — so `status.status ===
|
|
48
|
+
// undefined` is the real "unreadable" signal, matching network-apply-read.ts's `awaitTask`
|
|
49
|
+
// and this file's own malformed-answer test. A well-formed answer with a status word this
|
|
50
|
+
// endpoint's contract was never proven to exclude (something other than exactly "running")
|
|
51
|
+
// must keep polling rather than abort, since only "stopped" ends the task
|
|
52
|
+
// (2026-09-25 adversarial review).
|
|
53
|
+
if (status === undefined || status.status === undefined) {
|
|
54
|
+
exit =
|
|
55
|
+
'the task could not be read -- the node stopped answering, or this lease cannot see it';
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (status.status === 'stopped') {
|
|
59
|
+
exit = text(status.exitstatus, 'stopped with no exitstatus');
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
yield* Effect.sleep('1 second');
|
|
63
|
+
}
|
|
64
|
+
if (exit !== 'OK')
|
|
65
|
+
return yield* Effect.fail(
|
|
66
|
+
new LxcRefusedError(
|
|
67
|
+
`${what}: task ${upid} ended "${exit}". Read \`pvesh get /nodes/${where.node}/tasks/` +
|
|
68
|
+
`${upid}/log\` before running the deploy again.`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** NetworkApply over a stub. The interfaces diff must not enter state. */
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
import * as Layer from 'effect/Layer';
|
|
4
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
5
|
+
import { FAKE_TARGET, fakePve, withoutBao } from './fake-pve.ts';
|
|
6
|
+
import { ProxmoxNetworkApply, ProxmoxNetworkApplyProvider } from './network-apply.ts';
|
|
7
|
+
|
|
8
|
+
const SECRET = 'wpa-psk-do-not-store';
|
|
9
|
+
const DIFF = `--- a/interfaces\n+++ b/interfaces\n@@ -1 +1 @@\n-old\n+${SECRET}\n`;
|
|
10
|
+
const UPID = 'UPID:pve1:00000001:0:0:srvreload:networking:test@pve!fake:';
|
|
11
|
+
const health = [
|
|
12
|
+
{ id: 'cluster', name: 'c1', type: 'cluster', quorate: 1 },
|
|
13
|
+
{ id: 'node/pve1', name: 'pve1', type: 'node', online: 1 },
|
|
14
|
+
];
|
|
15
|
+
const task = {
|
|
16
|
+
exitstatus: 'OK',
|
|
17
|
+
id: 'networking',
|
|
18
|
+
node: 'pve1',
|
|
19
|
+
pid: 1,
|
|
20
|
+
pstart: 1,
|
|
21
|
+
starttime: 1,
|
|
22
|
+
status: 'stopped',
|
|
23
|
+
type: 'srvreload',
|
|
24
|
+
upid: UPID,
|
|
25
|
+
user: 'test@pve',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const engine = (staged: { value: boolean }) => {
|
|
29
|
+
const fake = fakePve((call) => {
|
|
30
|
+
if (call.path.startsWith('cluster/status')) return health;
|
|
31
|
+
if (call.path.includes('/tasks/')) return task;
|
|
32
|
+
if (call.method === 'PUT') {
|
|
33
|
+
staged.value = false;
|
|
34
|
+
return UPID;
|
|
35
|
+
}
|
|
36
|
+
if (call.path.startsWith('nodes/pve1/network')) {
|
|
37
|
+
return staged.value
|
|
38
|
+
? Response.json({
|
|
39
|
+
changes: DIFF,
|
|
40
|
+
data: [{ iface: 'vmbr0', type: 'bridge' }],
|
|
41
|
+
})
|
|
42
|
+
: [{ iface: 'vmbr0', type: 'bridge' }];
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
fake,
|
|
48
|
+
stack: engineOver(ProxmoxNetworkApplyProvider().pipe(Layer.provideMerge(fake.layer))),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const declare = () => ProxmoxNetworkApply('row', { node: 'pve1', target: FAKE_TARGET });
|
|
53
|
+
|
|
54
|
+
test('a settled node is adopted without a reload', async () => {
|
|
55
|
+
const { fake, stack } = engine({ value: false });
|
|
56
|
+
await withoutBao(async () => {
|
|
57
|
+
expect(await stack.deploy(declare())).toEqual({ row: 'adopted' });
|
|
58
|
+
expect((await stack.verify(declare(), { all: true })).rows[0]).toMatchObject({ diff: 'noop' });
|
|
59
|
+
});
|
|
60
|
+
expect(fake.writes()).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a staged diff is applied once, then the secret is not in state', async () => {
|
|
64
|
+
const staged = { value: true };
|
|
65
|
+
const { fake, stack } = engine(staged);
|
|
66
|
+
await withoutBao(async () => {
|
|
67
|
+
expect((await stack.verify(declare(), { all: true })).rows[0]).toMatchObject({
|
|
68
|
+
diff: 'update',
|
|
69
|
+
});
|
|
70
|
+
await stack.deploy(declare());
|
|
71
|
+
expect(stack.stored()).not.toContain(SECRET);
|
|
72
|
+
expect((await stack.verify(declare(), { all: true })).rows[0]).toMatchObject({ diff: 'noop' });
|
|
73
|
+
});
|
|
74
|
+
expect(fake.writes()).toEqual(['PUT nodes/pve1/network']);
|
|
75
|
+
expect(fake.calls.some((call) => JSON.stringify(call).includes(SECRET))).toBe(false);
|
|
76
|
+
});
|
|
@@ -17,13 +17,8 @@
|
|
|
17
17
|
* of `data`, set only when the diff is non-empty. `client.ts`'s `pve()` returns `body.data` and
|
|
18
18
|
* drops the envelope, so the one readable pending signal is invisible to every other resource in
|
|
19
19
|
* this package. Without the fetch below, `Proxmox.NetworkApply` could not diff honestly at all.
|
|
20
|
-
* ★ `
|
|
21
|
-
*
|
|
22
|
-
* 43's walk-down): CONFIRMED against `@distilled.cloud/proxmox`'s own `protocol.ts` that its
|
|
23
|
-
* `transformResponse` unconditionally returns `body.data ?? {}` for every operation, before a
|
|
24
|
-
* generated call's typed output is even built — no typed call, current or future, could see
|
|
25
|
-
* `changes` without a protocol-level change upstream. `pveEnvelopeWith` stays because nothing
|
|
26
|
-
* else preserves the sibling this function needs.
|
|
20
|
+
* ★ `listNodeNetwork` keeps a string `changes` sibling. The protocol unwraps every other
|
|
21
|
+
* envelope. The diff string is counted here and then dropped.
|
|
27
22
|
*
|
|
28
23
|
* ⛔ AND THE INTERFACE ROWS CANNOT TELL YOU EITHER. MEASURED in PVE/INotify.pm `read_file`: when
|
|
29
24
|
* `/etc/network/interfaces.new` exists, THAT is the file that gets parsed, so
|
|
@@ -41,21 +36,14 @@
|
|
|
41
36
|
* there waiting for the next reload anybody triggers. The file's existence is not exposed by the
|
|
42
37
|
* API in any other form, so the alternative is not a better read — it is SSH.
|
|
43
38
|
*/
|
|
39
|
+
import * as cluster from '@distilled.cloud/proxmox/cluster';
|
|
40
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
44
41
|
import * as Effect from 'effect/Effect';
|
|
45
|
-
import {
|
|
42
|
+
import { runPve, runPveWith } from './distilled-pve.ts';
|
|
46
43
|
import { type PveCredential, type PveTarget } from './credentials.ts';
|
|
47
44
|
import { leased } from './lease-cache.ts';
|
|
48
45
|
import { bool, text } from './values.ts';
|
|
49
46
|
|
|
50
|
-
/** The `GET /nodes/{node}/network` envelope. ⛔ `changes` is beside `data`, never inside it. */
|
|
51
|
-
type NetworkEnvelope = { changes?: string };
|
|
52
|
-
|
|
53
|
-
/** One row of `GET /cluster/status` — a `cluster` row, or one `node` row per member. */
|
|
54
|
-
type StatusRow = { type?: string; name?: string; quorate?: unknown; online?: unknown };
|
|
55
|
-
|
|
56
|
-
/** One `GET /nodes/{node}/tasks/{upid}/status` answer. `status` is `running` or `stopped`. */
|
|
57
|
-
type TaskStatus = { status?: string; exitstatus?: string };
|
|
58
|
-
|
|
59
47
|
/** What the cluster looked like the moment it was asked. Never persisted — see network-apply.ts. */
|
|
60
48
|
export type ClusterHealth = { quorate: boolean; offline: readonly string[] };
|
|
61
49
|
|
|
@@ -70,7 +58,6 @@ export type ClusterHealth = { quorate: boolean; offline: readonly string[] };
|
|
|
70
58
|
*/
|
|
71
59
|
const stagedDiff = (target: PveTarget, node: string) =>
|
|
72
60
|
Effect.gen(function* () {
|
|
73
|
-
const path = `nodes/${node}/network`;
|
|
74
61
|
// ⚠️ THE `read` ROLE IS ENOUGH, AND DELIBERATELY SO. The schema's permission for this GET is
|
|
75
62
|
// `"user": "all"` — no privilege check at all — so the pending read never needs the
|
|
76
63
|
// provisioning lease, and a plan stays a plan even if the write role is missing entirely.
|
|
@@ -79,8 +66,9 @@ const stagedDiff = (target: PveTarget, node: string) =>
|
|
|
79
66
|
// SIX extra `hf-read@pve` tokens on node-b per C1 plan (one per node, for read and for diff),
|
|
80
67
|
// MEASURED 2026-09-14 while the cache itself made one read mint for the whole run.
|
|
81
68
|
const credential = yield* leased(target, 'read');
|
|
82
|
-
const
|
|
83
|
-
|
|
69
|
+
const live = yield* runPveWith(target, credential, false, nodes.listNodeNetwork({ node }));
|
|
70
|
+
// ⛔ The diff string is not returned from pendingCount and is not an attribute.
|
|
71
|
+
return Array.isArray(live) ? '' : live.changes;
|
|
84
72
|
});
|
|
85
73
|
|
|
86
74
|
/**
|
|
@@ -128,7 +116,7 @@ export const pendingCount = (target: PveTarget, node: string) =>
|
|
|
128
116
|
* It is asked of the `read` role, which is auditor-shaped, rather than of `provision`.
|
|
129
117
|
*/
|
|
130
118
|
const clusterHealth = (target: PveTarget) =>
|
|
131
|
-
|
|
119
|
+
runPve(target, 'read', false, cluster.listClusterStatus({})).pipe(
|
|
132
120
|
Effect.map((rows) => {
|
|
133
121
|
const all = rows ?? [];
|
|
134
122
|
const cluster = all.find((row) => row.type === 'cluster');
|
|
@@ -195,11 +183,8 @@ const ATTEMPTS = 30;
|
|
|
195
183
|
* is not the owner — which is why this takes a `PveCredential` and the caller keeps one lease
|
|
196
184
|
* across both calls, instead of widening the provisioning role to read its own tasks.
|
|
197
185
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* propagates a poll failure as a genuine typed `GetNodeTaskStatusError` rather than collapsing
|
|
201
|
-
* it to `UNREACHABLE` the way this function does, which would turn the ⛔ two paragraphs above
|
|
202
|
-
* into a failed `reconcile` on exactly the poll a mid-reload connection drop is expected to hit.
|
|
186
|
+
* ★ A poll failure stays `UNREACHABLE`. `getNodeTaskStatus` would otherwise fail the reload
|
|
187
|
+
* on the connection drop this function exists to survive.
|
|
203
188
|
*/
|
|
204
189
|
export const awaitTask = (
|
|
205
190
|
target: PveTarget,
|
|
@@ -210,16 +195,7 @@ export const awaitTask = (
|
|
|
210
195
|
attempts: number = ATTEMPTS,
|
|
211
196
|
) =>
|
|
212
197
|
Effect.gen(function* () {
|
|
213
|
-
|
|
214
|
-
* ⚠️ THE UPID GOES IN RAW, NOT PERCENT-ENCODED, AND THE CHOICE IS DELIBERATE. A UPID is
|
|
215
|
-
* `UPID:<node>:<hex>:<hex>:<hex>:<type>:<id>:<user>:` — its only punctuation is `:`, `@`, `!`
|
|
216
|
-
* and `-`, every one of which is a legal path character, so raw needs nothing from the
|
|
217
|
-
* router. Encoding them instead RELIES on PVE unescaping each path segment, which is
|
|
218
|
-
* plausible and was NOT measured here (measuring it means starting a task, i.e. writing). If
|
|
219
|
-
* that assumption were wrong the poll would 404, this would report the outcome as unknown,
|
|
220
|
-
* and a perfectly good reload would stop the deploy. Raw removes the assumption.
|
|
221
|
-
*/
|
|
222
|
-
const path = `nodes/${node}/tasks/${upid}/status`;
|
|
198
|
+
// ★ The SDK percent-encodes the UPID label. PVE APIServer unescapes it before routing.
|
|
223
199
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
224
200
|
/**
|
|
225
201
|
* ⛔ A FAILED POLL IS NOT A FAILED RELOAD, AND CALLING IT ONE WOULD BE THE WORST KIND OF
|
|
@@ -231,9 +207,12 @@ export const awaitTask = (
|
|
|
231
207
|
* task's owner, the endpoint wants `Sys.Audit` on the node and this sees only "no answer" —
|
|
232
208
|
* which is why `UNREACHABLE` names both possibilities rather than blaming the network.
|
|
233
209
|
*/
|
|
234
|
-
const status = yield*
|
|
235
|
-
|
|
236
|
-
|
|
210
|
+
const status = yield* runPveWith(
|
|
211
|
+
target,
|
|
212
|
+
credential,
|
|
213
|
+
false,
|
|
214
|
+
nodes.getNodeTaskStatus({ node, upid }),
|
|
215
|
+
).pipe(Effect.orElseSucceed(() => undefined));
|
|
237
216
|
if (status === undefined) return UNREACHABLE;
|
|
238
217
|
if (status.status === 'stopped') return text(status.exitstatus, 'stopped with no exitstatus');
|
|
239
218
|
yield* Effect.sleep('1 second');
|