@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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Proxmox.NodeNetwork`'s absence signal, and the cries-wolf fix, through Alchemy's own Plan and
|
|
3
|
+
* Apply over a fake cluster. Real-shaped fixtures (live TB4 `n2` response bodies, measured
|
|
4
|
+
* 2026-09-24, nothing secret): `GET /nodes/n2/network/vmbr0` -> a full interface object; a
|
|
5
|
+
* missing interface -> `{"errors":{"iface":"interface does not exist"},"data":null,"message":
|
|
6
|
+
* "Parameter verification failed.\n"}` at HTTP 400 — THIS family's absence shape, distinct from
|
|
7
|
+
* every other migrated family's measured 500.
|
|
8
|
+
*
|
|
9
|
+
* ★ ONLY THIS EXACT SIGNAL MEANS ABSENT. The tests below prove both directions: the specific
|
|
10
|
+
* 400 lets `reconcile` create a genuinely new interface, and nothing else — a different 400
|
|
11
|
+
* reason, a 500, or a refused credential — is ever silently folded into "go ahead and create".
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
16
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
17
|
+
import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
|
|
18
|
+
import { ProxmoxNodeNetwork, ProxmoxNodeNetworkProvider } from './node-network.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One interface, an OpenBao mint the test can deny for `read` mid-run (the cries-wolf case), and
|
|
22
|
+
* a `GET` answer the test can swap to any status/body (the absence-signal cases).
|
|
23
|
+
*/
|
|
24
|
+
const clusterOverInterface = (node: string, iface: string, live: Record<string, unknown>) => {
|
|
25
|
+
let denyRead = false;
|
|
26
|
+
let answer: 'live' | 'genuinely-absent' | 'other-400' | 'server-error' = 'live';
|
|
27
|
+
const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
28
|
+
const request =
|
|
29
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
if (url.pathname.includes('/creds/')) {
|
|
32
|
+
if (denyRead && url.pathname.endsWith('/creds/read')) {
|
|
33
|
+
return Response.json({ errors: ['permission denied'] }, { status: 403 });
|
|
34
|
+
}
|
|
35
|
+
const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
|
|
36
|
+
return Response.json({ data, lease_duration: 0 });
|
|
37
|
+
}
|
|
38
|
+
if (request.method === 'GET') {
|
|
39
|
+
// ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE: a real fetch client refuses a GET
|
|
40
|
+
// carrying a body outright.
|
|
41
|
+
expect(await request.text()).toBe('');
|
|
42
|
+
}
|
|
43
|
+
const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
|
|
44
|
+
if (request.method === 'POST' && wire === `nodes/${node}/network`) {
|
|
45
|
+
answer = 'live';
|
|
46
|
+
return Response.json({ data: null });
|
|
47
|
+
}
|
|
48
|
+
if (wire !== `nodes/${node}/network/${iface}`) return Response.json({ data: null });
|
|
49
|
+
if (answer === 'genuinely-absent') {
|
|
50
|
+
// MEASURED 2026-09-24, live TB4: a missing interface answers exactly this shape.
|
|
51
|
+
return Response.json(
|
|
52
|
+
{
|
|
53
|
+
data: null,
|
|
54
|
+
errors: { iface: 'interface does not exist' },
|
|
55
|
+
message: 'Parameter verification failed.\n',
|
|
56
|
+
},
|
|
57
|
+
{ status: 400 },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (answer === 'other-400') {
|
|
61
|
+
return Response.json(
|
|
62
|
+
{
|
|
63
|
+
data: null,
|
|
64
|
+
errors: { type: 'value does not look like a valid interface type' },
|
|
65
|
+
message: 'Parameter verification failed.\n',
|
|
66
|
+
},
|
|
67
|
+
{ status: 400 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (answer === 'server-error') {
|
|
71
|
+
return Response.json({ data: null, message: 'internal error\n' }, { status: 500 });
|
|
72
|
+
}
|
|
73
|
+
return Response.json({ data: live });
|
|
74
|
+
};
|
|
75
|
+
const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
|
|
76
|
+
return {
|
|
77
|
+
denyRead: () => {
|
|
78
|
+
denyRead = true;
|
|
79
|
+
},
|
|
80
|
+
setAnswer: (next: typeof answer) => {
|
|
81
|
+
answer = next;
|
|
82
|
+
},
|
|
83
|
+
layer: FetchHttpClient.layer.pipe(
|
|
84
|
+
Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// MEASURED 2026-09-24, live TB4 `GET /nodes/n2/network/vmbr0`.
|
|
90
|
+
const live = {
|
|
91
|
+
autostart: 1,
|
|
92
|
+
bridge_ports: 'enp87s0',
|
|
93
|
+
bridge_vids: '2-4094',
|
|
94
|
+
bridge_vlan_aware: 1,
|
|
95
|
+
type: 'bridge',
|
|
96
|
+
};
|
|
97
|
+
const declare = () =>
|
|
98
|
+
ProxmoxNodeNetwork('n2-vmbr0', {
|
|
99
|
+
autostart: true,
|
|
100
|
+
bridge_ports: 'enp87s0',
|
|
101
|
+
bridge_vids: '2-4094',
|
|
102
|
+
bridge_vlan_aware: true,
|
|
103
|
+
iface: 'vmbr0',
|
|
104
|
+
node: 'n2',
|
|
105
|
+
target: FAKE_TARGET,
|
|
106
|
+
type: 'bridge',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("Proxmox.NodeNetwork's own cries-wolf fix: a refused read mint reports noop, never update", () => {
|
|
110
|
+
test('an already-adopted interface plans noop once the credential is denied, and nothing is written', async () => {
|
|
111
|
+
const fake = clusterOverInterface('n2', 'vmbr0', live);
|
|
112
|
+
await withoutBao(async () => {
|
|
113
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
114
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
|
|
115
|
+
fake.denyRead();
|
|
116
|
+
const report = await engine.verify(declare(), { all: true });
|
|
117
|
+
expect(report.rows[0]).toMatchObject({ diff: 'noop' });
|
|
118
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'noop' });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('only the measured "interface does not exist" 400 means absent', () => {
|
|
124
|
+
test('a genuinely new interface is created from exactly that signal', async () => {
|
|
125
|
+
const fake = clusterOverInterface('n2', 'vmbr9', {});
|
|
126
|
+
fake.setAnswer('genuinely-absent');
|
|
127
|
+
await withoutBao(async () => {
|
|
128
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
129
|
+
const declareNew = () =>
|
|
130
|
+
ProxmoxNodeNetwork('n2-vmbr9', {
|
|
131
|
+
iface: 'vmbr9',
|
|
132
|
+
node: 'n2',
|
|
133
|
+
target: FAKE_TARGET,
|
|
134
|
+
type: 'bridge',
|
|
135
|
+
});
|
|
136
|
+
// ⚠️ The fake's POST handler flips `answer` back to `'live'`, so the read-back after
|
|
137
|
+
// create finds the interface — matching a real create landing.
|
|
138
|
+
expect(await engine.deploy(declareNew())).toEqual({ 'n2-vmbr9': 'create' });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ★ WHY `read` FOLDS AT ALL, SPELLED OUT ON A BRAND NEW DECLARATION. Plan.ts's cold-start
|
|
144
|
+
* adoption probe (no prior Alchemy state for this declaration) calls `provider.read` with no
|
|
145
|
+
* catch of its own, and aggregates every resource's probe fail-fast — so an uncaught failure
|
|
146
|
+
* here would abort the WHOLE plan, every other resource in it included, over one interface a
|
|
147
|
+
* flaky read happened to hit. Proven here: a genuine 500 (never the absence signal) on a
|
|
148
|
+
* cold-start read still resolves to `create`, not a thrown plan failure.
|
|
149
|
+
* ⚠️ THIS TEST ALONE DOES NOT DISTINGUISH THE FIX FROM THE DISPROVEN DESIGN — checked directly:
|
|
150
|
+
* the pre-fix single-function `read` folded unconditionally too, so it passes either way. The
|
|
151
|
+
* test that actually regresses without `read`'s `output`-branching is the drift test right
|
|
152
|
+
* below (`output` IS defined there, on an already-confirmed row, and that is the case the
|
|
153
|
+
* fix changes): verified by temporarily reverting `read` to the pre-fix form and confirming
|
|
154
|
+
* that test, and only that one, fails. Kept anyway as a direct demonstration of the
|
|
155
|
+
* reasoning above, not as the regression proof.
|
|
156
|
+
*/
|
|
157
|
+
test("a cold-start declaration's adoption probe folds a genuine 500, and the plan is not aborted", async () => {
|
|
158
|
+
const fake = clusterOverInterface('n2', 'vmbr8', {});
|
|
159
|
+
fake.setAnswer('server-error');
|
|
160
|
+
await withoutBao(async () => {
|
|
161
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
162
|
+
const declareNew = () =>
|
|
163
|
+
ProxmoxNodeNetwork('n2-vmbr8', {
|
|
164
|
+
iface: 'vmbr8',
|
|
165
|
+
node: 'n2',
|
|
166
|
+
target: FAKE_TARGET,
|
|
167
|
+
type: 'bridge',
|
|
168
|
+
});
|
|
169
|
+
expect(await engine.deploy(declareNew())).toEqual({ 'n2-vmbr8': 'create' });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('a different 400 (not "interface does not exist") propagates instead of creating', async () => {
|
|
174
|
+
const fake = clusterOverInterface('n2', 'vmbr0', live);
|
|
175
|
+
await withoutBao(async () => {
|
|
176
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
177
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
|
|
178
|
+
fake.setAnswer('other-400');
|
|
179
|
+
// ⛔ THE FIX: this must REJECT, never resolve with a false 'update' (or worse, a create).
|
|
180
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a genuine 500 propagates instead of creating', async () => {
|
|
185
|
+
const fake = clusterOverInterface('n2', 'vmbr0', live);
|
|
186
|
+
await withoutBao(async () => {
|
|
187
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
188
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
|
|
189
|
+
fake.setAnswer('server-error');
|
|
190
|
+
await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ⛔ THE ADVERSARIAL REVIEW'S OWN REGRESSION TEST — `output` is defined here (an already
|
|
195
|
+
// -confirmed row), the one case `read`'s `output`-branching actually changes. Confirmed by
|
|
196
|
+
// temporarily reverting `read` to the pre-fix, non-branching form: only this test fails.
|
|
197
|
+
test('alchemy drift never reports a transient failure as missing', async () => {
|
|
198
|
+
const fake = clusterOverInterface('n2', 'vmbr0', live);
|
|
199
|
+
await withoutBao(async () => {
|
|
200
|
+
const engine = engineOver(ProxmoxNodeNetworkProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
201
|
+
expect(await engine.deploy(declare())).toEqual({ 'n2-vmbr0': 'adopted' });
|
|
202
|
+
fake.setAnswer('server-error');
|
|
203
|
+
await expect(engine.drift(declare())).rejects.toBeDefined();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -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']);
|