@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.
- package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
- package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-model.d.ts +28 -0
- package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group.d.ts +60 -0
- package/dist/grafana/alert-rule-group.d.ts.map +1 -0
- package/dist/grafana/alerting-provenance.d.ts +80 -0
- package/dist/grafana/alerting-provenance.d.ts.map +1 -0
- package/dist/grafana/contact-point-fixtures.d.ts +28 -0
- package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
- package/dist/grafana/contact-point.d.ts +99 -0
- package/dist/grafana/contact-point.d.ts.map +1 -0
- package/dist/grafana/dashboard-model.d.ts +8 -38
- package/dist/grafana/dashboard-model.d.ts.map +1 -1
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +11 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +339 -80
- package/dist/grafana/index.js.map +16 -7
- package/dist/grafana/message-template.d.ts +70 -0
- package/dist/grafana/message-template.d.ts.map +1 -0
- package/dist/grafana/mute-timing.d.ts +112 -0
- package/dist/grafana/mute-timing.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/secret-refs.d.ts +19 -0
- package/dist/grafana/secret-refs.d.ts.map +1 -0
- package/dist/grafana/subset-match.d.ts +2 -0
- package/dist/grafana/subset-match.d.ts.map +1 -0
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +3 -1
- package/dist/linux/index.js.map +1 -1
- package/dist/proxmox/index.js +349 -287
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/network-apply-read.d.ts +12 -0
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply.d.ts +2 -0
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-form.d.ts +86 -54
- package/dist/proxmox/node-network-form.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +100 -0
- package/dist/proxmox/node-network-wire.d.ts.map +1 -0
- package/dist/proxmox/node-network.d.ts +21 -106
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/docs/grafana-alerting-rules.md +143 -0
- package/docs/grafana-alerting.md +147 -0
- package/docs/grafana.md +6 -3
- package/docs/upstream-conformance.md +38 -4
- package/package.json +1 -1
- package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
- package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
- package/src/grafana/alert-rule-group-fixtures.ts +46 -0
- package/src/grafana/alert-rule-group-model.test.ts +75 -0
- package/src/grafana/alert-rule-group-model.ts +66 -0
- package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
- package/src/grafana/alert-rule-group.test.ts +164 -0
- package/src/grafana/alert-rule-group.ts +231 -0
- package/src/grafana/alerting-provenance.ts +109 -0
- package/src/grafana/contact-point-fixtures.ts +25 -0
- package/src/grafana/contact-point-provenance.test.ts +113 -0
- package/src/grafana/contact-point-secrets.test.ts +117 -0
- package/src/grafana/contact-point.test.ts +123 -0
- package/src/grafana/contact-point.ts +179 -0
- package/src/grafana/dashboard-model.ts +8 -57
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/index.ts +31 -4
- package/src/grafana/message-template.test.ts +212 -0
- package/src/grafana/message-template.ts +114 -0
- package/src/grafana/mute-timing.test.ts +227 -0
- package/src/grafana/mute-timing.ts +160 -0
- package/src/grafana/providers.ts +8 -0
- package/src/grafana/secret-refs.ts +48 -0
- package/src/grafana/subset-match.ts +71 -0
- package/src/linux/index-export.test.ts +25 -0
- package/src/linux/index.ts +15 -1
- package/src/proxmox/constraints-host-forms.test.ts +2 -2
- package/src/proxmox/network-apply-read.ts +12 -0
- package/src/proxmox/network-apply.ts +2 -0
- package/src/proxmox/node-network-form.ts +93 -99
- package/src/proxmox/node-network-read-failure.test.ts +206 -0
- package/src/proxmox/node-network-wire.ts +239 -0
- package/src/proxmox/node-network.ts +155 -167
|
@@ -1,32 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
29
|
-
import {
|
|
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.
|
|
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
|
|
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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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`
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* it
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
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
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
171
|
-
* `
|
|
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
|
|
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
|
-
|
|
179
|
-
return withClears(fields, clear);
|
|
221
|
+
return withClears(body(props), clear);
|
|
180
222
|
};
|
|
181
223
|
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
});
|