@homeflare/alchemy 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
- package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-model.d.ts +28 -0
- package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group.d.ts +60 -0
- package/dist/grafana/alert-rule-group.d.ts.map +1 -0
- package/dist/grafana/alerting-provenance.d.ts +80 -0
- package/dist/grafana/alerting-provenance.d.ts.map +1 -0
- package/dist/grafana/contact-point-fixtures.d.ts +28 -0
- package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
- package/dist/grafana/contact-point.d.ts +99 -0
- package/dist/grafana/contact-point.d.ts.map +1 -0
- package/dist/grafana/dashboard-model.d.ts +8 -38
- package/dist/grafana/dashboard-model.d.ts.map +1 -1
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/index.d.ts +11 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +339 -80
- package/dist/grafana/index.js.map +16 -7
- package/dist/grafana/message-template.d.ts +70 -0
- package/dist/grafana/message-template.d.ts.map +1 -0
- package/dist/grafana/mute-timing.d.ts +112 -0
- package/dist/grafana/mute-timing.d.ts.map +1 -0
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/secret-refs.d.ts +19 -0
- package/dist/grafana/secret-refs.d.ts.map +1 -0
- package/dist/grafana/subset-match.d.ts +2 -0
- package/dist/grafana/subset-match.d.ts.map +1 -0
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +3 -1
- package/dist/linux/index.js.map +1 -1
- package/dist/proxmox/index.js +349 -287
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/network-apply-read.d.ts +12 -0
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply.d.ts +2 -0
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-form.d.ts +86 -54
- package/dist/proxmox/node-network-form.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +100 -0
- package/dist/proxmox/node-network-wire.d.ts.map +1 -0
- package/dist/proxmox/node-network.d.ts +21 -106
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/docs/grafana-alerting-rules.md +143 -0
- package/docs/grafana-alerting.md +147 -0
- package/docs/grafana.md +6 -3
- package/docs/upstream-conformance.md +38 -4
- package/package.json +1 -1
- package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
- package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
- package/src/grafana/alert-rule-group-fixtures.ts +46 -0
- package/src/grafana/alert-rule-group-model.test.ts +75 -0
- package/src/grafana/alert-rule-group-model.ts +66 -0
- package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
- package/src/grafana/alert-rule-group.test.ts +164 -0
- package/src/grafana/alert-rule-group.ts +231 -0
- package/src/grafana/alerting-provenance.ts +109 -0
- package/src/grafana/contact-point-fixtures.ts +25 -0
- package/src/grafana/contact-point-provenance.test.ts +113 -0
- package/src/grafana/contact-point-secrets.test.ts +117 -0
- package/src/grafana/contact-point.test.ts +123 -0
- package/src/grafana/contact-point.ts +179 -0
- package/src/grafana/dashboard-model.ts +8 -57
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/index.ts +31 -4
- package/src/grafana/message-template.test.ts +212 -0
- package/src/grafana/message-template.ts +114 -0
- package/src/grafana/mute-timing.test.ts +227 -0
- package/src/grafana/mute-timing.ts +160 -0
- package/src/grafana/providers.ts +8 -0
- package/src/grafana/secret-refs.ts +48 -0
- package/src/grafana/subset-match.ts +71 -0
- package/src/linux/index-export.test.ts +25 -0
- package/src/linux/index.ts +15 -1
- package/src/proxmox/constraints-host-forms.test.ts +2 -2
- package/src/proxmox/network-apply-read.ts +12 -0
- package/src/proxmox/network-apply.ts +2 -0
- package/src/proxmox/node-network-form.ts +93 -99
- package/src/proxmox/node-network-read-failure.test.ts +206 -0
- package/src/proxmox/node-network-wire.ts +239 -0
- package/src/proxmox/node-network.ts +155 -167
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.MuteTiming` against a fake Grafana — the widened-type `provenance`/`version` read (see
|
|
3
|
+
* the file's own header for why the SDK's declared `MuteTimeInterval` type undersells the runtime
|
|
4
|
+
* value), the foreign-provenance refusal, and opaque time-interval passthrough (weekdays/times
|
|
5
|
+
* fields the SDK's own `TimeInterval` type does not model either). Mirrors `contact-point.test.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
|
|
10
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
11
|
+
import { spec } from './mute-timing.ts';
|
|
12
|
+
import { grafanaOperations } from './resource.ts';
|
|
13
|
+
|
|
14
|
+
const PATH = '/api/v1/provisioning/mute-timings/weekends';
|
|
15
|
+
const LIST_PATH = '/api/v1/provisioning/mute-timings';
|
|
16
|
+
|
|
17
|
+
const timeIntervals = [
|
|
18
|
+
{ times: [{ end_time: '09:00', start_time: '17:00' }], weekdays: ['friday'] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const liveJson = {
|
|
22
|
+
name: 'weekends',
|
|
23
|
+
provenance: 'api',
|
|
24
|
+
time_intervals: timeIntervals,
|
|
25
|
+
version: 'v1',
|
|
26
|
+
};
|
|
27
|
+
const props = { name: 'weekends', timeIntervals };
|
|
28
|
+
|
|
29
|
+
describe('spec.fetchLive', () => {
|
|
30
|
+
test('provenance and the real weekday/time fields survive decode despite the SDK type', async () => {
|
|
31
|
+
const fake = fakeGrafana((method, url) =>
|
|
32
|
+
method === 'GET' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
|
|
33
|
+
);
|
|
34
|
+
const live = await Effect.runPromise(
|
|
35
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
36
|
+
);
|
|
37
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
38
|
+
name: 'weekends',
|
|
39
|
+
provenance: 'api',
|
|
40
|
+
timeIntervals,
|
|
41
|
+
});
|
|
42
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a 404 folds to undefined', async () => {
|
|
46
|
+
const fake = fakeGrafana(() => fakeFailure(404, 'mute timing not found'));
|
|
47
|
+
const result = await Effect.runPromise(
|
|
48
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
49
|
+
);
|
|
50
|
+
expect(result).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
54
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
55
|
+
const failure = await Effect.runPromise(
|
|
56
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
57
|
+
);
|
|
58
|
+
expect(failure._tag).toBe('Forbidden');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('create', () => {
|
|
63
|
+
test('sends the real weekday/time fields on the wire — not dropped by the SDK type either', async () => {
|
|
64
|
+
const fake = fakeGrafana((method, url) =>
|
|
65
|
+
method === 'POST' && url.pathname === LIST_PATH
|
|
66
|
+
? Response.json(liveJson)
|
|
67
|
+
: fakeFailure(404, 'x'),
|
|
68
|
+
);
|
|
69
|
+
await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
70
|
+
expect(fake.bodies[0]).toEqual({ name: 'weekends', time_intervals: timeIntervals });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('subset-match array rule — pinned, not re-measured live', () => {
|
|
75
|
+
// ⛔ FLAGGED BY AN ADVERSARIAL REVIEW OF THIS PR: `subset-match.ts`'s array comparison is exact
|
|
76
|
+
// length, then element-wise — unverified against a real Grafana instance for whether it ever
|
|
77
|
+
// pads or appends a default entry to `time_intervals` (no live mute timing exists in this house
|
|
78
|
+
// to observe). This test pins TODAY's behavior — a live observation that finds Grafana doing so
|
|
79
|
+
// would make this test itself the place to change, not a surprise discovered elsewhere.
|
|
80
|
+
test('live carrying one extra time-interval entry the declaration never mentioned still shows update', async () => {
|
|
81
|
+
const extraEntry = {
|
|
82
|
+
...liveJson,
|
|
83
|
+
time_intervals: [...timeIntervals, { weekdays: ['sunday'] }],
|
|
84
|
+
};
|
|
85
|
+
const fake = fakeGrafana((method, url) =>
|
|
86
|
+
method === 'GET' && url.pathname === PATH ? Response.json(extraEntry) : fakeFailure(500, 'x'),
|
|
87
|
+
);
|
|
88
|
+
const live = await Effect.runPromise(
|
|
89
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
90
|
+
);
|
|
91
|
+
const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
|
|
92
|
+
expect(attrs !== undefined && spec.matches(attrs, props)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('reconcile — adopt semantics', () => {
|
|
97
|
+
test('declaring exactly what is live is a noop: no write is ever sent', async () => {
|
|
98
|
+
const fake = fakeGrafana((method, url) =>
|
|
99
|
+
method === 'GET' && url.pathname === PATH
|
|
100
|
+
? Response.json(liveJson)
|
|
101
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
102
|
+
);
|
|
103
|
+
const after = await Effect.runPromise(
|
|
104
|
+
grafanaOperations(spec)
|
|
105
|
+
.reconcile(props)
|
|
106
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
107
|
+
);
|
|
108
|
+
expect(after.name).toBe('weekends');
|
|
109
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('foreign provenance refuses update and destroy', () => {
|
|
114
|
+
test('a file-provisioned mute timing refuses update and sends no PUT', async () => {
|
|
115
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
116
|
+
const fake = fakeGrafana((method, url) =>
|
|
117
|
+
method === 'GET' && url.pathname === PATH
|
|
118
|
+
? Response.json(fileProvisioned)
|
|
119
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
120
|
+
);
|
|
121
|
+
const changed = { name: 'weekends', timeIntervals: [{ weekdays: ['saturday'] }] };
|
|
122
|
+
const failure = await Effect.runPromise(
|
|
123
|
+
Effect.flip(
|
|
124
|
+
grafanaOperations(spec)
|
|
125
|
+
.reconcile(changed)
|
|
126
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
130
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a file-provisioned mute timing refuses destroy and sends no DELETE', async () => {
|
|
134
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
135
|
+
const fake = fakeGrafana((method, url) =>
|
|
136
|
+
method === 'GET' && url.pathname === PATH
|
|
137
|
+
? Response.json(fileProvisioned)
|
|
138
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
139
|
+
);
|
|
140
|
+
const failure = await Effect.runPromise(
|
|
141
|
+
Effect.flip(
|
|
142
|
+
grafanaOperations(spec)
|
|
143
|
+
.destroy(props)
|
|
144
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
148
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('deletes a live, api-provenance mute timing, sending its version for concurrency', async () => {
|
|
152
|
+
const fake = fakeGrafana((method, url) => {
|
|
153
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
|
|
154
|
+
if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
|
|
155
|
+
return fakeFailure(500, 'unexpected request');
|
|
156
|
+
});
|
|
157
|
+
await Effect.runPromise(
|
|
158
|
+
grafanaOperations(spec)
|
|
159
|
+
.destroy(props)
|
|
160
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
161
|
+
);
|
|
162
|
+
const del = fake.seen.findIndex((s) => s.method === 'DELETE');
|
|
163
|
+
expect(del).toBeGreaterThanOrEqual(0);
|
|
164
|
+
expect(fake.seen[del]?.path).toBe(`${PATH}?version=v1`);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('missing provenance fails closed', () => {
|
|
169
|
+
// ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND — see contact-point.test.ts's own
|
|
170
|
+
// copy of this block for the full account. This resource is the one where it matters most:
|
|
171
|
+
// `provenance` isn't even in the SDK's declared `MuteTimeInterval` type, so "the field is
|
|
172
|
+
// missing" is a real, plausible shape for a response to take, not a hypothetical.
|
|
173
|
+
const noProvenanceField: Record<string, unknown> = { ...liveJson };
|
|
174
|
+
delete noProvenanceField.provenance;
|
|
175
|
+
|
|
176
|
+
test('a response with no provenance field at all refuses update', async () => {
|
|
177
|
+
const fake = fakeGrafana((method, url) =>
|
|
178
|
+
method === 'GET' && url.pathname === PATH
|
|
179
|
+
? Response.json(noProvenanceField)
|
|
180
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
181
|
+
);
|
|
182
|
+
const changed = { name: 'weekends', timeIntervals: [{ weekdays: ['saturday'] }] };
|
|
183
|
+
const failure = await Effect.runPromise(
|
|
184
|
+
Effect.flip(
|
|
185
|
+
grafanaOperations(spec)
|
|
186
|
+
.reconcile(changed)
|
|
187
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
191
|
+
expect(failure.message).toContain('did not report a provenance');
|
|
192
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a response with no provenance field at all refuses destroy', async () => {
|
|
196
|
+
const fake = fakeGrafana((method, url) =>
|
|
197
|
+
method === 'GET' && url.pathname === PATH
|
|
198
|
+
? Response.json(noProvenanceField)
|
|
199
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
200
|
+
);
|
|
201
|
+
const failure = await Effect.runPromise(
|
|
202
|
+
Effect.flip(
|
|
203
|
+
grafanaOperations(spec)
|
|
204
|
+
.destroy(props)
|
|
205
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
209
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
|
|
213
|
+
const explicitlyNone = { ...liveJson, provenance: '' };
|
|
214
|
+
const fake = fakeGrafana((method, url) => {
|
|
215
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(explicitlyNone);
|
|
216
|
+
if (method === 'PUT' && url.pathname === PATH) return Response.json(explicitlyNone);
|
|
217
|
+
return fakeFailure(500, 'unexpected request');
|
|
218
|
+
});
|
|
219
|
+
const changed = { name: 'weekends', timeIntervals: [{ weekdays: ['saturday'] }] };
|
|
220
|
+
await Effect.runPromise(
|
|
221
|
+
grafanaOperations(spec)
|
|
222
|
+
.reconcile(changed)
|
|
223
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
224
|
+
);
|
|
225
|
+
expect(fake.seen.some((s) => s.method === 'PUT')).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.MuteTiming` — one Grafana alerting mute timing, keyed by `name` (there is no separate
|
|
3
|
+
* `uid` — `routeGetMuteTiming`/`routePutMuteTiming`/`routeDeleteMuteTiming` are all name-keyed on
|
|
4
|
+
* the wire, and `name` is the field a notification policy's own `mute_time_intervals` references).
|
|
5
|
+
*
|
|
6
|
+
* ★ `provenance`/`version` ARE READ THROUGH A WIDENED TYPE, NOT THE SDK's OWN `MuteTimeInterval` —
|
|
7
|
+
* MEASURED, and a genuinely surprising result worth recording in full. The SDK's generated
|
|
8
|
+
* `MuteTimeInterval` interface (`packages/distilled-grafana/src/services/grafana.ts`) declares
|
|
9
|
+
* only `name`/`time_intervals` — no `provenance`, no `version` — unlike `EmbeddedContactPoint`/
|
|
10
|
+
* `NotificationTemplate`/`ProvisionedAlertRule`/`Route`, which all carry `provenance`. A first
|
|
11
|
+
* pass at this file assumed that meant the data was gone: decoding a raw payload containing both
|
|
12
|
+
* fields through `S.decodeUnknownSync(MuteTimeInterval)` directly DOES strip them. But that is
|
|
13
|
+
* the wrong code path — it is not what `routeGetMuteTiming` itself does. Proven empirically by
|
|
14
|
+
* calling the REAL operation (through `fake-grafana.ts`'s harness, the exact path this file's own
|
|
15
|
+
* `fetchLive` uses) with a response body containing `provenance`/`version`: both come through on
|
|
16
|
+
* the decoded value intact. The mechanism, read from `@distilled.cloud/core@1.0.0-rc.12`'s
|
|
17
|
+
* `protocol-rest.ts`/`protocol-http.ts` (the runtime this SDK's every operation shares): response
|
|
18
|
+
* decoding is `JSON.parse` + `mapKeys` (renames only the field names a schema DOES declare) +
|
|
19
|
+
* `wrapSensitive` (wraps only fields a schema marks sensitive in `Redacted`) — never a strict
|
|
20
|
+
* `Schema.decode`. `protocol-http.ts`'s `mapKeys`, object branch: every response key NOT among a
|
|
21
|
+
* type's declared `props` is copied to the output verbatim in a second pass
|
|
22
|
+
* (`for (const [k, v] of Object.entries(value)) { if (consumed.has(k)) continue; out[k] = v; }`,
|
|
23
|
+
* line ~297-311) — the doc comment on the sibling `wrapSensitive` states the same rule plainly:
|
|
24
|
+
* "Keys the schema doesn't model pass through verbatim." So the TS type is simply narrower than
|
|
25
|
+
* the runtime value; `MuteTimeIntervalWire` below is that same runtime value, typed honestly.
|
|
26
|
+
* This is a real, worth-fixing SDK type-generation gap (tracked in `docs/upstream-conformance.md`,
|
|
27
|
+
* same S22 route as the other named gaps) but NOT a data-loss gap — unlike, say, a field the wire
|
|
28
|
+
* protocol itself never sent, this one is fully recoverable without touching vendored `src/`.
|
|
29
|
+
*
|
|
30
|
+
* The same test proved the outbound direction too: an object with extra keys (`weekdays`, `times`
|
|
31
|
+
* — the real Alertmanager time-interval fields Grafana's OpenAPI's own `TimeInterval` schema
|
|
32
|
+
* apparently fails to model either, for the same reason) passed to `routePutMuteTiming` reaches
|
|
33
|
+
* the wire body unchanged — `mapKeys`'s encode direction is the same verbatim-passthrough rule.
|
|
34
|
+
* `MuteTimingProps.timeIntervals` is therefore typed as the opaque `Record<string, unknown>`
|
|
35
|
+
* shape actually needed to declare a real mute timing (weekdays/times/months/etc.), not the
|
|
36
|
+
* SDK's own too-narrow `TimeInterval`, and cast at the two call sites below.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ THE EVIDENCE ABOVE IS A HAND-AUTHORED FIXTURE, NOT A CAPTURED REAL RESPONSE — flagged by an
|
|
39
|
+
* adversarial review of this PR. The estate has zero live mute timings to observe (grafana.md's
|
|
40
|
+
* census), so nothing confirms Grafana always includes `provenance` on this route in practice,
|
|
41
|
+
* or that some other SDK/version edge case never drops it. That is exactly why `attributes()`
|
|
42
|
+
* below never coerces a missing `live.provenance` to `''` — see alerting-provenance.ts's "fails
|
|
43
|
+
* open" note — an absent field refuses a write rather than being trusted as `ProvenanceNone`.
|
|
44
|
+
*
|
|
45
|
+
* ⛔ A FOREIGN-PROVENANCE MUTE TIMING REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
|
|
46
|
+
* shared with `Grafana.ContactPoint`/`MessageTemplate`/`AlertRuleGroup`/`NotificationPolicy`; see
|
|
47
|
+
* that file's header. `create` is never at risk: it only runs when `fetchLive` already found
|
|
48
|
+
* nothing with this `name`.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ `version` IS SENT ON DESTROY, NOT ON UPDATE — MEASURED: `RouteDeleteMuteTimingRequest.version`
|
|
51
|
+
* ("Version of mute timing to use for optimistic concurrency. Leave empty to disable validation")
|
|
52
|
+
* exists; `RoutePutMuteTimingRequest` has no `version` field at all, so an update has nothing to
|
|
53
|
+
* send for it — a real Grafana API asymmetry between the two write routes, not an omission here.
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
|
|
56
|
+
*/
|
|
57
|
+
import { Resource } from 'alchemy';
|
|
58
|
+
import * as Provider from 'alchemy/Provider';
|
|
59
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
60
|
+
import * as Effect from 'effect/Effect';
|
|
61
|
+
import {
|
|
62
|
+
type GrafanaProvisionedObjectError,
|
|
63
|
+
isForeignProvenance,
|
|
64
|
+
refuseIfForeignProvenance,
|
|
65
|
+
} from './alerting-provenance.ts';
|
|
66
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
67
|
+
import { declaredContentMatches } from './subset-match.ts';
|
|
68
|
+
|
|
69
|
+
export interface MuteTimingProps {
|
|
70
|
+
name: string;
|
|
71
|
+
/** Grafana's Alertmanager-style time-interval entries (`weekdays`/`times`/`months`/…), passed
|
|
72
|
+
* through opaquely — see the file header for why this is not the SDK's own `TimeInterval`. */
|
|
73
|
+
timeIntervals: ReadonlyArray<Record<string, unknown>>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MuteTimingAttributes {
|
|
77
|
+
name: string;
|
|
78
|
+
timeIntervals: ReadonlyArray<Record<string, unknown>>;
|
|
79
|
+
/** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
|
|
80
|
+
* report the field, or the widened-type passthrough this file depends on turns out not to hold
|
|
81
|
+
* for some response shape not yet observed), refuses — see alerting-provenance.ts's "fails
|
|
82
|
+
* open" note. This is the field where that default matters most: unlike the other resources in
|
|
83
|
+
* this family, `provenance` here is not even in the SDK's declared type. */
|
|
84
|
+
provenance: string | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface GrafanaMuteTiming extends Resource<
|
|
88
|
+
'Grafana.MuteTiming',
|
|
89
|
+
MuteTimingProps,
|
|
90
|
+
MuteTimingAttributes,
|
|
91
|
+
never
|
|
92
|
+
> {}
|
|
93
|
+
|
|
94
|
+
export const GrafanaMuteTiming = Resource<GrafanaMuteTiming>('Grafana.MuteTiming');
|
|
95
|
+
|
|
96
|
+
/** The real runtime shape of a `GET`/list response — see the file header's "measured" note. */
|
|
97
|
+
type MuteTimeIntervalWire = grafana.MuteTimeInterval & {
|
|
98
|
+
provenance?: string;
|
|
99
|
+
version?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const spec: GrafanaSpec<
|
|
103
|
+
MuteTimingProps,
|
|
104
|
+
MuteTimeIntervalWire,
|
|
105
|
+
MuteTimingAttributes,
|
|
106
|
+
| grafana.RouteGetMuteTimingError
|
|
107
|
+
| grafana.RoutePostMuteTimingError
|
|
108
|
+
| grafana.RoutePutMuteTimingError
|
|
109
|
+
| grafana.RouteDeleteMuteTimingError
|
|
110
|
+
| GrafanaProvisionedObjectError
|
|
111
|
+
> = {
|
|
112
|
+
attributes: (live) => ({
|
|
113
|
+
name: live.name ?? '',
|
|
114
|
+
// ⛔ NEVER `?? ''` — see alerting-provenance.ts's "fails open" note, and this file's own
|
|
115
|
+
// Attributes doc comment on why it matters most here.
|
|
116
|
+
provenance: live.provenance,
|
|
117
|
+
timeIntervals: (live.time_intervals ?? []) as unknown as ReadonlyArray<Record<string, unknown>>,
|
|
118
|
+
}),
|
|
119
|
+
create: (props) =>
|
|
120
|
+
grafana.routePostMuteTiming({
|
|
121
|
+
name: props.name,
|
|
122
|
+
time_intervals:
|
|
123
|
+
props.timeIntervals as unknown as grafana.RoutePostMuteTimingRequestTimeIntervalsList,
|
|
124
|
+
}),
|
|
125
|
+
destroy: (props, live) =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
if (isForeignProvenance(live.provenance)) {
|
|
128
|
+
return yield* refuseIfForeignProvenance('Grafana.MuteTiming', props.name, live.provenance);
|
|
129
|
+
}
|
|
130
|
+
yield* grafana.routeDeleteMuteTiming({
|
|
131
|
+
name: props.name,
|
|
132
|
+
...(live.version === undefined ? {} : { version: live.version }),
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
fetchLive: (props) =>
|
|
136
|
+
grafana.routeGetMuteTiming({ name: props.name }).pipe(
|
|
137
|
+
Effect.map((live) => live as MuteTimeIntervalWire),
|
|
138
|
+
Effect.catchTag('NotFound', () => Effect.succeed(undefined)),
|
|
139
|
+
),
|
|
140
|
+
/** Subset match (subset-match.ts), not plain equality — tolerates a default Grafana might
|
|
141
|
+
* inject into a time-interval entry the declaration never set. */
|
|
142
|
+
matches: (attributes, props) =>
|
|
143
|
+
declaredContentMatches(props.timeIntervals, attributes.timeIntervals),
|
|
144
|
+
update: (props, live) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
if (isForeignProvenance(live.provenance)) {
|
|
147
|
+
return yield* refuseIfForeignProvenance('Grafana.MuteTiming', props.name, live.provenance);
|
|
148
|
+
}
|
|
149
|
+
yield* grafana.routePutMuteTiming({
|
|
150
|
+
name: props.name,
|
|
151
|
+
time_intervals:
|
|
152
|
+
props.timeIntervals as unknown as grafana.RoutePutMuteTimingRequestTimeIntervalsList,
|
|
153
|
+
});
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const handlers = grafanaHandlers(spec);
|
|
158
|
+
|
|
159
|
+
export const GrafanaMuteTimingProvider = () =>
|
|
160
|
+
Provider.effect(GrafanaMuteTiming, Effect.succeed(GrafanaMuteTiming.Provider.of(handlers)));
|
package/src/grafana/providers.ts
CHANGED
|
@@ -12,14 +12,22 @@
|
|
|
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';
|
|
16
|
+
import { GrafanaContactPointProvider } from './contact-point.ts';
|
|
15
17
|
import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
16
18
|
import { GrafanaDashboardProvider } from './dashboard.ts';
|
|
17
19
|
import { GrafanaDatasourceProvider } from './datasource.ts';
|
|
18
20
|
import { GrafanaFolderProvider } from './folder.ts';
|
|
21
|
+
import { GrafanaMessageTemplateProvider } from './message-template.ts';
|
|
22
|
+
import { GrafanaMuteTimingProvider } from './mute-timing.ts';
|
|
19
23
|
|
|
20
24
|
export const grafanaProviders = (target: GrafanaTarget) =>
|
|
21
25
|
Layer.mergeAll(
|
|
22
26
|
GrafanaDatasourceProvider(),
|
|
23
27
|
GrafanaFolderProvider(),
|
|
24
28
|
GrafanaDashboardProvider(),
|
|
29
|
+
GrafanaContactPointProvider(),
|
|
30
|
+
GrafanaMuteTimingProvider(),
|
|
31
|
+
GrafanaMessageTemplateProvider(),
|
|
32
|
+
GrafanaAlertRuleGroupProvider(),
|
|
25
33
|
).pipe(Layer.provide(grafanaCredentials(target)));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "secret stays in env, never in state" resolution (S25) for this family —
|
|
3
|
+
* `Grafana.Datasource`'s `secureJsonDataRefs` and `Grafana.ContactPoint`'s `secureSettingsRefs`
|
|
4
|
+
* are the identical shape: a map of field name to an env var NAME, resolved fresh from
|
|
5
|
+
* `process.env` inside the caller's own create/update effect (S24), never at plan time and never
|
|
6
|
+
* returned in `attributes`. Extracted here once a second family needed the exact same loop
|
|
7
|
+
* `datasource.ts` already had — see that file's original note for why this exists at all:
|
|
8
|
+
* Grafana's own read APIs never return a secret value, only whether one is set
|
|
9
|
+
* (`DataSource.secureJsonFields`'s booleans; a contact point's `settings` comes back with secure
|
|
10
|
+
* keys replaced by a literal redaction placeholder instead — see contact-point.ts).
|
|
11
|
+
*/
|
|
12
|
+
import * as Data from 'effect/Data';
|
|
13
|
+
import * as Effect from 'effect/Effect';
|
|
14
|
+
|
|
15
|
+
/** A named secure-field env var is unset — a domain refusal, not a distilled error. */
|
|
16
|
+
export class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}> {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads every named env var fresh, inside the caller's effect (S24) — never at plan time.
|
|
22
|
+
* `describeField` builds the refusal message's own field-specific phrase — each caller names its
|
|
23
|
+
* declaration path differently (`secureJsonData.<field>` for a datasource,
|
|
24
|
+
* `settings.<field>` for a contact point) — so the message stays accurate to what the caller
|
|
25
|
+
* itself declares.
|
|
26
|
+
*/
|
|
27
|
+
export const resolveSecretRefs = (
|
|
28
|
+
refs: Record<string, string> | undefined,
|
|
29
|
+
describeField: (field: string) => string,
|
|
30
|
+
): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
if (refs === undefined) return undefined;
|
|
33
|
+
const out: Record<string, string> = {};
|
|
34
|
+
for (const [field, envVar] of Object.entries(refs)) {
|
|
35
|
+
const raw = process.env[envVar];
|
|
36
|
+
if (raw === undefined || raw.trim() === '') {
|
|
37
|
+
return yield* Effect.fail(
|
|
38
|
+
new GrafanaSecretRefUnsetError({
|
|
39
|
+
message:
|
|
40
|
+
`${envVar} is unset. Export the value of ${describeField(field)} there — ` +
|
|
41
|
+
'never as an Alchemy prop.',
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
out[field] = raw.trim();
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declaredContentMatches` — "is the declaration a subset of what is live", not plain equality.
|
|
3
|
+
* Extracted out of `dashboard-model.ts` (where it was introduced, after an adversarial review of
|
|
4
|
+
* that PR found plain structural equality made an ordinary, never-edited dashboard show `update`
|
|
5
|
+
* FOREVER) once a second shape needed the identical rule: `Grafana.ContactPoint`'s `settings` is
|
|
6
|
+
* the same story — Grafana can inject default fields into a contact point's settings that the
|
|
7
|
+
* declaration never mentioned, and `Grafana.AlertRuleGroup`'s per-rule `data`/`annotations`/
|
|
8
|
+
* `labels` maps carry the same risk. Nothing about the reasoning below is dashboard-specific;
|
|
9
|
+
* `dashboard-model.ts` now just re-exports this.
|
|
10
|
+
*
|
|
11
|
+
* ★ `declared` NEED ONLY BE A SUBSET OF `live`. Grafana's own save/decode path can decorate an
|
|
12
|
+
* object with fields the declaration never set (a dashboard panel's auto-resolved `datasource`
|
|
13
|
+
* reference is the documented example dashboard-model.ts found — Grafana's schema migration
|
|
14
|
+
* decorates any panel/target whose `datasource` is absent with an explicit `{type, uid}`
|
|
15
|
+
* reference on save). This function instead asks only "does every field the DECLARATION
|
|
16
|
+
* mentions, at any depth, still match what is live" — `live` may carry additional fields, at any
|
|
17
|
+
* depth, that `declared` never mentioned, without being compared at all. A field `declared` DOES
|
|
18
|
+
* mention that is missing, or different, in `live` still fails the match — real content drift is
|
|
19
|
+
* still caught exactly as before; only fields the declaration is silent about are now tolerated.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ THE TRADE-OFF THIS ACCEPTS, DELIBERATELY: removing a field from the declaration (rather than
|
|
22
|
+
* changing its value) can no longer force Grafana to drop it. If a previous declaration set a
|
|
23
|
+
* field and a later one deletes that key entirely, `live` keeps whatever the last write set — the
|
|
24
|
+
* declaration is silent about that key now, so nothing compares it, so no `update` ever fires to
|
|
25
|
+
* change it. This is the same "undeclared means don't care, never means force-to-default"
|
|
26
|
+
* convention already used everywhere else in this kit for a plain optional prop; it now also
|
|
27
|
+
* applies inside a nested JSON value, not just at the top-level props. Declare an explicit
|
|
28
|
+
* replacement value to change a field; omission never clears one that was set by a previous
|
|
29
|
+
* declaration or by Grafana itself.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ ARRAYS COMPARE BY LENGTH, THEN ELEMENT-WISE SUBSET — never a looser "declared has fewer
|
|
32
|
+
* items" rule. A caller whose array IS order-significant (dashboard `panels`/`targets`) or
|
|
33
|
+
* position-keyed (an alert rule group's `rules`) gets that for free; a caller with a genuinely
|
|
34
|
+
* unordered array would need its own normalization first (this file has no `sortedSet`, and
|
|
35
|
+
* nothing in this kit's Grafana family needs one — see dashboard-model.ts's own note).
|
|
36
|
+
*
|
|
37
|
+
* ⛔ NOT RE-MEASURED FOR EVERY CALLER — flagged by an adversarial review of this PR. The exact
|
|
38
|
+
* length rule is confirmed for dashboards (the original, documented case), but whether Grafana
|
|
39
|
+
* ever PADS or APPENDS a default entry to an array the way it decorates an object field (the
|
|
40
|
+
* `datasource` auto-decoration above) is unverified for `Grafana.MuteTiming`'s `time_intervals`
|
|
41
|
+
* specifically — no live mute timing exists in this house to observe (grafana.md's census), and
|
|
42
|
+
* `mute-timing.test.ts`'s own fixtures are hand-authored, not captured. If a future live
|
|
43
|
+
* observation finds Grafana appending an entry (e.g. a normalized default time range) to a
|
|
44
|
+
* declared `time_intervals` array, the length check here would show a permanent false `update` —
|
|
45
|
+
* the exact regression this whole file exists to prevent for OBJECT fields already. `mute-timing.
|
|
46
|
+
* test.ts`'s "array length is exact, not tolerant" test pins today's behavior so that fix has an
|
|
47
|
+
* obvious, single place to change (relax this rule, or give the mute-timing caller its own
|
|
48
|
+
* length-tolerant wrapper) rather than being rediscovered from scratch.
|
|
49
|
+
* ⚠️ `null` ON EITHER SIDE OF A LEAF NEVER MISMATCHES (mirrors `alchemy/Diff`'s `deepEqual` with
|
|
50
|
+
* `stripNullish: true`, which this function replaces wherever a caller needs subset semantics).
|
|
51
|
+
*/
|
|
52
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
53
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
54
|
+
|
|
55
|
+
export const declaredContentMatches = (declared: unknown, live: unknown): boolean => {
|
|
56
|
+
if (declared == null) return true;
|
|
57
|
+
if (Array.isArray(declared)) {
|
|
58
|
+
return (
|
|
59
|
+
Array.isArray(live) &&
|
|
60
|
+
declared.length === live.length &&
|
|
61
|
+
declared.every((item, index) => declaredContentMatches(item, live[index]))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (isPlainObject(declared)) {
|
|
65
|
+
return (
|
|
66
|
+
isPlainObject(live) &&
|
|
67
|
+
Object.entries(declared).every(([key, value]) => declaredContentMatches(value, live[key]))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return declared === live;
|
|
71
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renderContainerFile`/`containerPathFor` are reachable from the package entry — the same pure
|
|
3
|
+
* `.container` renderer `container-fixture.test.ts` (container-form.ts) already proves internally,
|
|
4
|
+
* now callable by a CONSUMER without a deep import.
|
|
5
|
+
*
|
|
6
|
+
* ★ WHY THIS WAS MISSING. `index.ts`'s barrel forwarded `QUADLET_DEFAULT_DIRECTORY`/
|
|
7
|
+
* `QUADLET_SEARCH_DIRECTORIES` (constants) but not the renderer or the path rule, unlike
|
|
8
|
+
* `Systemd.Unit`'s own pure helpers (`renderUnit`/`DEFAULT_UNIT_DIRECTORY`), which ARE both on
|
|
9
|
+
* the barrel. A consumer (homeflare-ct100 PR #1) writing an equivalence test against a live
|
|
10
|
+
* host — the same kind of test this package's own fixture tests are — had no import path to
|
|
11
|
+
* the real renderer and had to reimplement the render contract by hand to write one at all.
|
|
12
|
+
*/
|
|
13
|
+
import { expect, test } from 'bun:test';
|
|
14
|
+
import { containerPathFor, renderContainerFile } from './index.ts';
|
|
15
|
+
|
|
16
|
+
test('renderContainerFile and containerPathFor are reachable from the package entry', () => {
|
|
17
|
+
const rendered = renderContainerFile({
|
|
18
|
+
container: { image: 'docker.io/library/busybox:1', network: 'host' },
|
|
19
|
+
name: 'smoke',
|
|
20
|
+
});
|
|
21
|
+
expect(rendered).toContain('[Container]');
|
|
22
|
+
expect(rendered).toContain('Image=docker.io/library/busybox:1');
|
|
23
|
+
expect(rendered).toContain('Network=host');
|
|
24
|
+
expect(containerPathFor({ name: 'smoke' })).toBe('/etc/containers/systemd/smoke.container');
|
|
25
|
+
});
|
package/src/linux/index.ts
CHANGED
|
@@ -38,5 +38,19 @@ export type {
|
|
|
38
38
|
PodmanContainerProps,
|
|
39
39
|
PodmanUnitLine,
|
|
40
40
|
} from './container.ts';
|
|
41
|
-
|
|
41
|
+
// ★ `renderContainerFile`/`containerPathFor` are the pure `.container` file renderer and its path
|
|
42
|
+
// rule — the same kind of pure helper `renderUnit`/`DEFAULT_UNIT_DIRECTORY` above already are
|
|
43
|
+
// for `Systemd.Unit`. A consumer needs these to prove ITS OWN declared props render into the
|
|
44
|
+
// directive set it expects (an equivalence/fixture test against a live host), the same way this
|
|
45
|
+
// package's own container-fixture.test.ts does internally — without them, a consuming repo has
|
|
46
|
+
// no import path to the real renderer and has to reimplement it to write that test at all
|
|
47
|
+
// (found: homeflare-ct100 PR #1). `containerProblems`/`refuseContainer`/`serviceNameFor` stay
|
|
48
|
+
// internal — no consumer need for those was raised, and validation/naming internals are a wider
|
|
49
|
+
// surface to commit to than one pure render function.
|
|
50
|
+
export {
|
|
51
|
+
containerPathFor,
|
|
52
|
+
QUADLET_DEFAULT_DIRECTORY,
|
|
53
|
+
QUADLET_SEARCH_DIRECTORIES,
|
|
54
|
+
renderContainerFile,
|
|
55
|
+
} from './container-form.ts';
|
|
42
56
|
export { PodmanContainer, PodmanContainerProvider } from './container.ts';
|
|
@@ -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';
|