@homeflare/alchemy 0.33.0 → 0.35.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 (86) 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/alerting-provenance.d.ts +80 -0
  10. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  11. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  12. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  13. package/dist/grafana/contact-point.d.ts +99 -0
  14. package/dist/grafana/contact-point.d.ts.map +1 -0
  15. package/dist/grafana/dashboard-model.d.ts +8 -38
  16. package/dist/grafana/dashboard-model.d.ts.map +1 -1
  17. package/dist/grafana/datasource.d.ts +2 -9
  18. package/dist/grafana/datasource.d.ts.map +1 -1
  19. package/dist/grafana/index.d.ts +11 -4
  20. package/dist/grafana/index.d.ts.map +1 -1
  21. package/dist/grafana/index.js +339 -80
  22. package/dist/grafana/index.js.map +16 -7
  23. package/dist/grafana/message-template.d.ts +70 -0
  24. package/dist/grafana/message-template.d.ts.map +1 -0
  25. package/dist/grafana/mute-timing.d.ts +112 -0
  26. package/dist/grafana/mute-timing.d.ts.map +1 -0
  27. package/dist/grafana/providers.d.ts +1 -1
  28. package/dist/grafana/providers.d.ts.map +1 -1
  29. package/dist/grafana/secret-refs.d.ts +19 -0
  30. package/dist/grafana/secret-refs.d.ts.map +1 -0
  31. package/dist/grafana/subset-match.d.ts +2 -0
  32. package/dist/grafana/subset-match.d.ts.map +1 -0
  33. package/dist/linux/index.d.ts +1 -1
  34. package/dist/linux/index.d.ts.map +1 -1
  35. package/dist/linux/index.js +3 -1
  36. package/dist/linux/index.js.map +1 -1
  37. package/dist/proxmox/index.js +349 -287
  38. package/dist/proxmox/index.js.map +8 -7
  39. package/dist/proxmox/network-apply-read.d.ts +12 -0
  40. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  41. package/dist/proxmox/network-apply.d.ts +2 -0
  42. package/dist/proxmox/network-apply.d.ts.map +1 -1
  43. package/dist/proxmox/node-network-form.d.ts +86 -54
  44. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  45. package/dist/proxmox/node-network-wire.d.ts +100 -0
  46. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  47. package/dist/proxmox/node-network.d.ts +21 -106
  48. package/dist/proxmox/node-network.d.ts.map +1 -1
  49. package/docs/grafana-alerting-rules.md +143 -0
  50. package/docs/grafana-alerting.md +147 -0
  51. package/docs/grafana.md +6 -3
  52. package/docs/upstream-conformance.md +38 -4
  53. package/package.json +1 -1
  54. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  55. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  56. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  57. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  58. package/src/grafana/alert-rule-group-model.ts +66 -0
  59. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  60. package/src/grafana/alert-rule-group.test.ts +164 -0
  61. package/src/grafana/alert-rule-group.ts +231 -0
  62. package/src/grafana/alerting-provenance.ts +109 -0
  63. package/src/grafana/contact-point-fixtures.ts +25 -0
  64. package/src/grafana/contact-point-provenance.test.ts +113 -0
  65. package/src/grafana/contact-point-secrets.test.ts +117 -0
  66. package/src/grafana/contact-point.test.ts +123 -0
  67. package/src/grafana/contact-point.ts +179 -0
  68. package/src/grafana/dashboard-model.ts +8 -57
  69. package/src/grafana/datasource.ts +5 -27
  70. package/src/grafana/index.ts +31 -4
  71. package/src/grafana/message-template.test.ts +212 -0
  72. package/src/grafana/message-template.ts +114 -0
  73. package/src/grafana/mute-timing.test.ts +227 -0
  74. package/src/grafana/mute-timing.ts +160 -0
  75. package/src/grafana/providers.ts +8 -0
  76. package/src/grafana/secret-refs.ts +48 -0
  77. package/src/grafana/subset-match.ts +71 -0
  78. package/src/linux/index-export.test.ts +25 -0
  79. package/src/linux/index.ts +15 -1
  80. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  81. package/src/proxmox/network-apply-read.ts +12 -0
  82. package/src/proxmox/network-apply.ts +2 -0
  83. package/src/proxmox/node-network-form.ts +93 -99
  84. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  85. package/src/proxmox/node-network-wire.ts +239 -0
  86. package/src/proxmox/node-network.ts +155 -167
