@homeflare/alchemy 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
  2. package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
  3. package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
  4. package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/alert-rule-group-model.d.ts +28 -0
  6. package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
  7. package/dist/grafana/alert-rule-group.d.ts +60 -0
  8. package/dist/grafana/alert-rule-group.d.ts.map +1 -0
  9. package/dist/grafana/index.d.ts +6 -4
  10. package/dist/grafana/index.d.ts.map +1 -1
  11. package/dist/grafana/index.js +355 -139
  12. package/dist/grafana/index.js.map +14 -8
  13. package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
  14. package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
  15. package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
  16. package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
  17. package/dist/grafana/notification-policy-model.d.ts +53 -0
  18. package/dist/grafana/notification-policy-model.d.ts.map +1 -0
  19. package/dist/grafana/notification-policy.d.ts +57 -0
  20. package/dist/grafana/notification-policy.d.ts.map +1 -0
  21. package/dist/grafana/providers.d.ts +1 -1
  22. package/dist/grafana/providers.d.ts.map +1 -1
  23. package/dist/proxmox/index.js +349 -287
  24. package/dist/proxmox/index.js.map +8 -7
  25. package/dist/proxmox/network-apply-read.d.ts +12 -0
  26. package/dist/proxmox/network-apply-read.d.ts.map +1 -1
  27. package/dist/proxmox/network-apply.d.ts +2 -0
  28. package/dist/proxmox/network-apply.d.ts.map +1 -1
  29. package/dist/proxmox/node-network-form.d.ts +86 -54
  30. package/dist/proxmox/node-network-form.d.ts.map +1 -1
  31. package/dist/proxmox/node-network-wire.d.ts +100 -0
  32. package/dist/proxmox/node-network-wire.d.ts.map +1 -0
  33. package/dist/proxmox/node-network.d.ts +21 -106
  34. package/dist/proxmox/node-network.d.ts.map +1 -1
  35. package/docs/grafana-alerting-rules.md +143 -0
  36. package/docs/grafana-alerting.md +7 -4
  37. package/docs/grafana-notification-policy.md +127 -0
  38. package/docs/grafana.md +12 -6
  39. package/docs/upstream-conformance.md +30 -4
  40. package/package.json +1 -1
  41. package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
  42. package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
  43. package/src/grafana/alert-rule-group-fixtures.ts +46 -0
  44. package/src/grafana/alert-rule-group-model.test.ts +75 -0
  45. package/src/grafana/alert-rule-group-model.ts +66 -0
  46. package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
  47. package/src/grafana/alert-rule-group.test.ts +164 -0
  48. package/src/grafana/alert-rule-group.ts +231 -0
  49. package/src/grafana/index.ts +18 -4
  50. package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
  51. package/src/grafana/notification-policy-drop-warning.ts +60 -0
  52. package/src/grafana/notification-policy-fixtures.ts +27 -0
  53. package/src/grafana/notification-policy-model.test.ts +104 -0
  54. package/src/grafana/notification-policy-model.ts +100 -0
  55. package/src/grafana/notification-policy-refusals.test.ts +119 -0
  56. package/src/grafana/notification-policy.test.ts +86 -0
  57. package/src/grafana/notification-policy.ts +191 -0
  58. package/src/grafana/providers.ts +4 -0
  59. package/src/proxmox/constraints-host-forms.test.ts +2 -2
  60. package/src/proxmox/network-apply-read.ts +12 -0
  61. package/src/proxmox/network-apply.ts +2 -0
  62. package/src/proxmox/node-network-form.ts +93 -99
  63. package/src/proxmox/node-network-read-failure.test.ts +206 -0
  64. package/src/proxmox/node-network-wire.ts +239 -0
  65. package/src/proxmox/node-network.ts +155 -167
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `Grafana.NotificationPolicy` — the notification policy tree. A SINGLETON: `GET`/`PUT`
3
+ * `/v1/provisioning/policies` always address the ONE tree an instance has, there is no path
4
+ * parameter, and `RouteGetPolicyTreeError` is `Forbidden | GrafanaOpError` — no `NotFound` case at
5
+ * all, MEASURED. `fetchLive` therefore NEVER returns `undefined`: this resource has no create and
6
+ * no delete in the sense every other resource in this family does — `reconcile`'s `before ===
7
+ * undefined` branch (`resource.ts`) is dead code here, and `create` below exists only because
8
+ * `GrafanaSpec.create` is a required field on the shared type, not because it is ever reached.
9
+ * "Declaring" this resource means ADOPTING whatever tree is live and then keeping it in sync —
10
+ * never creating or destroying the tree itself, only ever `PUT`-replacing its contents.
11
+ *
12
+ * ⛔ `routeResetPolicyTree` IS NEVER CALLED UNLESS `allowReset: true` IS EXPLICITLY DECLARED — a
13
+ * second, independent gate on top of `defaultRemovalPolicy: 'retain'` below. Alchemy's own
14
+ * removal-policy gate decides whether the engine calls this resource's `delete` handler AT ALL
15
+ * (retain means it never does, unless a stack opts in with `.pipe(RemovalPolicy.destroy())` —
16
+ * the convention every `Bao.*`/`Grafana.Folder`/`Grafana.AlertRuleGroup` family member already
17
+ * uses); `allowReset` is a SEPARATE prop-level gate inside `destroy` itself, so even a stack that
18
+ * opted into destroy at the Alchemy level still needs this resource's own explicit opt-in before
19
+ * it will reset. Two gates, not one, because `routeResetPolicyTree` is uniquely destructive among
20
+ * everything this family calls: it wipes every declared route, receiver reference and matcher
21
+ * back to Grafana's own bare default in one call, for the WHOLE instance, not one object. Without
22
+ * `allowReset`, `destroy` logs a warning and returns — retained, not reset.
23
+ *
24
+ * ★ PUT REPLACES THE WHOLE TREE — A DROPPED SUB-ROUTE IS WARNED ABOUT, NEVER SILENT. Same shape as
25
+ * kit PR 254's `Grafana.AlertRuleGroup` fix (an adversarial review of that PR found the identical
26
+ * risk for rule groups): `notification-policy-drop-warning.ts`'s `warnOnDroppedRoutes`, called
27
+ * from both this resource's custom `diff` (so `bun run plan` shows it before anything is
28
+ * written) and from `update` itself.
29
+ *
30
+ * ⛔ ROUTE ORDER IS SIGNIFICANT — NEVER REORDERED. Same as `Grafana.AlertRuleGroup`'s rules:
31
+ * `subset-match.ts`'s array comparison is position-significant, and Alertmanager itself routes
32
+ * an alert to the FIRST matching route in array order — reordering routes is a genuine routing
33
+ * change, not cosmetic.
34
+ *
35
+ * ⛔ A FOREIGN-PROVENANCE TREE REFUSES EVERY UPDATE — `alerting-provenance.ts`, unchanged from kit
36
+ * PR 250/254 (including the missing-means-foreign fix: `Route.provenance` is optional on the
37
+ * wire, and `undefined` is never folded into `""`). `create` is never at risk — see above, it is
38
+ * unreachable.
39
+ *
40
+ * ⚠️ GRAFANA-INJECTED DEFAULTS ARE TOLERATED THE `declaredContentMatches` WAY, NOT HAND-NORMALIZED
41
+ * — `matches()` below runs the (volatile-field-stripped) declared and live trees through
42
+ * `subset-match.ts`'s tolerant-subset comparison, the same mechanism `Grafana.Dashboard`'s
43
+ * panel-`datasource` auto-decoration and `Grafana.ContactPoint`'s settings defaults already rely
44
+ * on: a live tree may carry additional fields, at any depth, the declaration never mentioned
45
+ * (Grafana injecting a default `group_wait`/`group_interval`/`repeat_interval`/`group_by: []`
46
+ * when the declaration omits them is the plausible case) without failing the match. ⚠️ NOT
47
+ * MEASURED against a live instance — no live policy tree exists in this house to read (no live
48
+ * calls in this PR either) — this is the documented, conservative default every unmeasured value
49
+ * shape in this family takes, not a guess dressed up as a fact.
50
+ *
51
+ * ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
52
+ */
53
+ import { isResolved } from 'alchemy/Diff';
54
+ import type { Input } from 'alchemy/Input';
55
+ import { Resource } from 'alchemy';
56
+ import * as Provider from 'alchemy/Provider';
57
+ import * as grafana from '@distilled.cloud/grafana';
58
+ import * as Effect from 'effect/Effect';
59
+ import {
60
+ type GrafanaProvisionedObjectError,
61
+ isForeignProvenance,
62
+ refuseIfForeignProvenance,
63
+ } from './alerting-provenance.ts';
64
+ import { type PolicyRoute, normalizeRoute } from './notification-policy-model.ts';
65
+ import { warnOnDroppedRoutes } from './notification-policy-drop-warning.ts';
66
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
67
+ import { declaredContentMatches } from './subset-match.ts';
68
+
69
+ export type { PolicyRoute };
70
+
71
+ export interface NotificationPolicyProps {
72
+ /** The root route and its full nested tree — Grafana's `Route` shape (`receiver`, `group_by`,
73
+ * `matchers`, nested `routes[]`, …), passed through opaquely — see the file header. Order of
74
+ * `routes[]` at every level is significant. */
75
+ route: PolicyRoute;
76
+ /** Lets `destroy` actually call `routeResetPolicyTree`. Defaults to `false` — see the file
77
+ * header for why this is a second gate on top of Alchemy's own removal policy. */
78
+ allowReset?: boolean;
79
+ }
80
+
81
+ export interface NotificationPolicyAttributes {
82
+ route: PolicyRoute;
83
+ /** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined`, refuses — see
84
+ * alerting-provenance.ts's "fails open" note. */
85
+ provenance: string | undefined;
86
+ }
87
+
88
+ export interface GrafanaNotificationPolicy extends Resource<
89
+ 'Grafana.NotificationPolicy',
90
+ NotificationPolicyProps,
91
+ NotificationPolicyAttributes,
92
+ never
93
+ > {}
94
+
95
+ export const GrafanaNotificationPolicy = Resource<GrafanaNotificationPolicy>(
96
+ 'Grafana.NotificationPolicy',
97
+ { defaultRemovalPolicy: 'retain' },
98
+ );
99
+
100
+ const body = (props: NotificationPolicyProps) =>
101
+ props.route as unknown as grafana.RoutePutPolicyTreeRequest;
102
+
103
+ export const spec: GrafanaSpec<
104
+ NotificationPolicyProps,
105
+ grafana.Route,
106
+ NotificationPolicyAttributes,
107
+ | grafana.RouteGetPolicyTreeError
108
+ | grafana.RoutePutPolicyTreeError
109
+ | grafana.RouteResetPolicyTreeError
110
+ | GrafanaProvisionedObjectError
111
+ > = {
112
+ attributes: (live) => ({
113
+ provenance: live.provenance,
114
+ route: normalizeRoute(live as unknown as PolicyRoute),
115
+ }),
116
+ // ⚠️ UNREACHABLE IN PRACTICE — see the file header. Implemented as the same PUT `update` sends,
117
+ // defensively, rather than `Effect.die`, in case a future Grafana version ever does answer
118
+ // `NotFound` here; `GrafanaSpec.create` is required by the shared type either way.
119
+ create: (props) => grafana.routePutPolicyTree(body(props)),
120
+ destroy: (props, live) =>
121
+ Effect.gen(function* () {
122
+ if (isForeignProvenance(live.provenance)) {
123
+ return yield* refuseIfForeignProvenance(
124
+ 'Grafana.NotificationPolicy',
125
+ 'default',
126
+ live.provenance,
127
+ );
128
+ }
129
+ if (props.allowReset !== true) {
130
+ yield* Effect.logWarning(
131
+ 'Grafana.NotificationPolicy: destroy requested but allowReset is not set -- the policy ' +
132
+ 'tree is RETAINED, not reset. Grafana always needs a root route; resetting wipes ' +
133
+ "every declared route/receiver/matcher back to Grafana's own default for the WHOLE " +
134
+ 'instance. Set allowReset: true to actually call routeResetPolicyTree.',
135
+ );
136
+ return;
137
+ }
138
+ yield* grafana.routeResetPolicyTree({});
139
+ }),
140
+ fetchLive: (_props) => grafana.routeGetPolicyTree({}),
141
+ matches: (attributes, props) =>
142
+ declaredContentMatches(normalizeRoute(props.route), attributes.route),
143
+ update: (props, live) =>
144
+ Effect.gen(function* () {
145
+ if (isForeignProvenance(live.provenance)) {
146
+ return yield* refuseIfForeignProvenance(
147
+ 'Grafana.NotificationPolicy',
148
+ 'default',
149
+ live.provenance,
150
+ );
151
+ }
152
+ yield* warnOnDroppedRoutes(props.route, live as unknown as PolicyRoute);
153
+ yield* grafana.routePutPolicyTree(body(props));
154
+ }),
155
+ };
156
+
157
+ /**
158
+ * ⚠️ A LOCAL REIMPLEMENTATION OF `resource.ts`'s GENERIC `diff`, NOT A WRAP OF IT — same reasoning
159
+ * as `alert-rule-group.ts`'s own `diff`: the generic version has no seam to run the drop-warning
160
+ * side effect against the live tree it reads, and wrapping it would mean a second live fetch
161
+ * just to see what it already saw.
162
+ */
163
+ const diff = ({
164
+ news,
165
+ output,
166
+ }: {
167
+ news: Input<NotificationPolicyProps>;
168
+ output: NotificationPolicyAttributes | undefined;
169
+ }) =>
170
+ Effect.gen(function* () {
171
+ if (output === undefined || !isResolved(news)) return undefined;
172
+ const live = yield* spec.fetchLive(news);
173
+ // ⚠️ UNREACHABLE IN PRACTICE — see the file header: `routeGetPolicyTree` has no `NotFound`
174
+ // case, so `live` is never actually `undefined`. `GrafanaSpec.fetchLive`'s shared return
175
+ // type is `Live | undefined` for every resource in the family, so this check exists purely
176
+ // to satisfy that type, the same defensive stance `create` above takes.
177
+ if (live === undefined) return { action: 'update' } as const;
178
+ yield* warnOnDroppedRoutes(news.route, live as unknown as PolicyRoute);
179
+ const mapped = spec.attributes(live, news);
180
+ return mapped !== undefined && spec.matches(mapped, news)
181
+ ? ({ action: 'noop' } as const)
182
+ : ({ action: 'update' } as const);
183
+ });
184
+
185
+ export const handlers = { ...grafanaHandlers(spec), diff };
186
+
187
+ export const GrafanaNotificationPolicyProvider = () =>
188
+ Provider.effect(
189
+ GrafanaNotificationPolicy,
190
+ Effect.succeed(GrafanaNotificationPolicy.Provider.of(handlers)),
191
+ );
@@ -12,6 +12,7 @@
12
12
  * deploys anything over HTTP already has one.
