@homeflare/alchemy 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
  2. package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
  3. package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
  4. package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/alert-rule-group-model.d.ts +28 -0
  6. package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
  7. package/dist/grafana/alert-rule-group.d.ts +60 -0
  8. package/dist/grafana/alert-rule-group.d.ts.map +1 -0
  9. package/dist/grafana/index.d.ts +6 -4
  10. package/dist/grafana/index.d.ts.map +1 -1
  11. package/dist/grafana/index.js +355 -139
  12. package/dist/grafana/index.js.map +14 -8
  13. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  14. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  15. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  16. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  17. package/dist/grafana/notification-policy-model.d.ts +53 -0
  18. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  19. package/dist/grafana/notification-policy.d.ts +57 -0
  20. package/dist/grafana/notification-policy.d.ts.map +1 -0
  21. package/dist/grafana/providers.d.ts +1 -1
  22. package/dist/grafana/providers.d.ts.map +1 -1
  23. package/dist/proxmox/index.js +349 -287
  24. package/dist/proxmox/index.js.map +8 -7
  25. package/dist/proxmox/network-apply-read.d.ts +12 -0
  26. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  27. package/dist/proxmox/network-apply.d.ts +2 -0
  28. package/dist/proxmox/network-apply.d.ts.map +1 -1
  29. package/dist/proxmox/node-network-form.d.ts +86 -54
  30. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  31. package/dist/proxmox/node-network-wire.d.ts +100 -0
  32. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  33. package/dist/proxmox/node-network.d.ts +21 -106
  34. package/dist/proxmox/node-network.d.ts.map +1 -1
  35. package/docs/grafana-alerting-rules.md +143 -0
  36. package/docs/grafana-alerting.md +7 -4
  37. package/docs/grafana-notification-policy.md +127 -0
  38. package/docs/grafana.md +12 -6
  39. package/docs/upstream-conformance.md +30 -4
  40. package/package.json +1 -1
  41. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  42. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  43. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  44. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  45. package/src/grafana/alert-rule-group-model.ts +66 -0
  46. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  47. package/src/grafana/alert-rule-group.test.ts +164 -0
  48. package/src/grafana/alert-rule-group.ts +231 -0
  49. package/src/grafana/index.ts +18 -4
  50. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  51. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  52. package/src/grafana/notification-policy-fixtures.ts +27 -0
  53. package/src/grafana/notification-policy-model.test.ts +104 -0
  54. package/src/grafana/notification-policy-model.ts +100 -0
  55. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  56. package/src/grafana/notification-policy.test.ts +86 -0
  57. package/src/grafana/notification-policy.ts +191 -0
  58. package/src/grafana/providers.ts +4 -0
  59. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  60. package/src/proxmox/network-apply-read.ts +12 -0
  61. package/src/proxmox/network-apply.ts +2 -0
  62. package/src/proxmox/node-network-form.ts +93 -99
  63. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  64. package/src/proxmox/node-network-wire.ts +239 -0
  65. package/src/proxmox/node-network.ts +155 -167
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `Proxmox.NodeNetwork`'s absence signal, and the cries-wolf fix, through Alchemy's own Plan and
3
+ * Apply over a fake cluster. Real-shaped fixtures (live TB4 `n2` response bodies, measured
4
+ * 2026-09-24, nothing secret): `GET /nodes/n2/network/vmbr0` -> a full interface object; a
5
+ * missing interface -> `{"errors":{"iface":"interface does not exist"},"data":null,"message":
6
+ * "Parameter verification failed.\n"}` at HTTP 400 — THIS family's absence shape, distinct from
7
+ * every other migrated family's measured 500.
8
+ *
9
+ * ★ ONLY THIS EXACT SIGNAL MEANS ABSENT. The tests below prove both directions: the specific
10
+ * 400 lets `reconcile` create a genuinely new interface, and nothing else — a different 400
11
+ * reason, a 500, or a refused credential — is ever silently folded into "go ahead and create".
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Layer from 'effect/Layer';
15
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
16
+ import { engineOver } from '../verify/fake-engine.ts';
17
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
18
+ import { ProxmoxNodeNetwork, ProxmoxNodeNetworkProvider } from './node-network.ts';
19
+
20
+ /**
21
+ * One interface, an OpenBao mint the test can deny for `read` mid-run (the cries-wolf case), and
22
+ * a `GET` answer the test can swap to any status/body (the absence-signal cases).
23
+ */
24
+ const clusterOverInterface = (node: string, iface: string, live: Record<string, unknown>) => {
25
+ let denyRead = false;
26
+ let answer: 'live' | 'genuinely-absent' | 'other-400' | 'server-error' = 'live';
27
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
28
+ const request =
29
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
30
+ const url = new URL(request.url);
31
+ if (url.pathname.includes('/creds/')) {
32
+ if (denyRead && url.pathname.endsWith('/creds/read')) {
33
+ return Response.json({ errors: ['permission denied'] }, { status: 403 });
34
+ }
35
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
36
+ return Response.json({ data, lease_duration: 0 });
37
+ }
38
+ if (request.method === 'GET') {
39
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses a GET
40
+ // carrying a body outright.
41
+ expect(await request.text()).toBe('');
42
+ }
43
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
44
+ if (request.method === 'POST' && wire === `nodes/${node}/network`) {
45
+ answer = 'live';
46
+ return Response.json({ data: null });
47
+ }
48
+ if (wire !== `nodes/${node}/network/${iface}`) return Response.json({ data: null });
49
+ if (answer === 'genuinely-absent') {
50
+ // MEASURED 2026-09-24, live TB4: a missing interface answers exactly this shape.
51
+ return Response.json(
52
+ {
53
+ data: null,
54
+ errors: { iface: 'interface does not exist' },
55
+ message: 'Parameter verification failed.\n',
56
+ },
57
+ { status: 400 },
58
+ );
59
+ }
60
+ if (answer === 'other-400') {
61
+ return Response.json(
62
+ {
63
+ data: null,
64
+ errors: { type: 'value does not look like a valid interface type' },
65
+ message: 'Parameter verification failed.\n',
66
+ },
67
+ { status: 400 },
68
+ );
69
+ }
70
+ if (answer === 'server-error') {
71
+ return Response.json({ data: null, message: 'internal error\n' }, { status: 500 });
72
+ }
73
+ return Response.json({ data: live });
74
+ };
75
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
76
+ return {
77
+ denyRead: () => {
78
+ denyRead = true;
79
+ },
80
+ setAnswer: (next: typeof answer) => {
81
+ answer = next;
82
+ },
83
+ layer: FetchHttpClient.layer.pipe(
84
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
85
+ ),
86
+ };
87
+ };
88
+
89
+ // MEASURED 2026-09-24, live TB4 `GET /nodes/n2/network/vmbr0`.
90
+ const live = {
91
+ autostart: 1,
92
+ bridge_ports: 'enp87s0',
93
+ bridge_vids: '2-4094',
94
+ bridge_vlan_aware: 1,
95
+ type: 'bridge',
96
+ };
97
+ const declare = () =>
98
+ ProxmoxNodeNetwork('n2-vmbr0', {
99
+ autostart: true,
100
+ bridge_ports: 'enp87s0',
101
+ bridge_vids: '2-4094',
102
+ bridge_vlan_aware: true,
103
+ iface: 'vmbr0',
104
+ node: 'n2',
105
+ target: FAKE_TARGET,
106
+ type: 'bridge',
107
+ });
108
+
109
+ describe("Proxmox.NodeNetwork's own cries-wolf fix: a refused read mint reports noop, never update", () => {
110
+ test('an already-adopted interface plans noop once the credential is denied, and nothing is written', async () => {
111
+ const fake = clusterOverInterface('n2', 'vmbr0', live);
112
+ await withoutBao(async () => {
113
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
114
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
115
+ fake.denyRead();
116
+ const report = await engine.verify(declare(), { all: true });
117
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
118
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'noop' });
119
+ });
120
+ });
121
+ });
122
+
123
+ describe('only the measured "interface does not exist" 400 means absent', () => {
124
+ test('a genuinely new interface is created from exactly that signal', async () => {
125
+ const fake = clusterOverInterface('n2', 'vmbr9', {});
126
+ fake.setAnswer('genuinely-absent');
127
+ await withoutBao(async () => {
128
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
129
+ const declareNew = () =>
130
+ ProxmoxNodeNetwork('n2-vmbr9', {
131
+ iface: 'vmbr9',
132
+ node: 'n2',
133
+ target: FAKE_TARGET,
134
+ type: 'bridge',
135
+ });
136
+ // ⚠️ The fake's POST handler flips `answer` back to `'live'`, so the read-back after
137
+ // create finds the interface — matching a real create landing.
138
+ expect(await engine.deploy(declareNew())).toEqual({ 'n2-vmbr9': 'create' });
139
+ });
140
+ });
141
+
142
+ /**
143
+ * ★ WHY `read` FOLDS AT ALL, SPELLED OUT ON A BRAND NEW DECLARATION. Plan.ts's cold-start
144
+ * adoption probe (no prior Alchemy state for this declaration) calls `provider.read` with no
145
+ * catch of its own, and aggregates every resource's probe fail-fast — so an uncaught failure
146
+ * here would abort the WHOLE plan, every other resource in it included, over one interface a
147
+ * flaky read happened to hit. Proven here: a genuine 500 (never the absence signal) on a
148
+ * cold-start read still resolves to `create`, not a thrown plan failure.
149
+ * ⚠️ THIS TEST ALONE DOES NOT DISTINGUISH THE FIX FROM THE DISPROVEN DESIGN — checked directly:
150
+ * the pre-fix single-function `read` folded unconditionally too, so it passes either way. The
151
+ * test that actually regresses without `read`'s `output`-branching is the drift test right
152
+ * below (`output` IS defined there, on an already-confirmed row, and that is the case the
153
+ * fix changes): verified by temporarily reverting `read` to the pre-fix form and confirming
154
+ * that test, and only that one, fails. Kept anyway as a direct demonstration of the
155
+ * reasoning above, not as the regression proof.
156
+ */
157
+ test("a cold-start declaration's adoption probe folds a genuine 500, and the plan is not aborted", async () => {
158
+ const fake = clusterOverInterface('n2', 'vmbr8', {});
159
+ fake.setAnswer('server-error');
160
+ await withoutBao(async () => {
161
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
162
+ const declareNew = () =>
163
+ ProxmoxNodeNetwork('n2-vmbr8', {
164
+ iface: 'vmbr8',
165
+ node: 'n2',
166
+ target: FAKE_TARGET,
167
+ type: 'bridge',
168
+ });
169
+ expect(await engine.deploy(declareNew())).toEqual({ 'n2-vmbr8': 'create' });
170
+ });
171
+ });
172
+
173
+ test('a different 400 (not "interface does not exist") propagates instead of creating', async () => {
174
+ const fake = clusterOverInterface('n2', 'vmbr0', live);
175
+ await withoutBao(async () => {
176
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
177
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
178
+ fake.setAnswer('other-400');
179
+ // ⛔ THE FIX: this must REJECT, never resolve with a false 'update' (or worse, a create).
180
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
181
+ });
182
+ });
183
+
184
+ test('a genuine 500 propagates instead of creating', async () => {
185
+ const fake = clusterOverInterface('n2', 'vmbr0', live);
186
+ await withoutBao(async () => {
187
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
188
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
189
+ fake.setAnswer('server-error');
190
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
191
+ });
192
+ });
193
+
194
+ // ⛔ THE ADVERSARIAL REVIEW'S OWN REGRESSION TEST — `output` is defined here (an already
195
+ // -confirmed row), the one case `read`'s `output`-branching actually changes. Confirmed by
196
+ // temporarily reverting `read` to the pre-fix, non-branching form: only this test fails.
197
+ test('alchemy drift never reports a transient failure as missing', async () => {
198
+ const fake = clusterOverInterface('n2', 'vmbr0', live);
199
+ await withoutBao(async () => {
200
+ const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
201
+ expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
202
+ fake.setAnswer('server-error');
203
+ await expect(engine.drift(declare())).rejects.toBeDefined();
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `Proxmox.NodeNetwork`'s READ side: the coercions in both directions, the read itself, and
3
+ * `matches`. Split out of node-network-form.ts (2026-09-24, the distilled migration) — the
4
+ * storage.ts/storage-wire.ts seam; the write side stays in node-network-form.ts.
5
+ *
6
+ * * ⛔ THE LAST SEVEN ARE REPORTED AND NEVER COMPARED, AND EACH ONE IS A MEASURED FOREVER-DIFF.
7
+ * `priority` is assigned by PVE FROM FILE ORDER — `my $priority = 2; ... $d->{priority} =
8
+ * $priority++` — and it is not a POST or PUT parameter at all. MEASURED ACROSS C1: vmbr1.42 is
9
+ * priority 16 on node-b and node-c and 15 on node-d, because node-d has no `wlan0` above it. One
10
+ * declaration reused across three nodes would diff on exactly one of them, forever, over nothing.
11
+ * `method` is RECOMPUTED on every write from whether the form carried an address, `families` is
12
+ * recomputed the same way and never written to the file, `active` and `exists` describe the
13
+ * kernel rather than the config, and `bond_miimon`, `bridge_stp` and `bridge_fd` are emitted by
14
+ * PVE's writer with defaults (100, `off`, 0) while appearing in NEITHER write schema —
15
+ * `additionalProperties => 0` means sending one is a 400. Live proof of all three: node-b's bond0
16
+ * returns `bond_miimon "100"` and vmbr1 returns `bridge_stp "off"`, `bridge_fd "0"`, none of
17
+ * which any declaration can set.
18
+ */
19
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
20
+ import * as Effect from 'effect/Effect';
21
+ import { runPve } from './distilled-pve.ts';
22
+ import {
23
+ type NodeNetworkProps,
24
+ UNSET,
25
+ comment,
26
+ ifaceList,
27
+ same,
28
+ sameComment,
29
+ sameList,
30
+ } from './node-network-form.ts';
31
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
32
+ import { bool, int, text } from './values.ts';
33
+
34
+ /**
35
+ * PVE's interface types. `unknown` is what a NIC it does not manage reports — node-b and node-c
36
+ * both report `wlan0` that way, and node-d has no such interface at all.
37
+ *
38
+ * ⚠️ THAT ASYMMETRY IS A TRAP FOR A DECLARATION SHARED ACROSS NODES. On node-d the read answers
39
+ * 400 ("interface does not exist"), which this provider cannot tell from "deleted", so
40
+ * reconcile POSTs — writing a stanza for a card that is not in the machine. Declare per node
41
+ * what each node actually has; a physical interface is discovered, not decided.
42
+ */
43
+ export type NodeNetworkType =
44
+ | 'OVSBond'
45
+ | 'OVSBridge'
46
+ | 'OVSIntPort'
47
+ | 'OVSPort'
48
+ | 'alias'
49
+ | 'bond'
50
+ | 'bridge'
51
+ | 'eth'
52
+ | 'fabric'
53
+ | 'unknown'
54
+ | 'vlan'
55
+ | 'vnet';
56
+
57
+ /**
58
+ * ⛔ THE LAST SEVEN ARE REPORTED AND NEVER COMPARED — each is a MEASURED forever-diff, and the
59
+ * evidence for every one of them is below, beside the code that reads them.
60
+ */
61
+ export interface NodeNetworkAttributes {
62
+ node: string;
63
+ iface: string;
64
+ type: string;
65
+ cidr: string;
66
+ gateway: string;
67
+ autostart: boolean;
68
+ mtu: number;
69
+ comments: string;
70
+ bridge_ports: string;
71
+ bridge_vids: string;
72
+ bridge_vlan_aware: boolean;
73
+ slaves: string;
74
+ bond_mode: string;
75
+ bond_xmit_hash_policy: string;
76
+ 'bond-primary': string;
77
+ 'vlan-id': number;
78
+ 'vlan-raw-device': string;
79
+ priority: number;
80
+ method: string;
81
+ families: string;
82
+ active: boolean;
83
+ exists: boolean;
84
+ bond_miimon: string;
85
+ bridge_stp: string;
86
+ bridge_fd: string;
87
+ }
88
+
89
+ /**
90
+ * One live interface as attributes. `live` is cast past `GetNodeNetworkResponse`'s own TS type
91
+ * (`{method, type}`) — MEASURED: the generated schema is a lower bound, not the full shape (PVE's
92
+ * response varies by interface type, and the apidoc this was generated from does not enumerate
93
+ * every field it can carry), but decoding does NOT strip the extras (Effect Schema's `S.Struct` is
94
+ * structural, not exact) — every field PVE actually returns survives to this object at runtime,
95
+ * confirmed against the full measured `vmbr0` response below.
96
+ *
97
+ * ⚠️ EVERY FIELD PVE REPORTS IS CARRIED HERE, INCLUDING THE SEVEN NO DECLARATION CAN SET. A plan
98
+ * that cannot show `priority`, `method` or `bond_miimon` cannot explain why it is ignoring them,
99
+ * and the next person to read a forever-diff would start by adding them to `matches`. They are
100
+ * reported precisely so that they are visibly OUT of it — see the ⛔ above the module header.
101
+ */
102
+ const attributesOf = (
103
+ live: unknown,
104
+ props: NodeNetworkProps,
105
+ ): NodeNetworkAttributes | undefined => {
106
+ const data = live as Record<string, unknown>;
107
+ /**
108
+ * ⛔ AN INTERFACE OF ANOTHER TYPE IS ANOTHER OBJECT, AND "ABSENT" IS THE SAFE ANSWER — the
109
+ * same call metric-server.ts makes, for a worse reason. `type` is required on the PUT and
110
+ * the PUT MERGES, so a declaration naming `bridge` for what is really a vlan would write
111
+ * `type bridge` into the stanza and PVE's writer would then emit bridge-ports and
112
+ * bridge-stp lines for vmbr1.42. Reporting absent instead makes reconcile POST a create,
113
+ * which PVE refuses with "interface already exists": loud, and it changes nothing.
114
+ */
115
+ const liveType = text(data['type']);
116
+ if (liveType !== '' && liveType !== props.type) return undefined;
117
+ return {
118
+ active: bool(data['active']),
119
+ autostart: bool(data['autostart']),
120
+ 'bond-primary': text(data['bond-primary']),
121
+ bond_miimon: text(data['bond_miimon']),
122
+ bond_mode: text(data['bond_mode']),
123
+ bond_xmit_hash_policy: text(data['bond_xmit_hash_policy']),
124
+ bridge_fd: text(data['bridge_fd']),
125
+ bridge_ports: ifaceList(data['bridge_ports']),
126
+ bridge_stp: text(data['bridge_stp']),
127
+ bridge_vids: ifaceList(data['bridge_vids']),
128
+ bridge_vlan_aware: bool(data['bridge_vlan_aware']),
129
+ /** ⚠️ DERIVED BY THE READER from address+netmask, and always present when an address is. */
130
+ cidr: text(data['cidr']),
131
+ comments: comment(data['comments']),
132
+ exists: bool(data['exists']),
133
+ families: Array.isArray(data['families']) ? data['families'].join(',') : '',
134
+ gateway: text(data['gateway']),
135
+ iface: props.iface,
136
+ method: text(data['method']),
137
+ mtu: int(data['mtu'], UNSET),
138
+ node: props.node,
139
+ priority: int(data['priority'], UNSET),
140
+ slaves: ifaceList(data['slaves']),
141
+ type: liveType === '' ? props.type : liveType,
142
+ 'vlan-id': int(data['vlan-id'], UNSET),
143
+ 'vlan-raw-device': text(data['vlan-raw-device']),
144
+ };
145
+ };
146
+
147
+ /**
148
+ * ★ Default `read` role — `Sys.Audit` on `/nodes/{node}` (node-network.ts's header).
149
+ * ⛔ `{ node: props.node, iface: props.iface }`, NEVER THE WHOLE `props` — storage.ts's/user.ts's
150
+ * own ⛔, the kit 0.31.1 regression: `GetNodeNetworkRequest`'s schema declares only `node` and
151
+ * `iface` (both path labels); any OTHER key on the object passed to `nodes.getNodeNetwork` is
152
+ * treated by distilled's `buildRequest` as an "unknown key" and JSON-encoded onto this bodyless
153
+ * GET, which a stricter fetch client refuses outright.
154
+ * ⛔ A MISSING INTERFACE IS A 400, NOT A 500 AND NOT A 404 — MEASURED against the live cluster
155
+ * (TB4 `n2`, read-role, read-only probe, 2026-09-24): `GET /nodes/n2/network/vmbr9` answers
156
+ * `{"errors":{"iface":"interface does not exist"},"data":null,"message":"Parameter verification
157
+ * failed.\n"}` at HTTP 400 — a THIRD shape, different from every other migrated family's
158
+ * measured 500. `@distilled.cloud/proxmox` types this as a genuine typed error,
159
+ * `ParameterVerificationFailed` (`errors.ts`), carrying the per-field `errors` object rather
160
+ * than collapsing it to an opaque `BadRequest` — PVE's OWN 400 is never one shape (it is
161
+ * whatever the endpoint's parameter verification rejected), so `errors.ts`'s own header spells
162
+ * out why this ONE typed error is global rather than per-operation.
163
+ * ⛔ ONLY THIS EXACT SIGNAL MEANS ABSENT, EVEN WITH THE FOLD BELOW. A different 400 reason, a
164
+ * permission gap, a network blip, or ANY OTHER `ParameterVerificationFailed` reason is never
165
+ * read as "this interface is not there" — `diff` calls this directly and propagates every one
166
+ * of them loudly.
167
+ */
168
+ export const readInterfaceOrFail = (props: NodeNetworkProps) =>
169
+ readOrUnreadable(
170
+ runPve(
171
+ props.target,
172
+ 'read',
173
+ false,
174
+ 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
+ ),
182
+ ).pipe(
183
+ Effect.map((live) => {
184
+ if (live === UNREADABLE) return UNREADABLE;
185
+ return live === undefined ? undefined : attributesOf(live, props);
186
+ }),
187
+ );
188
+
189
+ /**
190
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `read`'s adoption/recovery branch
191
+ * AND `reconcile` MAY USE IT — FOUND BY ADVERSARIAL REVIEW, 2026-09-24: a first draft used
192
+ * `readInterfaceOrFail` (precise, non-folding) for EVERY caller, on the reasoning that a typed,
193
+ * parsed absence signal made folding unnecessary. That reasoning conflated two different
194
+ * questions: HOW PRECISELY a signal means absent (this family's is unusually precise), and
195
+ * WHETHER a given caller can safely treat an UNRELATED failure as "proceed as though absent"
196
+ * (storage.ts's/user.ts's own reasoning: a wrongful fold costs at most a redundant
197
+ * `createNodeNetwork` POST, refused loudly). Plan.ts's cold-start adoption probe and
198
+ * interrupted-create recovery, and Apply.ts's delete recovery, all call `read` with
199
+ * `output: undefined` and NO catch of their own (`Plan.ts` v2.0.0-beta.79 ~line 1308) — and
200
+ * Plan.ts fails the WHOLE PLAN, every other resource included, if that call throws. A malformed
201
+ * `iface` in ONE brand-new declaration (a DIFFERENT `ParameterVerificationFailed` than the
202
+ * absence one) would abort `bun run plan` for the entire stack under the precise-only design,
203
+ * where every other migrated family instead defers that same malformed declaration to a scoped,
204
+ * loud refusal at apply. This restores that same fold for those three call sites.
205
+ */
206
+ export const readInterface = (props: NodeNetworkProps) =>
207
+ readInterfaceOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
208
+
209
+ /** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
210
+ export const dropUnreadable = (live: NodeNetworkAttributes | Unreadable | undefined) =>
211
+ live === UNREADABLE ? undefined : live;
212
+
213
+ /**
214
+ * ⛔ `cidr` IS THE ONLY FIELD COMPARED WHEN UNDECLARED, AND THAT ASYMMETRY IS THE POINT. Every
215
+ * other field here follows storage.ts: undeclared means unmanaged, so it is neither sent nor
216
+ * compared and a plan cannot report an update no write could satisfy. `cidr` cannot be
217
+ * unmanaged, because PVE derives `method` from the presence of an address IN THE FORM — an
218
+ * omission is an edit. Comparing it unconditionally is what makes that edit visible in `plan`
219
+ * instead of at `ifreload` time.
220
+ *
221
+ * ⚠️ NOTHING IN THE REPORTED-ONLY BLOCK IS HERE, and that is what makes the C1 declaration
222
+ * plan as `noop` on all three nodes at once — the module header lists them and the
223
+ * measurement behind each.
224
+ */
225
+ export const matches = (attributes: NodeNetworkAttributes, props: NodeNetworkProps) =>
226
+ attributes.cidr === (props.cidr ?? '') &&
227
+ same(props.gateway, attributes.gateway) &&
228
+ same(props.autostart, attributes.autostart) &&
229
+ same(props.mtu, attributes.mtu) &&
230
+ sameComment(props.comments, attributes.comments) &&
231
+ sameList(props.bridge_ports, attributes.bridge_ports) &&
232
+ sameList(props.bridge_vids, attributes.bridge_vids) &&
233
+ same(props.bridge_vlan_aware, attributes.bridge_vlan_aware) &&
234
+ sameList(props.slaves, attributes.slaves) &&
235
+ same(props.bond_mode, attributes.bond_mode) &&
236
+ same(props.bond_xmit_hash_policy, attributes.bond_xmit_hash_policy) &&
237
+ same(props['bond-primary'], attributes['bond-primary']) &&
238
+ same(props['vlan-id'], attributes['vlan-id']) &&
239
+ same(props['vlan-raw-device'], attributes['vlan-raw-device']);