@@ -1,32 +1,66 @@
1
1
  /**
2
- * How a node interface crosses the wire in both directions — props to a PVE form, a PVE answer to
3
- * a comparable attribute — and when two of those values count as the same value.
4
- *
5
- * ★ SPLIT OUT OF node-network.ts TO KEEP BOTH FILES UNDER THE 250-LINE CAP, and the seam is the
6
- * same one metric-server-form.ts uses, one field wider: this file owns the COERCIONS, in both
7
- * directions, because a read that normalises differently from the write that produced it is
8
- * exactly how a forever-diff is born — keeping `ifaceList` next to both ends is what stops that.
9
- * node-network.ts answers "what is an interface, and which of its fields are safe to compare at
10
- * all". Nothing here calls the cluster.
11
- *
12
- * ⚠️ THE `import type` BACK TO node-network.ts IS A CYCLE ON PAPER ONLY — type-only, erased before
13
- * anything runs, so `NodeNetworkProps` stays in the file that declares the resource.
14
- *
15
- * * ⛔ THE LAST SEVEN ARE REPORTED AND NEVER COMPARED, AND EACH ONE IS A MEASURED FOREVER-DIFF.
16
- * `priority` is assigned by PVE FROM FILE ORDER — `my $priority = 2; ... $d->{priority} =
17
- * $priority++` — and it is not a POST or PUT parameter at all. MEASURED ACROSS C1: vmbr1.42 is
18
- * priority 16 on node-b and node-c and 15 on node-d, because node-d has no `wlan0` above it. One declaration
19
- * reused across three nodes would diff on exactly one of them, forever, over nothing.
20
- * `method` is RECOMPUTED on every write from whether the form carried an address, `families` is
21
- * recomputed the same way and never written to the file, `active` and `exists` describe the
22
- * kernel rather than the config, and `bond_miimon`, `bridge_stp` and `bridge_fd` are emitted by
23
- * PVE's writer with defaults (100, `off`, 0) while appearing in NEITHER write schema —
24
- * `additionalProperties => 0` means sending one is a 400. Live proof of all three: node-b's bond0
25
- * returns `bond_miimon "100"` and vmbr1 returns `bridge_stp "off"`, `bridge_fd "0"`, none of
26
- * which any declaration can set.
2
+ * `Proxmox.NodeNetwork`'s declared shape (`NodeNetworkProps`) and WRITE side, plus the wire
3
+ * coercions the read side needs too — kept here since neither `matches` nor `updateForm` makes
4
+ * sense without agreeing on the same normalisation. Split out of node-network.ts (2026-09-24,
5
+ * the distilled migration; `Props` joined it in this PR's later line-cap trim) — the
6
+ * storage.ts/storage-form.ts seam.
27
7
  */