13
13
  */
14
14
  import * as Layer from 'effect/Layer';
15
+ import { GrafanaAlertRuleGroupProvider } from './alert-rule-group.ts';
15
16
  import { GrafanaContactPointProvider } from './contact-point.ts';
16
17
  import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
17
18
  import { GrafanaDashboardProvider } from './dashboard.ts';
@@ -19,6 +20,7 @@ import { GrafanaDatasourceProvider } from './datasource.ts';
19
20
  import { GrafanaFolderProvider } from './folder.ts';
20
21
  import { GrafanaMessageTemplateProvider } from './message-template.ts';
21
22
  import { GrafanaMuteTimingProvider } from './mute-timing.ts';
23
+ import { GrafanaNotificationPolicyProvider } from './notification-policy.ts';
22
24
 
23
25
  export const grafanaProviders = (target: GrafanaTarget) =>
24
26
  Layer.mergeAll(
@@ -28,4 +30,6 @@ export const grafanaProviders = (target: GrafanaTarget) =>
28
30
  GrafanaContactPointProvider(),
29
31
  GrafanaMuteTimingProvider(),
30
32
  GrafanaMessageTemplateProvider(),
33
+ GrafanaAlertRuleGroupProvider(),
34
+ GrafanaNotificationPolicyProvider(),
31
35
  ).pipe(Layer.provide(grafanaCredentials(target)));
