@homeflare/alchemy 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/grafana/alert-rule-group-drop-warning.d.ts +25 -0
- package/dist/grafana/alert-rule-group-drop-warning.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts +73 -0
- package/dist/grafana/alert-rule-group-fixtures.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group-model.d.ts +28 -0
- package/dist/grafana/alert-rule-group-model.d.ts.map +1 -0
- package/dist/grafana/alert-rule-group.d.ts +60 -0
- package/dist/grafana/alert-rule-group.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +5 -4
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +241 -139
- package/dist/grafana/index.js.map +11 -8
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/proxmox/index.js +349 -287
- package/dist/proxmox/index.js.map +8 -7
- package/dist/proxmox/network-apply-read.d.ts +12 -0
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply.d.ts +2 -0
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/node-network-form.d.ts +86 -54
- package/dist/proxmox/node-network-form.d.ts.map +1 -1
- package/dist/proxmox/node-network-wire.d.ts +100 -0
- package/dist/proxmox/node-network-wire.d.ts.map +1 -0
- package/dist/proxmox/node-network.d.ts +21 -106
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/docs/grafana-alerting-rules.md +143 -0
- package/docs/grafana-alerting.md +5 -3
- package/docs/grafana.md +4 -2
- package/docs/upstream-conformance.md +16 -4
- package/package.json +1 -1
- package/src/grafana/alert-rule-group-drop-warning.test.ts +107 -0
- package/src/grafana/alert-rule-group-drop-warning.ts +41 -0
- package/src/grafana/alert-rule-group-fixtures.ts +46 -0
- package/src/grafana/alert-rule-group-model.test.ts +75 -0
- package/src/grafana/alert-rule-group-model.ts +66 -0
- package/src/grafana/alert-rule-group-provenance.test.ts +116 -0
- package/src/grafana/alert-rule-group.test.ts +164 -0
- package/src/grafana/alert-rule-group.ts +231 -0
- package/src/grafana/index.ts +11 -4
- package/src/grafana/providers.ts +2 -0
- package/src/proxmox/constraints-host-forms.test.ts +2 -2
- package/src/proxmox/network-apply-read.ts +12 -0
- package/src/proxmox/network-apply.ts +2 -0
- package/src/proxmox/node-network-form.ts +93 -99
- package/src/proxmox/node-network-read-failure.test.ts +206 -0
- package/src/proxmox/node-network-wire.ts +239 -0
- package/src/proxmox/node-network.ts +155 -167
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.AlertRuleGroup`'s per-rule provenance refusal — explicit-foreign (`"file"` on one
|
|
3
|
+
* rule), missing (a rule whose response omits the field, failing closed the same way every other
|
|
4
|
+
* resource in this family does), an EXPLICIT `""` being writable, and an empty group having
|
|
5
|
+
* nothing to refuse on. Split out of `alert-rule-group.test.ts` to keep each file under the
|
|
6
|
+
* house's 250-line cap.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
|
|
11
|
+
import { PATH, liveJson, liveRule, props } from './alert-rule-group-fixtures.ts';
|
|
12
|
+
import { spec } from './alert-rule-group.ts';
|
|
13
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
14
|
+
import { grafanaOperations } from './resource.ts';
|
|
15
|
+
|
|
16
|
+
describe('a foreign-provenance rule refuses the whole group', () => {
|
|
17
|
+
test('one file-provisioned rule among several refuses update and sends no PUT', async () => {
|
|
18
|
+
const mixed = {
|
|
19
|
+
...liveJson,
|
|
20
|
+
rules: [liveRule, { ...liveRule, uid: 'other', provenance: 'file' }],
|
|
21
|
+
};
|
|
22
|
+
const fake = fakeGrafana((method, url) =>
|
|
23
|
+
method === 'GET' && url.pathname === PATH
|
|
24
|
+
? Response.json(mixed)
|
|
25
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
26
|
+
);
|
|
27
|
+
const failure = await Effect.runPromise(
|
|
28
|
+
Effect.flip(
|
|
29
|
+
grafanaOperations(spec)
|
|
30
|
+
.reconcile({ ...props, interval: 120 })
|
|
31
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
35
|
+
expect(failure.message).toContain('file');
|
|
36
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a file-provisioned group refuses destroy and sends no DELETE', async () => {
|
|
40
|
+
const fileProvisioned = { ...liveJson, rules: [{ ...liveRule, provenance: 'file' }] };
|
|
41
|
+
const fake = fakeGrafana((method, url) =>
|
|
42
|
+
method === 'GET' && url.pathname === PATH
|
|
43
|
+
? Response.json(fileProvisioned)
|
|
44
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
45
|
+
);
|
|
46
|
+
const failure = await Effect.runPromise(
|
|
47
|
+
Effect.flip(
|
|
48
|
+
grafanaOperations(spec)
|
|
49
|
+
.destroy(props)
|
|
50
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
54
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: PATH }]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('missing provenance on a rule fails closed', () => {
|
|
59
|
+
// ⛔ SAME REGRESSION AN ADVERSARIAL REVIEW OF kit PR 250 FOUND — see contact-point-provenance.
|
|
60
|
+
// test.ts's own copy of this block for the full account.
|
|
61
|
+
const noProvenanceRule: Record<string, unknown> = { ...liveRule };
|
|
62
|
+
delete noProvenanceRule.provenance;
|
|
63
|
+
|
|
64
|
+
test('a response where a rule omits provenance entirely refuses update', async () => {
|
|
65
|
+
const missing = { ...liveJson, rules: [noProvenanceRule] };
|
|
66
|
+
const fake = fakeGrafana((method, url) =>
|
|
67
|
+
method === 'GET' && url.pathname === PATH
|
|
68
|
+
? Response.json(missing)
|
|
69
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
70
|
+
);
|
|
71
|
+
const failure = await Effect.runPromise(
|
|
72
|
+
Effect.flip(
|
|
73
|
+
grafanaOperations(spec)
|
|
74
|
+
.reconcile({ ...props, interval: 120 })
|
|
75
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
79
|
+
expect(failure.message).toContain('did not report a provenance');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
|
|
83
|
+
const explicitlyNone = { ...liveJson, rules: [{ ...liveRule, provenance: '' }] };
|
|
84
|
+
const fake = fakeGrafana((method, url) => {
|
|
85
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(explicitlyNone);
|
|
86
|
+
if (method === 'PUT' && url.pathname === PATH) return Response.json(explicitlyNone);
|
|
87
|
+
return fakeFailure(500, 'unexpected request');
|
|
88
|
+
});
|
|
89
|
+
await Effect.runPromise(
|
|
90
|
+
grafanaOperations(spec)
|
|
91
|
+
.reconcile({ ...props, interval: 120 })
|
|
92
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
93
|
+
);
|
|
94
|
+
expect(fake.seen.some((s) => s.method === 'PUT')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('an empty live group has nothing to refuse on', () => {
|
|
99
|
+
test('a group with zero rules is never foreign, and destroy proceeds', async () => {
|
|
100
|
+
const empty = { ...liveJson, rules: [] };
|
|
101
|
+
const fake = fakeGrafana((method, url) => {
|
|
102
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(empty);
|
|
103
|
+
if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
|
|
104
|
+
return fakeFailure(500, 'unexpected request');
|
|
105
|
+
});
|
|
106
|
+
await Effect.runPromise(
|
|
107
|
+
grafanaOperations(spec)
|
|
108
|
+
.destroy(props)
|
|
109
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
110
|
+
);
|
|
111
|
+
expect(fake.seen).toEqual([
|
|
112
|
+
{ method: 'GET', path: PATH },
|
|
113
|
+
{ method: 'DELETE', path: PATH },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.AlertRuleGroup` against a fake Grafana — the group-not-rule unit's create/update as one
|
|
3
|
+
* PUT, rule-order significance, volatile-field normalization, and the folder-doesn't-exist refusal.
|
|
4
|
+
* Provenance refusal tests: `alert-rule-group-provenance.test.ts` — split to keep each file under
|
|
5
|
+
* the house's 250-line cap. Mirrors `dashboard.test.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import { PATH, liveJson, props, ruleInput } from './alert-rule-group-fixtures.ts';
|
|
10
|
+
import { spec } from './alert-rule-group.ts';
|
|
11
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
12
|
+
import { grafanaOperations } from './resource.ts';
|
|
13
|
+
|
|
14
|
+
describe('spec.fetchLive', () => {
|
|
15
|
+
test('a live group decodes and normalizes into typed attributes', async () => {
|
|
16
|
+
const fake = fakeGrafana((method, url) =>
|
|
17
|
+
method === 'GET' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
|
|
18
|
+
);
|
|
19
|
+
const live = await Effect.runPromise(
|
|
20
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
21
|
+
);
|
|
22
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
23
|
+
folderUid: 'infra',
|
|
24
|
+
group: 'default',
|
|
25
|
+
interval: 60,
|
|
26
|
+
rules: [
|
|
27
|
+
{
|
|
28
|
+
condition: 'A',
|
|
29
|
+
data: [{ refId: 'A' }],
|
|
30
|
+
execErrState: 'Alerting',
|
|
31
|
+
folderUID: 'infra',
|
|
32
|
+
for: '5m',
|
|
33
|
+
noDataState: 'NoData',
|
|
34
|
+
orgID: 1,
|
|
35
|
+
ruleGroup: 'default',
|
|
36
|
+
title: 'High CPU',
|
|
37
|
+
uid: 'high-cpu',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a 404 folds to undefined', async () => {
|
|
45
|
+
const fake = fakeGrafana(() => fakeFailure(404, 'rule group not found'));
|
|
46
|
+
const result = await Effect.runPromise(
|
|
47
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
48
|
+
);
|
|
49
|
+
expect(result).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
53
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
54
|
+
const failure = await Effect.runPromise(
|
|
55
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
56
|
+
);
|
|
57
|
+
expect(failure._tag).toBe('Forbidden');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('create', () => {
|
|
62
|
+
test('sends interval and rules with the group-derived fields filled in', async () => {
|
|
63
|
+
const fake = fakeGrafana((method, url) =>
|
|
64
|
+
method === 'PUT' && url.pathname === PATH ? Response.json(liveJson) : fakeFailure(404, 'x'),
|
|
65
|
+
);
|
|
66
|
+
await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
67
|
+
expect(fake.bodies[0]).toEqual({
|
|
68
|
+
folderUid: 'infra',
|
|
69
|
+
interval: 60,
|
|
70
|
+
rules: [{ ...ruleInput, folderUID: 'infra', orgID: 1, ruleGroup: 'default' }],
|
|
71
|
+
title: 'default',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a nonexistent target folder fails as a typed BadRequest, never silently created', async () => {
|
|
76
|
+
// ⛔ THE POINT: this resource never checks folder existence itself and never calls
|
|
77
|
+
// `Grafana.Folder`'s create path — see the file header. `RoutePutAlertRuleGroupError` has no
|
|
78
|
+
// `NotFound` case; Grafana's own validation failure is a `BadRequest`, left uncaught here.
|
|
79
|
+
const fake = fakeGrafana(() => fakeFailure(400, 'folder does not exist'));
|
|
80
|
+
const failure = await Effect.runPromise(
|
|
81
|
+
Effect.flip(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
82
|
+
);
|
|
83
|
+
expect(failure._tag).toBe('BadRequest');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('reconcile — adopt semantics, rule order significant', () => {
|
|
88
|
+
test('declaring exactly what is live is a noop despite id/updated/provenance differing', async () => {
|
|
89
|
+
const fake = fakeGrafana((method, url) =>
|
|
90
|
+
method === 'GET' && url.pathname === PATH
|
|
91
|
+
? Response.json(liveJson)
|
|
92
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
93
|
+
);
|
|
94
|
+
const after = await Effect.runPromise(
|
|
95
|
+
grafanaOperations(spec)
|
|
96
|
+
.reconcile(props)
|
|
97
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
98
|
+
);
|
|
99
|
+
expect(after.group).toBe('default');
|
|
100
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('two rules in a different order than live shows update — order is content, not cosmetic', async () => {
|
|
104
|
+
// ⛔ live is [low-disk, high-cpu]; declared below is [high-cpu, low-disk] — the SAME two rules,
|
|
105
|
+
// reversed. A looser "declared is a set of live's rules" comparison would call this a match;
|
|
106
|
+
// subset-match's array rule (position-significant) must not.
|
|
107
|
+
const secondRule = { ...ruleInput, title: 'Low Disk', uid: 'low-disk' };
|
|
108
|
+
const reordered = {
|
|
109
|
+
...liveJson,
|
|
110
|
+
rules: [
|
|
111
|
+
{ ...liveJson.rules[0], title: 'Low Disk', uid: 'low-disk' },
|
|
112
|
+
{ ...liveJson.rules[0] },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
const fake = fakeGrafana((method, url) =>
|
|
116
|
+
method === 'GET' && url.pathname === PATH ? Response.json(reordered) : fakeFailure(500, 'x'),
|
|
117
|
+
);
|
|
118
|
+
const live = await Effect.runPromise(
|
|
119
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
120
|
+
);
|
|
121
|
+
const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
|
|
122
|
+
const declaredReordered = { ...props, rules: [ruleInput, secondRule] };
|
|
123
|
+
expect(attrs !== undefined && spec.matches(attrs, declaredReordered)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('update', () => {
|
|
128
|
+
test('a content change updates via the same PUT, sending only declared fields', async () => {
|
|
129
|
+
const changed = { ...ruleInput, for: '10m' };
|
|
130
|
+
const fake = fakeGrafana((method, url) => {
|
|
131
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
|
|
132
|
+
if (method === 'PUT' && url.pathname === PATH) {
|
|
133
|
+
return Response.json({ ...liveJson, rules: [{ ...liveJson.rules[0], for: '10m' }] });
|
|
134
|
+
}
|
|
135
|
+
return fakeFailure(500, 'unexpected request');
|
|
136
|
+
});
|
|
137
|
+
await Effect.runPromise(
|
|
138
|
+
grafanaOperations(spec)
|
|
139
|
+
.reconcile({ ...props, rules: [changed] })
|
|
140
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
141
|
+
);
|
|
142
|
+
const put = fake.seen.findIndex((s) => s.method === 'PUT');
|
|
143
|
+
expect(fake.bodies[put]).toMatchObject({ rules: [{ for: '10m' }] });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('destroy', () => {
|
|
148
|
+
test('deletes a live, api-provenance group', async () => {
|
|
149
|
+
const fake = fakeGrafana((method, url) => {
|
|
150
|
+
if (method === 'GET' && url.pathname === PATH) return Response.json(liveJson);
|
|
151
|
+
if (method === 'DELETE' && url.pathname === PATH) return Response.json({});
|
|
152
|
+
return fakeFailure(500, 'unexpected request');
|
|
153
|
+
});
|
|
154
|
+
await Effect.runPromise(
|
|
155
|
+
grafanaOperations(spec)
|
|
156
|
+
.destroy(props)
|
|
157
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
158
|
+
);
|
|
159
|
+
expect(fake.seen).toEqual([
|
|
160
|
+
{ method: 'GET', path: PATH },
|
|
161
|
+
{ method: 'DELETE', path: PATH },
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.AlertRuleGroup` — one Grafana alert rule GROUP, keyed by `(folderUid, group)`.
|
|
3
|
+
*
|
|
4
|
+
* ★ GROUP, NOT PER-RULE — THE UNIT CHOICE, JUSTIFIED. MEASURED against the SDK's generated
|
|
5
|
+
* operations: `PUT /v1/provisioning/folder/{FolderUID}/rule-groups/{Group}`
|
|
6
|
+
* (`routePutAlertRuleGroup`) takes the evaluation `interval` AND the full ordered `rules[]`
|
|
7
|
+
* TOGETHER, replacing the whole group in one call (`RoutePutAlertRuleGroupRequest.rules:
|
|
8
|
+
* Array<ProvisionedAlertRuleInput>`) — the individual `routePostAlertRule`/`routePutAlertRule`
|
|
9
|
+
* operations have no `interval` field at all and cannot reorder rules within a group. Declaring
|
|
10
|
+
* rules one at a time (`Grafana.AlertRule`) would leave a group's evaluation interval and rule
|
|
11
|
+
* order — both real, load-bearing Grafana state — either undeclarable or racy across N
|
|
12
|
+
* independent resources with no shared source of truth for them. Grafana's own docs describe
|
|
13
|
+
* `X-Disable-Provenance` on the rule-group PUT as setting "the provenance for the rule group AND
|
|
14
|
+
* ALL ITS ALERT RULES" — further evidence the group, not the rule, is Grafana's own natural unit
|
|
15
|
+
* of ownership here. Mirrors Terraform's own `grafana_rule_group` resource, which takes the
|
|
16
|
+
* identical shape (a list of rules under one folder/name/interval).
|
|
17
|
+
*
|
|
18
|
+
* ★ `uid` IS REQUIRED ON EVERY DECLARED RULE — same doctrine as every other resource in this
|
|
19
|
+
* family (`AlertRuleInput` in `alert-rule-group-model.ts`). `ProvisionedAlertRuleInput.uid` is
|
|
20
|
+
* optional on the wire (Grafana assigns one if omitted), but locating a specific rule this
|
|
21
|
+
* resource didn't just create would then mean matching by title — the same ambiguity
|
|
22
|
+
* `resource.ts`'s header and netbox's `soleMatch` exist to catch everywhere else in this family.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ RULE ORDER IS SIGNIFICANT — NEVER REORDERED. `subset-match.ts`'s array comparison is already
|
|
25
|
+
* position-significant (length, then element-wise) — this resource relies on that as-is; nothing
|
|
26
|
+
* here re-sorts `rules` before comparing. Declaring rules in a different order than what is live
|
|
27
|
+
* is a genuine content change (Grafana evaluates and displays a group's rules in array order),
|
|
28
|
+
* not a cosmetic one.
|
|
29
|
+
*
|
|
30
|
+
* ⛔ A FOREIGN-PROVENANCE GROUP REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`. Unlike
|
|
31
|
+
* `ContactPoint`/`MessageTemplate`, `AlertRuleGroup` (the type `routeGetAlertRuleGroup` returns)
|
|
32
|
+
* carries NO top-level `provenance` field — MEASURED: `folderUid?`/`interval?`/`rules?`/`title?`
|
|
33
|
+
* only. Each RULE inside `rules[]` has its own (`ProvisionedAlertRule.provenance`), and Grafana's
|
|
34
|
+
* docs say a new rule's provenance "must match the provenance value configured for its rule
|
|
35
|
+
* group" — so this resource checks every live rule and refuses if ANY carries a foreign
|
|
36
|
+
* provenance, naming the first one found. An empty live group (no rules yet) has nothing to check
|
|
37
|
+
* and is never foreign. `create` is never at risk: it only runs when `fetchLive` already found
|
|
38
|
+
* nothing.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ A DECLARED GROUP WHOSE FOLDER DOES NOT EXIST REFUSES — IT IS NEVER AUTO-CREATED. This resource
|
|
41
|
+
* makes no "does the folder exist" call of its own and does not depend on or invoke
|
|
42
|
+
* `Grafana.Folder`'s create path. MEASURED: `RoutePutAlertRuleGroupError` is
|
|
43
|
+
* `BadRequest | Forbidden | GrafanaOpError` — no `NotFound` case at all — so a nonexistent
|
|
44
|
+
* `folderUid` fails the PUT as a typed `BadRequest`, left uncaught and propagated (the house rule
|
|
45
|
+
* against folding a non-not-found error into absent), which fails the whole `reconcile` and stops
|
|
46
|
+
* the deploy. Declare a `Grafana.Folder` for it first — the two resources sharing one `folderUid`
|
|
47
|
+
* string is the dependency; auto-creating a folder from inside this resource would blur ownership
|
|
48
|
+
* the same way `provisioned.ts`'s "one owner per resource" principle already guards against
|
|
49
|
+
* elsewhere in this family. ⚠️ NOT RE-MEASURED against a live instance in this PR — inferred from
|
|
50
|
+
* the declared error union, the same evidentiary bar `folder.ts`'s own `version` note uses.
|
|
51
|
+
*
|
|
52
|
+
* ⛔ DELETE DEFAULTS TO `retain` — DELETING A GROUP DELETES EVERY RULE IN IT IN ONE CALL
|
|
53
|
+
* (`routeDeleteAlertRuleGroup`, whole-group DELETE). The same multi-object-cascade class
|
|
54
|
+
* `Grafana.Folder`/`openbao/mount.ts` already guard with `defaultRemovalPolicy: 'retain'` — a
|
|
55
|
+
* stack that wants the cascade opts in with `.pipe(RemovalPolicy.destroy())`; `destroy` below
|
|
56
|
+
* still implements the real DELETE in full either way (S11).
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ `orgID` DEFAULTS TO `1` ON EVERY WRITTEN RULE — see `alert-rule-group-model.ts`'s own note;
|
|
59
|
+
* not measured against a multi-org instance, since none exists in this house yet.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ `X-Disable-Provenance` IS NEVER SET — same reasoning as `contact-point.ts`'s file header.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ A WHOLE-GROUP WRITE SILENTLY DROPS ANY LIVE RULE THE DECLARATION DOESN'T MENTION — flagged by
|
|
64
|
+
* an adversarial review of this PR. Unlike every other resource in this family, this one does
|
|
65
|
+
* not compare one object's fields; `update`/`diff` REPLACE an entire collection, so a live rule
|
|
66
|
+
* with no counterpart in `props.rules` (e.g. one a human just added in the Grafana UI, ordinary
|
|
67
|
+
* writable provenance — nothing for the refusal above to catch) is removed with no error and no
|
|
68
|
+
* distinct `Diff` action to report it under. This is accepted by design — the group is still the
|
|
69
|
+
* unit — but never SILENT: `alert-rule-group-drop-warning.ts`'s `warnOnDroppedRules` logs every
|
|
70
|
+
* dropped rule by title and uid, called from both the custom `diff` below (so `bun run plan`
|
|
71
|
+
* shows it before anything is written) and from `update` itself (so it is visible wherever
|
|
72
|
+
* `reconcile` runs directly too).
|
|
73
|
+
*/
|
|
74
|
+
import { isResolved } from 'alchemy/Diff';
|
|
75
|
+
import type { Input } from 'alchemy/Input';
|
|
76
|
+
import { Resource } from 'alchemy';
|
|
77
|
+
import * as Provider from 'alchemy/Provider';
|
|
78
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
79
|
+
import * as Effect from 'effect/Effect';
|
|
80
|
+
import { warnOnDroppedRules } from './alert-rule-group-drop-warning.ts';
|
|
81
|
+
import { type AlertRuleInput, normalizeRule, toWireRule } from './alert-rule-group-model.ts';
|
|
82
|
+
import {
|
|
83
|
+
type GrafanaProvisionedObjectError,
|
|
84
|
+
isForeignProvenance,
|
|
85
|
+
refuseIfForeignProvenance,
|
|
86
|
+
} from './alerting-provenance.ts';
|
|
87
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
88
|
+
import { declaredContentMatches } from './subset-match.ts';
|
|
89
|
+
|
|
90
|
+
export type { AlertRuleInput };
|
|
91
|
+
|
|
92
|
+
export interface AlertRuleGroupProps {
|
|
93
|
+
folderUid: string;
|
|
94
|
+
group: string;
|
|
95
|
+
/** Evaluation interval, in seconds. */
|
|
96
|
+
interval: number;
|
|
97
|
+
/** Order is significant — see the file header. */
|
|
98
|
+
rules: ReadonlyArray<AlertRuleInput>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AlertRuleGroupAttributes {
|
|
102
|
+
folderUid: string;
|
|
103
|
+
group: string;
|
|
104
|
+
interval: number;
|
|
105
|
+
rules: ReadonlyArray<Record<string, unknown>>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface GrafanaAlertRuleGroup extends Resource<
|
|
109
|
+
'Grafana.AlertRuleGroup',
|
|
110
|
+
AlertRuleGroupProps,
|
|
111
|
+
AlertRuleGroupAttributes,
|
|
112
|
+
never
|
|
113
|
+
> {}
|
|
114
|
+
|
|
115
|
+
export const GrafanaAlertRuleGroup = Resource<GrafanaAlertRuleGroup>('Grafana.AlertRuleGroup', {
|
|
116
|
+
defaultRemovalPolicy: 'retain',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/** The first live rule whose provenance this family may not write to, or `undefined` when every
|
|
120
|
+
* rule (including none at all) is writable. */
|
|
121
|
+
const findForeignRule = (
|
|
122
|
+
rules: ReadonlyArray<grafana.ProvisionedAlertRule> | undefined,
|
|
123
|
+
): grafana.ProvisionedAlertRule | undefined =>
|
|
124
|
+
(rules ?? []).find((rule) => isForeignProvenance(rule.provenance));
|
|
125
|
+
|
|
126
|
+
const body = (props: AlertRuleGroupProps) => ({
|
|
127
|
+
folderUid: props.folderUid,
|
|
128
|
+
interval: props.interval,
|
|
129
|
+
rules: props.rules.map((rule) => toWireRule(rule, props.folderUid, props.group)),
|
|
130
|
+
title: props.group,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const refuseForForeignRule = (props: AlertRuleGroupProps, foreign: grafana.ProvisionedAlertRule) =>
|
|
134
|
+
refuseIfForeignProvenance(
|
|
135
|
+
'Grafana.AlertRuleGroup',
|
|
136
|
+
`${props.folderUid}/${props.group}`,
|
|
137
|
+
foreign.provenance,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const declaredUids = (props: AlertRuleGroupProps): ReadonlySet<string> =>
|
|
141
|
+
new Set(props.rules.map((rule) => rule.uid));
|
|
142
|
+
|
|
143
|
+
export const spec: GrafanaSpec<
|
|
144
|
+
AlertRuleGroupProps,
|
|
145
|
+
grafana.AlertRuleGroup,
|
|
146
|
+
AlertRuleGroupAttributes,
|
|
147
|
+
| grafana.RouteGetAlertRuleGroupError
|
|
148
|
+
| grafana.RoutePutAlertRuleGroupError
|
|
149
|
+
| grafana.RouteDeleteAlertRuleGroupError
|
|
150
|
+
| GrafanaProvisionedObjectError
|
|
151
|
+
> = {
|
|
152
|
+
attributes: (live, props) => ({
|
|
153
|
+
folderUid: live.folderUid ?? props.folderUid,
|
|
154
|
+
group: props.group,
|
|
155
|
+
interval: live.interval ?? 0,
|
|
156
|
+
rules: (live.rules ?? []).map((rule) =>
|
|
157
|
+
normalizeRule(rule as unknown as Record<string, unknown>, props.folderUid, props.group),
|
|
158
|
+
),
|
|
159
|
+
}),
|
|
160
|
+
create: (props) =>
|
|
161
|
+
grafana.routePutAlertRuleGroup({
|
|
162
|
+
FolderUID: props.folderUid,
|
|
163
|
+
Group: props.group,
|
|
164
|
+
...body(props),
|
|
165
|
+
}),
|
|
166
|
+
destroy: (props, live) =>
|
|
167
|
+
Effect.gen(function* () {
|
|
168
|
+
const foreign = findForeignRule(live.rules);
|
|
169
|
+
if (foreign !== undefined) return yield* refuseForForeignRule(props, foreign);
|
|
170
|
+
yield* grafana.routeDeleteAlertRuleGroup({ FolderUID: props.folderUid, Group: props.group });
|
|
171
|
+
}),
|
|
172
|
+
fetchLive: (props) =>
|
|
173
|
+
grafana
|
|
174
|
+
.routeGetAlertRuleGroup({ FolderUID: props.folderUid, Group: props.group })
|
|
175
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
176
|
+
matches: (attributes, props) =>
|
|
177
|
+
attributes.interval === props.interval &&
|
|
178
|
+
declaredContentMatches(
|
|
179
|
+
props.rules.map((rule) =>
|
|
180
|
+
normalizeRule(rule as unknown as Record<string, unknown>, props.folderUid, props.group),
|
|
181
|
+
),
|
|
182
|
+
attributes.rules,
|
|
183
|
+
),
|
|
184
|
+
update: (props, live) =>
|
|
185
|
+
Effect.gen(function* () {
|
|
186
|
+
const foreign = findForeignRule(live.rules);
|
|
187
|
+
if (foreign !== undefined) return yield* refuseForForeignRule(props, foreign);
|
|
188
|
+
yield* warnOnDroppedRules(props.folderUid, props.group, declaredUids(props), live.rules);
|
|
189
|
+
yield* grafana.routePutAlertRuleGroup({
|
|
190
|
+
FolderUID: props.folderUid,
|
|
191
|
+
Group: props.group,
|
|
192
|
+
...body(props),
|
|
193
|
+
});
|
|
194
|
+
}),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* ⚠️ A LOCAL REIMPLEMENTATION OF `resource.ts`'s GENERIC `diff`, NOT A WRAP OF IT — deliberately.
|
|
199
|
+
* The generic `grafanaOperations(spec).diff` fetches live and maps it to `Attributes` entirely
|
|
200
|
+
* inside itself, with no seam to run the drop warning against the rules it read; wrapping it
|
|
201
|
+
* would mean a SECOND live fetch just to see what it saw. This resource's `diff` is the ONLY one
|
|
202
|
+
* in the family that needs a resource-specific side effect mid-comparison, so it re-reads
|
|
203
|
+
* `resource.ts`'s own logic here rather than changing the shared engine every other resource
|
|
204
|
+
* also uses. Keep this in sync with `resource.ts`'s `grafanaOperations.diff` if that ever
|
|
205
|
+
* changes shape.
|
|
206
|
+
*/
|
|
207
|
+
const diff = ({
|
|
208
|
+
news,
|
|
209
|
+
output,
|
|
210
|
+
}: {
|
|
211
|
+
news: Input<AlertRuleGroupProps>;
|
|
212
|
+
output: AlertRuleGroupAttributes | undefined;
|
|
213
|
+
}) =>
|
|
214
|
+
Effect.gen(function* () {
|
|
215
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
216
|
+
const live = yield* spec.fetchLive(news);
|
|
217
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
218
|
+
yield* warnOnDroppedRules(news.folderUid, news.group, declaredUids(news), live.rules);
|
|
219
|
+
const mapped = spec.attributes(live, news);
|
|
220
|
+
return mapped !== undefined && spec.matches(mapped, news)
|
|
221
|
+
? ({ action: 'noop' } as const)
|
|
222
|
+
: ({ action: 'update' } as const);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
export const handlers = { ...grafanaHandlers(spec), diff };
|
|
226
|
+
|
|
227
|
+
export const GrafanaAlertRuleGroupProvider = () =>
|
|
228
|
+
Provider.effect(
|
|
229
|
+
GrafanaAlertRuleGroup,
|
|
230
|
+
Effect.succeed(GrafanaAlertRuleGroup.Provider.of(handlers)),
|
|
231
|
+
);
|
package/src/grafana/index.ts
CHANGED
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* depend on.
|
|
8
8
|
*
|
|
9
9
|
* `Grafana.Datasource`, `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.ContactPoint`,
|
|
10
|
-
* `Grafana.MuteTiming
|
|
11
|
-
* `Grafana.NotificationPolicy`
|
|
12
|
-
* docs/grafana.md, docs/grafana-folder-dashboard.md and
|
|
13
|
-
* and what is still open.
|
|
10
|
+
* `Grafana.MuteTiming`, `Grafana.MessageTemplate` and `Grafana.AlertRuleGroup` ship here.
|
|
11
|
+
* `Grafana.NotificationPolicy` — the singleton policy tree — is a stacked follow-up PR on the same
|
|
12
|
+
* SDK operations — see docs/grafana.md, docs/grafana-folder-dashboard.md and
|
|
13
|
+
* docs/grafana-alerting.md for what shipped and what is still open.
|
|
14
14
|
*/
|
|
15
|
+
export {
|
|
16
|
+
type AlertRuleGroupAttributes,
|
|
17
|
+
type AlertRuleGroupProps,
|
|
18
|
+
type AlertRuleInput,
|
|
19
|
+
GrafanaAlertRuleGroup,
|
|
20
|
+
GrafanaAlertRuleGroupProvider,
|
|
21
|
+
} from './alert-rule-group.ts';
|
|
15
22
|
export {
|
|
16
23
|
type DatasourceAttributes,
|
|
17
24
|
type DatasourceProps,
|
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';
|
|
@@ -28,4 +29,5 @@ export const grafanaProviders = (target: GrafanaTarget) =>
|
|
|
28
29
|
GrafanaContactPointProvider(),
|
|
29
30
|
GrafanaMuteTimingProvider(),
|
|
30
31
|
GrafanaMessageTemplateProvider(),
|
|
32
|
+
GrafanaAlertRuleGroupProvider(),
|
|
31
33
|
).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';
|