28
- import type { NodeNetworkAttributes, NodeNetworkProps } from './node-network.ts';
29
- import { bool, flag, int, text, withClears } from './values.ts';
8
+ import type { NodeNetworkType } from './node-network-wire.ts';
9
+ import { type WithTarget } from './resource-spec.ts';
10
+ import { flag, text, withClears } from './values.ts';
11
+
12
+ export interface NodeNetworkProps extends WithTarget {
13
+ /** Which node's file this stanza lives in. Interfaces are per node, never cluster-wide. */
14
+ node: string;
15
+ /** `vmbr0`, `bond0`, `vmbr1.42`. 2-20 characters, PVE's `pve-iface` format. */
16
+ iface: string;
17
+ /** ⛔ REQUIRED ON EVERY WRITE, update included, never used to retype — see node-network-wire.ts. */
18
+ type: NodeNetworkType;
19
+ /**
20
+ * `198.51.100.12/24`. ⛔ THE ONE FIELD WITH NO UNMANAGED MODE: leaving it out is an instruction
21
+ * to make the interface `manual`, not an instruction to leave its address alone. The ⛔ on
22
+ * `updateForm` in node-network-form.ts has the measurement and the consequence.
23
+ */
24
+ cidr?: string;
25
+ /** ⚠️ PVE allows exactly ONE default gateway per node and refuses a second with "Default
26
+ * gateway already exists on interface '<other>'". On C1 it is vmbr0.41's. */
27
+ gateway?: string;
28
+ /**
29
+ * `auto <iface>` in the file. Absent on read means off, which is why `bool`'s fallback is used.
30
+ * ⚠️ UNDECLARED IS UNMANAGED ON AN UPDATE AND OFF ON A CREATE — the same asymmetry storage.ts
31
+ * has, and it bites harder here: an interface created without `autostart` is one the node
32
+ * will not bring up at boot. Every C1 bridge, bond and vlan carries it.
33
+ */
34
+ autostart?: boolean;
35
+ /** 1280-65520. Unset leaves the file without an `mtu` line and the kernel default in force. */
36
+ mtu?: number;
37
+ /** ⚠️ Round-trips only after normalisation — see `comment` in node-network-wire.ts. */
38
+ comments?: string;
39
+ /** Space-separated, a SET: `enp87s0`, or `bond0`. ⚠️ PVE refuses a port already used elsewhere. */
40
+ bridge_ports?: string;
41
+ /** `2-4094`, or `2 100-200`. Only written when `bridge_vlan_aware` is on. */
42
+ bridge_vids?: string;
43
+ /** ⛔ A `false` here is sent as `delete=`, never as `0` — node-network-form.ts's ⛔ says why. */
44
+ bridge_vlan_aware?: boolean;
45
+ /**
46
+ * A bond's members, space separated. ⚠️ THE PARAMETER IS `slaves`, NOT `bond_slaves`, in BOTH
47
+ * directions on this PVE: MEASURED, the POST/PUT schema names only `slaves` and the GET returns
48
+ * `"slaves":"enp2s0f0np0 enp2s0f1np1"`. `bond_slaves` appears nowhere in Network.pm here.
49
+ */
50
+ slaves?: string;
51
+ bond_mode?: string;
52
+ /** ⚠️ Only written when `bond_mode` is `balance-xor` or `802.3ad`; ignored otherwise. */
53
+ bond_xmit_hash_policy?: string;
54
+ /** active-backup only. Kept hyphenated because that is the wire name. */
55
+ 'bond-primary'?: string;
56
+ /** ⚠️ DERIVED FROM A DOTTED NAME. `vmbr1.42` reports `vlan-id` 42 with no such line in the file;
57
+ * declaring it there is harmless but adds a line the file did not have. */
58
+ 'vlan-id'?: number;
59
+ 'vlan-raw-device'?: string;
60
+ }
61
+
62
+ export const NODE_NETWORK_CREATE = 'pve:POST /nodes/{node}/network';
63
+ export const NODE_NETWORK_UPDATE = 'pve:PUT /nodes/{node}/network/{iface}';
30
64
 
31
65
  /**
32
66
  * "Not set", for the three integers this family reports.
@@ -78,7 +112,7 @@ export const ifaceList = (value: unknown) =>
78
112
  */
79
113
  export const comment = (value: unknown) => text(value).replace(/\s+$/, '');
80
114
 
81
- /** Undeclared is unmanaged: neither sent nor compared. See the ⚠️ on `body`. */
115
+ /** Undeclared is unmanaged: neither sent nor compared. */
82
116
  export const same = <T>(declared: T | undefined, live: T) =>
83
117
  declared === undefined || declared === live;
84
118
 
@@ -97,12 +131,13 @@ const field = (name: string, value: string | undefined): Record<string, string>
97
131
  * ⚠️ UNDECLARED IS UNMANAGED — not sent, and not compared either. PVE's PUT MERGES the form into
98
132
  * the existing stanza (`foreach my $k (keys %$param) { $ifaces->{$iface}->{$k} = $param->{$k} }`),
99
133
  * so an omitted parameter is left exactly as it was. `cidr` is the one exception and it is not
100
- * a small one — see the ⛔ in node-network.ts.
134
+ * a small one — see node-network.ts's own ⛔.
101
135
  *
102
136
  * ⛔ `type` IS REQUIRED ON THE PUT, NOT ONLY ON THE POST. It carries no `optional` in either
103
- * schema, and the merge above writes it straight into the stanza — so a PUT naming the wrong
104
- * type would RETYPE A LIVE INTERFACE in the file and the writer would then emit a bridge stanza
105
- * for what is really a vlan. node-network.ts refuses that case before it can reach here.
137
+ * schema (CONFIRMED again in distilled's generated `PutNodeNetwork2Request`/
138
+ * `CreateNodeNetworkRequest`), and the merge above writes it straight into the stanza — so a PUT
139
+ * naming the wrong type would RETYPE A LIVE INTERFACE in the file. node-network-wire.ts's
140
+ * `attributesOf` refuses that case before it can reach here.
106
141
  *
107
142
  * ⛔ `bridge_vlan_aware` IS SENT ONLY WHEN TRUE, AND A FALSE ONE IS A `delete=`. MEASURED in the
108
143
  * writer: the test is `if (defined($d->{bridge_vlan_aware}))`, not a truth test, so `0` is