@@ -11,7 +11,7 @@
11
11
  import { describe, expect, test } from 'bun:test';
12
12
  import { constraintsFor, formViolations } from './constraint-guard.ts';
13
13
  import type { PbsTarget, PveTarget } from './credentials.ts';
14
- import { createBody as ifaceCreateForm } from './node-network-form.ts';
14
+ import { createForm as ifaceCreateForm } from './node-network-form.ts';
15
15
  import { resolveGroups, targetForm } from './pbs-notification-target-form.ts';
16
16
  import { createForm as zfsCreateForm } from './zfs-pool-form.ts';
17
17
 
@@ -26,7 +26,7 @@ describe('the host families plan clean against their own creates', () => {
26
26
  * 🔴 THE BUG THIS PROOF FOUND. `createForm` was `body`, which never sent `iface` — and PVE marks
27
27
  * it REQUIRED on the POST while `{node}` is the only path parameter. Every NodeNetwork create
28
28
  * this repository could have made would have 400ed; nothing caught it because C1's three nodes
29
- * were all adopted, which takes the PUT path. See the 🔴 on `createBody`.
29
+ * were all adopted, which takes the PUT path. See the 🔴 on `createForm`.
30
30
  */
31
31
  test('an interface create carries iface, which PVE requires in the body', () => {
32
32
  const form = ifaceCreateForm({
@@ -18,6 +18,12 @@
18
18
  * drops the envelope, so the one readable pending signal is invisible to every other resource in
19
19
  * this package. Without the fetch below, `Proxmox.NetworkApply` could not diff honestly at all.
20
20
  * ★ `pveEnvelopeWith` in client.ts now carries the same member failover as every other call.
21
+ * ⛔ WHY THIS FAMILY DID NOT MOVE TO DISTILLED WITH `Proxmox.NodeNetwork` (2026-09-24, decision
22
+ * 43's walk-down): CONFIRMED against `@distilled.cloud/proxmox`'s own `protocol.ts` that its
23
+ * `transformResponse` unconditionally returns `body.data ?? {}` for every operation, before a
24
+ * generated call's typed output is even built — no typed call, current or future, could see
25
+ * `changes` without a protocol-level change upstream. `pveEnvelopeWith` stays because nothing
26
+ * else preserves the sibling this function needs.
21
27
  *
22
28
  * ⛔ AND THE INTERFACE ROWS CANNOT TELL YOU EITHER. MEASURED in PVE/INotify.pm `read_file`: when
23
29
  * `/etc/network/interfaces.new` exists, THAT is the file that gets parsed, so
@@ -188,6 +194,12 @@ const ATTEMPTS = 30;
188
194
  * of the task". Every mint returns a NEW token id, so a second mint is a different identity and
189
195
  * is not the owner — which is why this takes a `PveCredential` and the caller keeps one lease
190
196
  * across both calls, instead of widening the provisioning role to read its own tasks.
197
+ *
198
+ * ⛔ THE OTHER REASON THIS FAMILY DID NOT MOVE TO DISTILLED (2026-09-24): CHECKED against
199
+ * `@distilled.cloud/proxmox`'s own `Task.awaitTask` (`src/task.ts`) before deciding — it
200
+ * propagates a poll failure as a genuine typed `GetNodeTaskStatusError` rather than collapsing
201
+ * it to `UNREACHABLE` the way this function does, which would turn the ⛔ two paragraphs above
202
+ * into a failed `reconcile` on exactly the poll a mid-reload connection drop is expected to hit.
191
203
  */
192
204
  export const awaitTask = (
193
205
  target: PveTarget,
@@ -44,6 +44,8 @@
44
44
  * task it starts so it can poll it without `Sys.Audit`. ⛔ `Sys.Modify` ON A NODE IS NOT A SMALL
45
45
  * GRANT — it also carries that node's DNS, hosts file, time and service configuration. Granting
46
46
  * it cluster-wide to run this resource widens the provisioning role well beyond networking.
47
+ *
48
+ * ⛔ STAYS ON `client.ts` (2026-09-24) — network-apply-read.ts's header has the two MEASURED reasons.
47
49
  */
48
50
  import { Resource } from 'alchemy';
49
51
  import { isResolved } from 'alchemy/Diff';
@@ -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.