@homeflare/alchemy 0.37.6 → 0.37.8

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 (112) hide show
  1. package/dist/linux/container-form.d.ts +1 -1
  2. package/dist/linux/container-generator.d.ts +28 -0
  3. package/dist/linux/container-generator.d.ts.map +1 -1
  4. package/dist/linux/container-lifecycle.d.ts +18 -1
  5. package/dist/linux/container-lifecycle.d.ts.map +1 -1
  6. package/dist/linux/container-preflight.d.ts +34 -0
  7. package/dist/linux/container-preflight.d.ts.map +1 -1
  8. package/dist/linux/container-settle.d.ts.map +1 -1
  9. package/dist/linux/fake-sudo.d.ts +14 -2
  10. package/dist/linux/fake-sudo.d.ts.map +1 -1
  11. package/dist/linux/index.js +76 -29
  12. package/dist/linux/index.js.map +11 -10
  13. package/dist/linux/sudo-exec.d.ts +16 -0
  14. package/dist/linux/sudo-exec.d.ts.map +1 -1
  15. package/dist/linux/unit-form.d.ts.map +1 -1
  16. package/dist/linux/unit-text.d.ts +35 -0
  17. package/dist/linux/unit-text.d.ts.map +1 -0
  18. package/dist/proxmox/firewall-alias-config.d.ts +36 -0
  19. package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
  20. package/dist/proxmox/firewall-alias-form.d.ts +3 -1
  21. package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
  22. package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
  23. package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
  24. package/dist/proxmox/firewall-alias.d.ts +2 -8
  25. package/dist/proxmox/firewall-alias.d.ts.map +1 -1
  26. package/dist/proxmox/index.js +795 -526
  27. package/dist/proxmox/index.js.map +23 -10
  28. package/dist/proxmox/lxc-errors.d.ts +12 -0
  29. package/dist/proxmox/lxc-errors.d.ts.map +1 -0
  30. package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
  31. package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
  32. package/dist/proxmox/lxc-read.d.ts +4 -0
  33. package/dist/proxmox/lxc-read.d.ts.map +1 -0
  34. package/dist/proxmox/lxc-task.d.ts +15 -0
  35. package/dist/proxmox/lxc-task.d.ts.map +1 -0
  36. package/dist/proxmox/network-apply-read.d.ts +7 -14
  37. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  38. package/dist/proxmox/network-apply-run.d.ts +7 -0
  39. package/dist/proxmox/network-apply-run.d.ts.map +1 -0
  40. package/dist/proxmox/network-apply.d.ts +2 -3
  41. package/dist/proxmox/network-apply.d.ts.map +1 -1
  42. package/dist/proxmox/qemu-errors.d.ts +5 -0
  43. package/dist/proxmox/qemu-errors.d.ts.map +1 -0
  44. package/dist/proxmox/qemu-form.d.ts +22 -0
  45. package/dist/proxmox/qemu-form.d.ts.map +1 -0
  46. package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
  47. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
  48. package/dist/proxmox/qemu-read.d.ts +5 -0
  49. package/dist/proxmox/qemu-read.d.ts.map +1 -0
  50. package/dist/proxmox/qemu-task.d.ts +9 -0
  51. package/dist/proxmox/qemu-task.d.ts.map +1 -0
  52. package/dist/proxmox/qemu.d.ts +9 -14
  53. package/dist/proxmox/qemu.d.ts.map +1 -1
  54. package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
  55. package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
  56. package/dist/proxmox/replication-job-config.d.ts +56 -0
  57. package/dist/proxmox/replication-job-config.d.ts.map +1 -0
  58. package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
  59. package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
  60. package/dist/proxmox/replication-job.d.ts +5 -6
  61. package/dist/proxmox/replication-job.d.ts.map +1 -1
  62. package/dist/release/archive.d.ts.map +1 -1
  63. package/dist/release/index.js +3 -2
  64. package/dist/release/index.js.map +3 -3
  65. package/docs/linux-sudo.md +18 -7
  66. package/docs/proxmox-lxc-distilled.md +56 -0
  67. package/docs/proxmox-replication-firewall-distilled.md +53 -0
  68. package/docs/proxmox-sdk-migration.md +23 -18
  69. package/package.json +2 -2
  70. package/src/linux/container-form.ts +1 -1
  71. package/src/linux/container-generator.test.ts +33 -0
  72. package/src/linux/container-generator.ts +72 -0
  73. package/src/linux/container-lifecycle.test.ts +6 -3
  74. package/src/linux/container-lifecycle.ts +23 -2
  75. package/src/linux/container-preflight.ts +47 -0
  76. package/src/linux/container-settle.ts +12 -1
  77. package/src/linux/container-shadow.test.ts +131 -0
  78. package/src/linux/fake-sudo.ts +21 -5
  79. package/src/linux/sudo-exec.ts +60 -10
  80. package/src/linux/sudo-lifecycle.test.ts +64 -3
  81. package/src/linux/unit-form.ts +4 -18
  82. package/src/linux/unit-text.test.ts +67 -0
  83. package/src/linux/unit-text.ts +58 -0
  84. package/src/proxmox/firewall-alias-config.ts +39 -0
  85. package/src/proxmox/firewall-alias-form.ts +1 -1
  86. package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
  87. package/src/proxmox/firewall-alias.ts +4 -67
  88. package/src/proxmox/lxc-create.test.ts +4 -1
  89. package/src/proxmox/lxc-distilled.test.ts +239 -0
  90. package/src/proxmox/lxc-errors.ts +9 -0
  91. package/src/proxmox/lxc-lifecycle.ts +31 -113
  92. package/src/proxmox/lxc-read.test.ts +29 -6
  93. package/src/proxmox/lxc-read.ts +88 -0
  94. package/src/proxmox/lxc-task.ts +71 -0
  95. package/src/proxmox/network-apply-distilled.test.ts +76 -0
  96. package/src/proxmox/network-apply-read.ts +18 -39
  97. package/src/proxmox/network-apply-run.ts +97 -0
  98. package/src/proxmox/network-apply.ts +5 -94
  99. package/src/proxmox/qemu-errors.ts +7 -0
  100. package/src/proxmox/qemu-form.ts +15 -0
  101. package/src/proxmox/qemu-lifecycle.test.ts +171 -0
  102. package/src/proxmox/qemu-lifecycle.ts +134 -0
  103. package/src/proxmox/qemu-read.ts +73 -0
  104. package/src/proxmox/qemu-task.ts +64 -0
  105. package/src/proxmox/qemu.ts +11 -44
  106. package/src/proxmox/replication-firewall-fixtures.ts +71 -0
  107. package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
  108. package/src/proxmox/replication-firewall-wire.test.ts +173 -0
  109. package/src/proxmox/replication-job-config.ts +79 -0
  110. package/src/proxmox/replication-job-lifecycle.ts +113 -0
  111. package/src/proxmox/replication-job.ts +7 -84
  112. package/src/release/archive.ts +4 -1
@@ -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
- * ★ `pveEnvelopeWith` in client.ts now carries the same member failover as every other call.
21
- * ⛔ WHY THIS FAMILY DID NOT MOVE TO DISTILLED WITH `Proxmox.NodeNetwork` (2026-09-24, decision
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 { pve, pveEnvelopeWith, pveWith } from './client.ts';
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 envelope = (yield* pveEnvelopeWith(target, credential, 'GET', path)) as NetworkEnvelope;
83
- return text(envelope.changes);
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
- pve<StatusRow[]>(target, 'read', 'GET', 'cluster/status').pipe(
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
- * ⛔ THE OTHER REASON THIS FAMILY DID NOT MOVE TO DISTILLED (2026-09-24): CHECKED against
199
- * `@distilled.cloud/proxmox`'s own `Task.awaitTask` (`src/task.ts`) before deciding — it
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* pveWith<TaskStatus>(target, credential, 'GET', path).pipe(
235
- Effect.orElseSucceed(() => undefined),
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');
@@ -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 = () =>
@@ -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) });