@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.
- 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 +6 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +355 -139
- package/dist/grafana/index.js.map +14 -8
- package/dist/grafana/notification-policy-drop-warning.d.ts +27 -0
- package/dist/grafana/notification-policy-drop-warning.d.ts.map +1 -0
- package/dist/grafana/notification-policy-fixtures.d.ts +34 -0
- package/dist/grafana/notification-policy-fixtures.d.ts.map +1 -0
- package/dist/grafana/notification-policy-model.d.ts +53 -0
- package/dist/grafana/notification-policy-model.d.ts.map +1 -0
- package/dist/grafana/notification-policy.d.ts +57 -0
- package/dist/grafana/notification-policy.d.ts.map +1 -0
- 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 +7 -4
- package/docs/grafana-notification-policy.md +127 -0
- package/docs/grafana.md +12 -6
- package/docs/upstream-conformance.md +30 -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 +18 -4
- package/src/grafana/notification-policy-drop-warning.test.ts +163 -0
- package/src/grafana/notification-policy-drop-warning.ts +60 -0
- package/src/grafana/notification-policy-fixtures.ts +27 -0
- package/src/grafana/notification-policy-model.test.ts +104 -0
- package/src/grafana/notification-policy-model.ts +100 -0
- package/src/grafana/notification-policy-refusals.test.ts +119 -0
- package/src/grafana/notification-policy.test.ts +86 -0
- package/src/grafana/notification-policy.ts +191 -0
- package/src/grafana/providers.ts +4 -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,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
|
+
);
|
package/src/grafana/providers.ts
CHANGED
|
@@ -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 {
|
|
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 `
|
|
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
|
-
*
|
|
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.
|