@@ -113,6 +148,14 @@ const field = (name: string, value: string | undefined): Record<string, string>
113
148
  * "netmask conflicts with cidr" when both are sent, and the reader rewrites whatever was written
114
149
  * into prefix form — MEASURED: node-b's vmbr1.42 reports `"netmask":"24"`, never `255.255.255.0`.
115
150
  * A resource accepting both spellings would let a declaration diff against its own value.
151
+ *
152
+ * ★ HYPHENATED, THE WIRE SHAPE — `guardWrite` (node-network.ts) checks this form directly, keyed
153
+ * by PVE's own names (`generated/constraints/pve-nodes-network.ts`). `toDistilledCreate`/
154
+ * `toDistilledUpdate` below translate a COPY for the actual SDK call — storage-form.ts's
155
+ * `underscored` has the fuller measurement of why the wire itself does not need this rename
156
+ * (distilled's own `T.Body()` annotation re-hyphenates on the way out either way), reused here
157
+ * only for the three fields this family's schema renames: `bond-primary`, `vlan-id`,
158
+ * `vlan-raw-device` -> `bond_primary`, `vlan_id`, `vlan_raw_device`.
116
159
  */
117
160
  export const body = (props: NodeNetworkProps): Record<string, string> => ({
118
161
  ...field('autostart', flag(props.autostart)),
@@ -135,18 +178,18 @@ export const body = (props: NodeNetworkProps): Record<string, string> => ({
135
178
  /**
136
179
  * The create form: `body` plus the name of the interface being created.
137
180
  *
138
- * 🔴 `iface` WAS MISSING UNTIL 2026-09-22, AND EVERY CREATE THIS FAMILY HAS EVER PLANNED WOULD
139
- * HAVE 400ed. `POST /nodes/{node}/network` declares `iface` REQUIRED (`pve-iface`, 2..20) and
140
- * `{node}` is its only path parameter, so the name has to travel in the BODY — unlike the PUT,
141
- * where `/network/{iface}` carries it and `updateBody` is right to leave it out. Nothing caught
142
- * it because no NodeNetwork has been created from a declaration yet: C1's three nodes were all
143
- * adopted, which takes the PUT path. Found by wiring this family to the vendor's own table,
144
- * which is exactly the 128-character comment again with a different parameter.
181
+ * 🔴 `iface` MUST BE IN THE BODY, NOT ONLY THE PATH — `POST /nodes/{node}/network` declares
182
+ * `iface` REQUIRED (`pve-iface`, 2..20) and `{node}` is its only path parameter, CONFIRMED again
183
+ * in distilled's generated `CreateNodeNetworkRequest`, which carries `iface` as a plain body
184
+ * field, never a `T.Label()`. The pre-distilled client.ts version of this file shipped without
185
+ * it for a time (see the git history around 2026-09-22) — every create this family had ever
186
+ * planned would have 400ed, caught only because every C1 interface had been adopted, never
187
+ * created, so nothing exercised the POST until the vendor table was wired in.
145
188
  *
146
189
  * ⚠️ NOT ADDED TO `body`, BECAUSE THE PUT MUST NOT CARRY IT. `additionalProperties => 0` on the
147
190
  * update schema makes a second copy of the name a 400 rather than an ignored hint.
148
191
  */
149
- export const createBody = (props: NodeNetworkProps): Record<string, string> => ({
192
+ export const createForm = (props: NodeNetworkProps): Record<string, string> => ({
150
193
  ...body(props),
151
194
  iface: props.iface,
152
195
  });
@@ -159,74 +202,25 @@ export const createBody = (props: NodeNetworkProps): Record<string, string> => (
159
202
  * `$param->{method} = $param->{address} ? 'static' : 'manual'` — so a PUT WITHOUT an address
160
203
  * makes the interface manual no matter what the caller intended. Left at that, the merge would
161
204
  * leave the old `address` and `netmask` in the hash and the writer would emit an `address` line
162
- * under `iface … inet manual`: a stanza PVE's own UI cannot produce, whose behaviour under
163
- * `ifreload -a` I did NOT measure. Clearing cidr explicitly at least produces a clean, honest
164
- * manual interface — and `matches` compares `cidr` UNCONDITIONALLY, so the plan says so first.
205
+ * under `iface … inet manual`: a stanza PVE's own UI cannot produce. Clearing cidr explicitly at
206
+ * least produces a clean, honest manual interface — and `matches` compares `cidr`
207
+ * UNCONDITIONALLY, so the plan says so first.
165
208
  *
166
209
  * ⛔ ON vmbr1.42 THAT IS THE CEPH TRANSPORT. A declaration of that interface without its `cidr` is
167
210
  * a declaration that it should have no address, applied across three nodes. The plan will read
168
211
  * `1 to update` rather than `noop`; do not wave it through.
169
212
  *
170
- * ⚠️ `delete` IS A PUT-ONLY PARAMETER. The POST schema is `additionalProperties => 0` and has no
171
- * `delete`, so sending it on a create is a 400 — which is why `createForm` calls `body` directly.
213
+ * ⚠️ `delete` IS A PUT-ONLY PARAMETER. The POST schema has no `delete` at all, which is why
214
+ * `createForm` calls `body` directly rather than going through this function.
172
215
  */
173
- export const updateBody = (props: NodeNetworkProps): Record<string, string> => {
216
+ export const updateForm = (props: NodeNetworkProps): Record<string, string> => {
174
217
  const clear = [
175
218
  ...(props.cidr === undefined ? ['cidr'] : []),
176
219
  ...(props.bridge_vlan_aware === false ? ['bridge_vlan_aware'] : []),
177
220
  ];
178
- const fields = body(props);
179
- return withClears(fields, clear);
221
+ return withClears(body(props), clear);
180
222
  };
181
223
 
182
- /**
183
- * One live interface as attributes, or `undefined` — "this is not really there".
184
- *
185
- * ⚠️ EVERY FIELD PVE REPORTS IS CARRIED HERE, INCLUDING THE SEVEN NO DECLARATION CAN SET. A plan
186
- * that cannot show `priority`, `method` or `bond_miimon` cannot explain why it is ignoring them,
187
- * and the next person to read a forever-diff would start by adding them to `matches`. They are
188
- * reported precisely so that they are visibly OUT of it — see the ⛔ above the attribute type.
189
- */
190
- export const readAttributes = (
191
- live: Record<string, unknown>,
192
- props: NodeNetworkProps,
193
- ): NodeNetworkAttributes | undefined => {
194
- /**
195
- * ⛔ AN INTERFACE OF ANOTHER TYPE IS ANOTHER OBJECT, AND "ABSENT" IS THE SAFE ANSWER — the
196
- * same call metric-server.ts makes, for a worse reason. `type` is required on the PUT and
197
- * the PUT MERGES, so a declaration naming `bridge` for what is really a vlan would write
198
- * `type bridge` into the stanza and PVE's writer would then emit bridge-ports and
199
- * bridge-stp lines for vmbr1.42. Reporting absent instead makes reconcile POST a create,
200
- * which PVE refuses with "interface already exists": loud, and it changes nothing.
201
- */
202
- const liveType = text(live['type']);
203
- if (liveType !== '' && liveType !== props.type) return undefined;
204
- return {
205
- active: bool(live['active']),
206
- autostart: bool(live['autostart']),
207
- 'bond-primary': text(live['bond-primary']),
208
- bond_miimon: text(live['bond_miimon']),
209
- bond_mode: text(live['bond_mode']),
210
- bond_xmit_hash_policy: text(live['bond_xmit_hash_policy']),
211
- bridge_fd: text(live['bridge_fd']),
212
- bridge_ports: ifaceList(live['bridge_ports']),
213
- bridge_stp: text(live['bridge_stp']),
214
- bridge_vids: ifaceList(live['bridge_vids']),
215
- bridge_vlan_aware: bool(live['bridge_vlan_aware']),
216
- /** ⚠️ DERIVED BY THE READER from address+netmask, and always present when an address is. */
217
- cidr: text(live['cidr']),
218
- comments: comment(live['comments']),
219
- exists: bool(live['exists']),
220
- families: Array.isArray(live['families']) ? live['families'].join(',') : '',
221
- gateway: text(live['gateway']),
222
- iface: props.iface,
223
- method: text(live['method']),
224
- mtu: int(live['mtu'], UNSET),
225
- node: props.node,
226
- priority: int(live['priority'], UNSET),
227
- slaves: ifaceList(live['slaves']),
228
- type: liveType === '' ? props.type : liveType,
229
- 'vlan-id': int(live['vlan-id'], UNSET),
230
- 'vlan-raw-device': text(live['vlan-raw-device']),
231
- };
232
- };
224
+ // ⚠️ `toDistilledCreate`/`toDistilledUpdate` MOVED TO node-network.ts IN THIS PR'S LINE-CAP
225
+ // TRIM — the only caller, and the only place `nodes.CreateNodeNetworkRequest`/
226
+ // `PutNodeNetwork2Request` were otherwise needed. `createForm`/`updateForm` above are unchanged.
@@ -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
+ });