@homeflare/alchemy 0.34.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/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +241 -139
- package/dist/grafana/index.js.map +11 -8
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.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 +5 -3
- package/docs/grafana.md +4 -2
- package/docs/upstream-conformance.md +16 -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/index.ts +11 -4
- package/src/grafana/providers.ts +2 -0
- 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
|
@@ -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']);
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* that apply — this one never calls it, exactly as `Proxmox.SdnZone` never calls
|
|
12
12
|
* `Proxmox.SdnApply`.
|
|
13
13
|
*
|
|
14
|
+
* ⛔ `Proxmox.NetworkApply` STAYS ON `client.ts`, NOT MIGRATED WITH THIS FILE — network-apply-
|
|
15
|
+
* read.ts's own header has the two MEASURED reasons distilled cannot represent yet.
|
|
16
|
+
*
|
|
14
17
|
* ⛔ CEPH ON C1 RIDES TWO NETWORKS AND THIS API ONLY SEES ONE OF THEM. Measured from
|
|
15
18
|
* `/etc/pve/ceph.conf` on 2026-09-13:
|
|
16
19
|
*
|
|
@@ -18,7 +21,8 @@
|
|
|
18
21
|
* cluster_network = 203.0.113.0/24 -> every OSD's cluster_addr: .102 / .103 / .104
|
|
19
22
|
*
|
|
20
23
|
* The cluster network — OSD REPLICATION, the traffic that rebuilds a lost replica — lives on
|
|
21
|
-
* `203.0.113.10X/32` addresses carried by `tb0`, `tb1` and `dummy_c1`: the Thunderbolt mesh
|
|
24
|
+
* `203.0.113.10X/32` addresses carried by `tb0`, `tb1` and `dummy_c1`: the Thunderbolt mesh —
|
|
25
|
+
* TB4's own fabric ports, EXCLUDED from this family by decision 28/29/9 (SdnFabric owns them).
|
|
22
26
|
*
|
|
23
27
|
* ⛔ AND THOSE ADDRESSES ARE INVISIBLE HERE, WHICH IS THE TRAP. They are declared in
|
|
24
28
|
* `/etc/network/interfaces.d/sdn`, and PVE's network API does not parse that directory.
|
|
@@ -34,8 +38,9 @@
|
|
|
34
38
|
* owns them, not this resource.
|
|
35
39
|
*
|
|
36
40
|
* ⚠️ Two ways this file limits the damage on the interfaces it CAN see: `cidr` is compared
|
|
37
|
-
* unconditionally (
|
|
38
|
-
* declaration is reported ABSENT rather than retyped
|
|
41
|
+
* unconditionally (node-network-wire.ts's own ⛔ on `matches`), and a live interface whose
|
|
42
|
+
* `type` disagrees with the declaration is reported ABSENT rather than retyped
|
|
43
|
+
* (node-network-wire.ts's `attributesOf`).
|
|
39
44
|
*
|
|
40
45
|
* ★ THE REASON IT EXISTS IS THAT THE STAGED READ CANNOT TELL YOU THE NETWORK IS RIGHT — it can
|
|
41
46
|
* only tell you the FILE is. Once anything is staged, `GET /nodes/{node}/network/{iface}`
|
|
@@ -48,11 +53,9 @@
|
|
|
48
53
|
* object to scope to, so that also buys the node's services, certificates, DNS and time.
|
|
49
54
|
* Collected with every other family in docs/privileges.md.
|
|
50
55
|
*
|
|
51
|
-
* ★ A RECONCILE OVER A MATCHING INTERFACE IS A NO-WRITE
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* the ⛔ above that line names THIS family as the reason the guard was added — adopting an
|
|
55
|
-
* interface a node already has would otherwise leave it holding a pending network change.
|
|
56
|
+
* ★ A RECONCILE OVER A MATCHING INTERFACE IS A NO-WRITE. `reconcile` below gates the PUT on
|
|
57
|
+
* `matches` first — adopting an interface a node already has must not leave it holding a
|
|
58
|
+
* pending network change, since a PUT under `/nodes/{node}/network/{iface}` STAGES one.
|
|
56
59
|
*
|
|
57
60
|
* ⚠️ WHAT IS STILL TRUE: when `matches` is false the PUT rewrites the whole stanza, and that can
|
|
58
61
|
* ADD lines the file did not have — a `vlan-raw-device vmbr1` under `vmbr1.42`, where PVE had
|
|
@@ -64,124 +67,41 @@
|
|
|
64
67
|
* them would put Alchemy one `destroy` away from staging the removal of a node's uplink — and
|
|
65
68
|
* `delete_network` has NO in-use check at all: MEASURED, it deletes the hash entry and writes
|
|
66
69
|
* the file. Adoption stays explicit.
|
|
70
|
+
*
|
|
71
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` ONTO `@distilled.cloud/proxmox`'s
|
|
72
|
+
* typed `nodes.getNodeNetwork`/`createNodeNetwork`/`putNodeNetwork2`/`deleteNodeNetwork2`
|
|
73
|
+
* (2026-09-24, decision 43). Dual-path read and `read`'s `output`-branching wired like every
|
|
74
|
+
* other migrated family; node-network-wire.ts's `readInterfaceOrFail` has the new nuance.
|
|
67
75
|
*/
|
|
68
76
|
import { Resource } from 'alchemy';
|
|
77
|
+
import { isResolved } from 'alchemy/Diff';
|
|
69
78
|
import * as Provider from 'alchemy/Provider';
|
|
79
|
+
import * as nodes from '@distilled.cloud/proxmox/nodes';
|
|
70
80
|
import * as Effect from 'effect/Effect';
|
|
81
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
71
82
|
import {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
updateBody,
|
|
83
|
+
NODE_NETWORK_CREATE,
|
|
84
|
+
NODE_NETWORK_UPDATE,
|
|
85
|
+
type NodeNetworkProps,
|
|
86
|
+
createForm,
|
|
87
|
+
updateForm,
|
|
78
88
|
} from './node-network-form.ts';
|
|
79
|
-
import {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*/
|
|
90
|
-
export type NodeNetworkType =
|
|
91
|
-
| 'OVSBond'
|
|
92
|
-
| 'OVSBridge'
|
|
93
|
-
| 'OVSIntPort'
|
|
94
|
-
| 'OVSPort'
|
|
95
|
-
| 'alias'
|
|
96
|
-
| 'bond'
|
|
97
|
-
| 'bridge'
|
|
98
|
-
| 'eth'
|
|
99
|
-
| 'fabric'
|
|
100
|
-
| 'unknown'
|
|
101
|
-
| 'vlan'
|
|
102
|
-
| 'vnet';
|
|
103
|
-
|
|
104
|
-
export interface NodeNetworkProps extends WithTarget {
|
|
105
|
-
/** Which node's file this stanza lives in. Interfaces are per node, never cluster-wide. */
|
|
106
|
-
node: string;
|
|
107
|
-
/** `vmbr0`, `bond0`, `vmbr1.42`. 2-20 characters, PVE's `pve-iface` format. */
|
|
108
|
-
iface: string;
|
|
109
|
-
/** ⛔ REQUIRED ON EVERY WRITE, update included, never used to retype — see `readAttributes`. */
|
|
110
|
-
type: NodeNetworkType;
|
|
111
|
-
/**
|
|
112
|
-
* `198.51.100.12/24`. ⛔ THE ONE FIELD WITH NO UNMANAGED MODE: leaving it out is an instruction
|
|
113
|
-
* to make the interface `manual`, not an instruction to leave its address alone. The ⛔ on
|
|
114
|
-
* `updateBody` in node-network-form.ts has the measurement and the consequence.
|
|
115
|
-
*/
|
|
116
|
-
cidr?: string;
|
|
117
|
-
/** ⚠️ PVE allows exactly ONE default gateway per node and refuses a second with "Default
|
|
118
|
-
* gateway already exists on interface '<other>'". On C1 it is vmbr0.41's. */
|
|
119
|
-
gateway?: string;
|
|
120
|
-
/**
|
|
121
|
-
* `auto <iface>` in the file. Absent on read means off, which is why `bool`'s fallback is used.
|
|
122
|
-
* ⚠️ UNDECLARED IS UNMANAGED ON AN UPDATE AND OFF ON A CREATE — the same asymmetry storage.ts
|
|
123
|
-
* has, and it bites harder here: an interface created without `autostart` is one the node
|
|
124
|
-
* will not bring up at boot. Every C1 bridge, bond and vlan carries it.
|
|
125
|
-
*/
|
|
126
|
-
autostart?: boolean;
|
|
127
|
-
/** 1280-65520. Unset leaves the file without an `mtu` line and the kernel default in force. */
|
|
128
|
-
mtu?: number;
|
|
129
|
-
/** ⚠️ Round-trips only after normalisation — see `comment` in node-network-form.ts. */
|
|
130
|
-
comments?: string;
|
|
131
|
-
/** Space-separated, a SET: `enp87s0`, or `bond0`. ⚠️ PVE refuses a port already used elsewhere. */
|
|
132
|
-
bridge_ports?: string;
|
|
133
|
-
/** `2-4094`, or `2 100-200`. Only written when `bridge_vlan_aware` is on. */
|
|
134
|
-
bridge_vids?: string;
|
|
135
|
-
/** ⛔ A `false` here is sent as `delete=`, never as `0` — the ⛔ on `body` says why. */
|
|
136
|
-
bridge_vlan_aware?: boolean;
|
|
137
|
-
/**
|
|
138
|
-
* A bond's members, space separated. ⚠️ THE PARAMETER IS `slaves`, NOT `bond_slaves`, in BOTH
|
|
139
|
-
* directions on this PVE: MEASURED, the POST/PUT schema names only `slaves` and the GET returns
|
|
140
|
-
* `"slaves":"enp2s0f0np0 enp2s0f1np1"`. `bond_slaves` appears nowhere in Network.pm here.
|
|
141
|
-
*/
|
|
142
|
-
slaves?: string;
|
|
143
|
-
bond_mode?: string;
|
|
144
|
-
/** ⚠️ Only written when `bond_mode` is `balance-xor` or `802.3ad`; ignored otherwise. */
|
|
145
|
-
bond_xmit_hash_policy?: string;
|
|
146
|
-
/** active-backup only. Kept hyphenated because that is the wire name. */
|
|
147
|
-
'bond-primary'?: string;
|
|
148
|
-
/** ⚠️ DERIVED FROM A DOTTED NAME. `vmbr1.42` reports `vlan-id` 42 with no such line in the file;
|
|
149
|
-
* declaring it there is harmless but adds a line the file did not have. */
|
|
150
|
-
'vlan-id'?: number;
|
|
151
|
-
'vlan-raw-device'?: string;
|
|
152
|
-
}
|
|
89
|
+
import {
|
|
90
|
+
dropUnreadable,
|
|
91
|
+
matches,
|
|
92
|
+
readInterface,
|
|
93
|
+
readInterfaceOrFail,
|
|
94
|
+
} from './node-network-wire.ts';
|
|
95
|
+
import type { NodeNetworkAttributes } from './node-network-wire.ts';
|
|
96
|
+
import { runPve } from './distilled-pve.ts';
|
|
97
|
+
import { type PveRequirements } from './resource-spec.ts';
|
|
98
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
153
99
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
export
|
|
159
|
-
node: string;
|
|
160
|
-
iface: string;
|
|
161
|
-
type: string;
|
|
162
|
-
cidr: string;
|
|
163
|
-
gateway: string;
|
|
164
|
-
autostart: boolean;
|
|
165
|
-
mtu: number;
|
|
166
|
-
comments: string;
|
|
167
|
-
bridge_ports: string;
|
|
168
|
-
bridge_vids: string;
|
|
169
|
-
bridge_vlan_aware: boolean;
|
|
170
|
-
slaves: string;
|
|
171
|
-
bond_mode: string;
|
|
172
|
-
bond_xmit_hash_policy: string;
|
|
173
|
-
'bond-primary': string;
|
|
174
|
-
'vlan-id': number;
|
|
175
|
-
'vlan-raw-device': string;
|
|
176
|
-
priority: number;
|
|
177
|
-
method: string;
|
|
178
|
-
families: string;
|
|
179
|
-
active: boolean;
|
|
180
|
-
exists: boolean;
|
|
181
|
-
bond_miimon: string;
|
|
182
|
-
bridge_stp: string;
|
|
183
|
-
bridge_fd: string;
|
|
184
|
-
}
|
|
100
|
+
export type { NodeNetworkAttributes, NodeNetworkType } from './node-network-wire.ts';
|
|
101
|
+
// ⚠️ MOVED TO node-network-form.ts IN THIS PR'S LINE-CAP TRIM — every field on it is already
|
|
102
|
+
// documented there against `body`/`createForm`/`updateForm`, so it travels with them rather
|
|
103
|
+
// than duplicating that documentation here. Nothing about its shape changed.
|
|
104
|
+
export type { NodeNetworkProps } from './node-network-form.ts';
|
|
185
105
|
|
|
186
106
|
export interface ProxmoxNodeNetwork extends Resource<
|
|
187
107
|
'Proxmox.NodeNetwork',
|
|
@@ -196,54 +116,122 @@ export const ProxmoxNodeNetwork = Resource<ProxmoxNodeNetwork>('Proxmox.NodeNetw
|
|
|
196
116
|
defaultRemovalPolicy: 'retain',
|
|
197
117
|
});
|
|
198
118
|
|
|
199
|
-
const handlers = pveHandlers<NodeNetworkProps, NodeNetworkAttributes>({
|
|
200
|
-
attributes: readAttributes,
|
|
201
|
-
collection: (props) => `nodes/${props.node}/network`,
|
|
202
|
-
/** ⚠️ No `delete` parameter on a POST — see the ⚠️ on `updateBody`. `iface` IS in the create
|
|
203
|
-
* body though, and was missing until 2026-09-22 — the 🔴 on `createBody`. */
|
|
204
|
-
createForm: createBody,
|
|
205
|
-
/** The vendor rules both forms are checked against at plan time — resource-spec.ts. */
|
|
206
|
-
endpoint: {
|
|
207
|
-
create: 'pve:POST /nodes/{node}/network',
|
|
208
|
-
update: 'pve:PUT /nodes/{node}/network/{iface}',
|
|
209
|
-
},
|
|
210
|
-
/**
|
|
211
|
-
* ⛔ `cidr` IS THE ONLY FIELD COMPARED WHEN UNDECLARED, AND THAT ASYMMETRY IS THE POINT. Every
|
|
212
|
-
* other field here follows storage.ts: undeclared means unmanaged, so it is neither sent nor
|
|
213
|
-
* compared and a plan cannot report an update no write could satisfy. `cidr` cannot be
|
|
214
|
-
* unmanaged, because PVE derives `method` from the presence of an address IN THE FORM — an
|
|
215
|
-
* omission is an edit. Comparing it unconditionally is what makes that edit visible in `plan`
|
|
216
|
-
* instead of at `ifreload` time.
|
|
217
|
-
*
|
|
218
|
-
* ⚠️ NOTHING IN THE REPORTED-ONLY BLOCK IS HERE, and that is what makes the C1 declaration
|
|
219
|
-
* plan as `noop` on all three nodes at once — the ⛔ above `NodeNetworkAttributes` lists them
|
|
220
|
-
* and the measurement behind each.
|
|
221
|
-
*/
|
|
222
|
-
matches: (attributes, props) =>
|
|
223
|
-
attributes.cidr === (props.cidr ?? '') &&
|
|
224
|
-
same(props.gateway, attributes.gateway) &&
|
|
225
|
-
same(props.autostart, attributes.autostart) &&
|
|
226
|
-
same(props.mtu, attributes.mtu) &&
|
|
227
|
-
sameComment(props.comments, attributes.comments) &&
|
|
228
|
-
sameList(props.bridge_ports, attributes.bridge_ports) &&
|
|
229
|
-
sameList(props.bridge_vids, attributes.bridge_vids) &&
|
|
230
|
-
same(props.bridge_vlan_aware, attributes.bridge_vlan_aware) &&
|
|
231
|
-
sameList(props.slaves, attributes.slaves) &&
|
|
232
|
-
same(props.bond_mode, attributes.bond_mode) &&
|
|
233
|
-
same(props.bond_xmit_hash_policy, attributes.bond_xmit_hash_policy) &&
|
|
234
|
-
same(props['bond-primary'], attributes['bond-primary']) &&
|
|
235
|
-
same(props['vlan-id'], attributes['vlan-id']) &&
|
|
236
|
-
same(props['vlan-raw-device'], attributes['vlan-raw-device']),
|
|
237
|
-
path: (props) => `nodes/${props.node}/network/${props.iface}`,
|
|
238
|
-
updateForm: updateBody,
|
|
239
|
-
});
|
|
240
|
-
|
|
241
119
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
120
|
+
* The three fields distilled's generator renamed to an underscore (`bond-primary`, `vlan-id`,
|
|
121
|
+
* `vlan-raw-device`) — everything else round-trips as-is. `storage-form.ts`'s `underscored` is a
|
|
122
|
+
* generic transform for a free-form bag; this family's fields are all named ahead of time, so a
|
|
123
|
+
* small local map is clearer than importing a generic helper for three keys. Moved here from
|
|
124
|
+
* node-network-form.ts in this PR's line-cap trim — this is the only caller.
|
|
247
125
|
*/
|
|
126
|
+
const RENAMED: Readonly<Record<string, string>> = {
|
|
127
|
+
'bond-primary': 'bond_primary',
|
|
128
|
+
'vlan-id': 'vlan_id',
|
|
129
|
+
'vlan-raw-device': 'vlan_raw_device',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const toDistilled = (form: Record<string, string>): Record<string, string> =>
|
|
133
|
+
Object.fromEntries(Object.entries(form).map(([key, value]) => [RENAMED[key] ?? key, value]));
|
|
134
|
+
|
|
135
|
+
/** The actual `createNodeNetwork` call body — `createForm`, translated once. See `toDistilled`. */
|
|
136
|
+
const toDistilledCreate = (props: NodeNetworkProps): nodes.CreateNodeNetworkRequest =>
|
|
137
|
+
({
|
|
138
|
+
...toDistilled(createForm(props)),
|
|
139
|
+
node: props.node,
|
|
140
|
+
}) as unknown as nodes.CreateNodeNetworkRequest;
|
|
141
|
+
|
|
142
|
+
/** The actual `putNodeNetwork2` call body — `updateForm`, translated once. See `toDistilledCreate`. */
|
|
143
|
+
const toDistilledUpdate = (props: NodeNetworkProps): nodes.PutNodeNetwork2Request =>
|
|
144
|
+
({
|
|
145
|
+
...toDistilled(updateForm(props)),
|
|
146
|
+
iface: props.iface,
|
|
147
|
+
node: props.node,
|
|
148
|
+
}) as unknown as nodes.PutNodeNetwork2Request;
|
|
149
|
+
|
|
248
150
|
export const ProxmoxNodeNetworkProvider = () =>
|
|
249
|
-
Provider.effect(
|
|
151
|
+
Provider.effect(
|
|
152
|
+
ProxmoxNodeNetwork,
|
|
153
|
+
Effect.succeed(
|
|
154
|
+
ProxmoxNodeNetwork.Provider.of({
|
|
155
|
+
/**
|
|
156
|
+
* ⛔ EMPTY, AND HERE IT MATTERS MORE THAN ANYWHERE ELSE IN THIS PACKAGE. `GET
|
|
157
|
+
* /nodes/{node}/network` returns every interface the node has. Adoption stays explicit.
|
|
158
|
+
*/
|
|
159
|
+
list: () => Effect.succeed([]),
|
|
160
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined`, restored by adversarial review after a
|
|
161
|
+
// first draft dropped it — node-network-wire.ts's `readInterface` has the reasoning.
|
|
162
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
163
|
+
return dropUnreadable(
|
|
164
|
+
yield* output === undefined ? readInterface(olds) : readInterfaceOrFail(olds),
|
|
165
|
+
);
|
|
166
|
+
}),
|
|
167
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
168
|
+
if (!isResolved(news)) return undefined;
|
|
169
|
+
yield* guardWrite(NODE_NETWORK_CREATE, createForm(news), output === undefined);
|
|
170
|
+
yield* guardWrite(NODE_NETWORK_UPDATE, updateForm(news), false);
|
|
171
|
+
if (output === undefined) return undefined;
|
|
172
|
+
const live = yield* readInterfaceOrFail(news);
|
|
173
|
+
// ⛔ THE CRIES-WOLF FIX: a refused mint used to fall into `undefined` and force `update`
|
|
174
|
+
// on an interface that was plainly there — see unreadable-read.ts.
|
|
175
|
+
if (live === UNREADABLE) {
|
|
176
|
+
yield* unreadableWarning('Proxmox.NodeNetwork', `${news.node}/${news.iface}`);
|
|
177
|
+
return { action: 'noop' } as const;
|
|
178
|
+
}
|
|
179
|
+
if (live === undefined) {
|
|
180
|
+
yield* guardWrite(NODE_NETWORK_CREATE, createForm(news), true);
|
|
181
|
+
return { action: 'update' } as const;
|
|
182
|
+
}
|
|
183
|
+
return matches(live, news)
|
|
184
|
+
? ({ action: 'noop' } as const)
|
|
185
|
+
: ({ action: 'update' } as const);
|
|
186
|
+
}),
|
|
187
|
+
/**
|
|
188
|
+
* ⛔ THE DESTROY IS STAGED LIKE EVERY OTHER WRITE, WHICH MAKES IT THE ONE RECOVERABLE
|
|
189
|
+
* DESTROY IN THIS PACKAGE. It removes the stanza from `interfaces.new` and nothing
|
|
190
|
+
* else; `DELETE /nodes/{node}/network` (a DIFFERENT, collection-level endpoint —
|
|
191
|
+
* `Proxmox.NetworkApply`'s own revert, never called here) puts the node back by
|
|
192
|
+
* unlinking that file. It becomes irreversible the moment somebody applies — and PVE
|
|
193
|
+
* performs NO in-use check before removing an interface other bridges or Ceph still
|
|
194
|
+
* stand on.
|
|
195
|
+
*/
|
|
196
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
197
|
+
const before = dropUnreadable(yield* readInterface(news));
|
|
198
|
+
yield* guardWrite(NODE_NETWORK_CREATE, createForm(news), before === undefined);
|
|
199
|
+
yield* guardWrite(NODE_NETWORK_UPDATE, updateForm(news), false);
|
|
200
|
+
if (before === undefined) {
|
|
201
|
+
yield* runPve(
|
|
202
|
+
news.target,
|
|
203
|
+
'provision',
|
|
204
|
+
true,
|
|
205
|
+
nodes.createNodeNetwork(toDistilledCreate(news)),
|
|
206
|
+
);
|
|
207
|
+
} else if (!matches(before, news)) {
|
|
208
|
+
yield* runPve(
|
|
209
|
+
news.target,
|
|
210
|
+
'provision',
|
|
211
|
+
true,
|
|
212
|
+
nodes.putNodeNetwork2(toDistilledUpdate(news)),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const after = dropUnreadable(yield* readInterface(news));
|
|
216
|
+
if (after === undefined) {
|
|
217
|
+
return yield* Effect.die(
|
|
218
|
+
new Error(
|
|
219
|
+
`nodes/${news.node}/network/${news.iface}: wrote with no error, but the ` +
|
|
220
|
+
'interface is still absent -- PVE wraps every answer in {"data":...} and can ' +
|
|
221
|
+
'report success on a call that did nothing, so read back rather than trust it.',
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return after;
|
|
226
|
+
}),
|
|
227
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
228
|
+
yield* runPve(
|
|
229
|
+
olds.target,
|
|
230
|
+
'provision',
|
|
231
|
+
true,
|
|
232
|
+
nodes.deleteNodeNetwork2({ iface: olds.iface, node: olds.node }),
|
|
233
|
+
);
|
|
234
|
+
}),
|
|
235
|
+
}),
|
|
236
|
+
),
|
|
237
|
+
);
|