@homeflare/alchemy 0.32.0 → 0.34.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/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 +47 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/datasource.d.ts +2 -9
- package/dist/grafana/datasource.d.ts.map +1 -1
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +16 -9
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +325 -16
- package/dist/grafana/index.js.map +15 -5
- 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/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- 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 +76 -45
- package/dist/proxmox/index.js.map +6 -6
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/docs/grafana-alerting.md +145 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +12 -10
- package/docs/upstream-conformance.md +31 -4
- package/package.json +1 -1
- 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.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +62 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/datasource.ts +5 -27
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +42 -9
- 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 +13 -1
- package/src/grafana/provisioned.ts +49 -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 +1 -1
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint` against a fake Grafana — the list-then-filter-by-uid read (no by-uid GET
|
|
3
|
+
* exists), the observe-before-write "adopt" posture, and subset-match tolerance. Mirrors
|
|
4
|
+
* `datasource.test.ts`. Secure-settings-ref tests: `contact-point-secrets.test.ts`. Provenance
|
|
5
|
+
* refusal tests (foreign and missing): `contact-point-provenance.test.ts` — split out to keep each
|
|
6
|
+
* file under the house's 250-line cap.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { ITEM_PATH, LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
|
|
11
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
12
|
+
import { grafanaOperations } from './resource.ts';
|
|
13
|
+
import { spec } from './contact-point.ts';
|
|
14
|
+
|
|
15
|
+
describe('spec.fetchLive', () => {
|
|
16
|
+
test('lists and filters by uid client-side — no by-uid GET route exists', async () => {
|
|
17
|
+
const other = { ...liveJson, name: 'Other', uid: 'other' };
|
|
18
|
+
const fake = fakeGrafana((method, url) =>
|
|
19
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
20
|
+
? Response.json([other, liveJson])
|
|
21
|
+
: fakeFailure(404, 'unexpected request'),
|
|
22
|
+
);
|
|
23
|
+
const live = await Effect.runPromise(
|
|
24
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
25
|
+
);
|
|
26
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
27
|
+
disableResolveMessage: false,
|
|
28
|
+
name: 'On-call Slack',
|
|
29
|
+
provenance: 'api',
|
|
30
|
+
settings: { recipient: '#oncall' },
|
|
31
|
+
type: 'slack',
|
|
32
|
+
uid: 'slack-oncall',
|
|
33
|
+
});
|
|
34
|
+
// ⛔ THE POINT: a GET never carries a body.
|
|
35
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a uid absent from the list decodes to undefined, not an error', async () => {
|
|
39
|
+
const fake = fakeGrafana(() => Response.json([]));
|
|
40
|
+
const result = await Effect.runPromise(
|
|
41
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
42
|
+
);
|
|
43
|
+
expect(result).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
47
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
48
|
+
const failure = await Effect.runPromise(
|
|
49
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
50
|
+
);
|
|
51
|
+
expect(failure._tag).toBe('Forbidden');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('subset match — tolerates a default Grafana injects into settings', () => {
|
|
56
|
+
test('an unchanged declaration is a noop despite an extra live settings field it never declared', async () => {
|
|
57
|
+
const decorated = { ...liveJson, settings: { ...liveJson.settings, severity: 'critical' } };
|
|
58
|
+
const fake = fakeGrafana((method, url) =>
|
|
59
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
60
|
+
? Response.json([decorated])
|
|
61
|
+
: fakeFailure(500, 'reconcile should not write when content is unchanged'),
|
|
62
|
+
);
|
|
63
|
+
const after = await Effect.runPromise(
|
|
64
|
+
grafanaOperations(spec)
|
|
65
|
+
.reconcile(props)
|
|
66
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
67
|
+
);
|
|
68
|
+
expect(after.uid).toBe('slack-oncall');
|
|
69
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('reconcile — adopt semantics', () => {
|
|
74
|
+
test('declaring exactly what is live is a noop: no write is ever sent', async () => {
|
|
75
|
+
const fake = fakeGrafana((method, url) =>
|
|
76
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
77
|
+
? Response.json([liveJson])
|
|
78
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
79
|
+
);
|
|
80
|
+
const after = await Effect.runPromise(
|
|
81
|
+
grafanaOperations(spec)
|
|
82
|
+
.reconcile(props)
|
|
83
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
84
|
+
);
|
|
85
|
+
expect(after.uid).toBe('slack-oncall');
|
|
86
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('update and destroy — normal, api-provenance writes', () => {
|
|
91
|
+
test("an api-provenance contact point (this family's own writes) updates normally", async () => {
|
|
92
|
+
const fake = fakeGrafana((method, url) => {
|
|
93
|
+
if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([liveJson]);
|
|
94
|
+
if (method === 'PUT' && url.pathname === ITEM_PATH) {
|
|
95
|
+
return Response.json({ ...liveJson, name: 'Renamed' });
|
|
96
|
+
}
|
|
97
|
+
return fakeFailure(500, 'unexpected request');
|
|
98
|
+
});
|
|
99
|
+
await Effect.runPromise(
|
|
100
|
+
grafanaOperations(spec)
|
|
101
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
102
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
103
|
+
);
|
|
104
|
+
expect(fake.seen.some((s) => s.method === 'PUT' && s.path === ITEM_PATH)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('deletes a live, api-provenance contact point by uid', async () => {
|
|
108
|
+
const fake = fakeGrafana((method, url) => {
|
|
109
|
+
if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([liveJson]);
|
|
110
|
+
if (method === 'DELETE' && url.pathname === ITEM_PATH) return Response.json({});
|
|
111
|
+
return fakeFailure(500, 'unexpected request');
|
|
112
|
+
});
|
|
113
|
+
await Effect.runPromise(
|
|
114
|
+
grafanaOperations(spec)
|
|
115
|
+
.destroy(props)
|
|
116
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
117
|
+
);
|
|
118
|
+
expect(fake.seen).toEqual([
|
|
119
|
+
{ method: 'GET', path: LIST_PATH },
|
|
120
|
+
{ method: 'DELETE', path: ITEM_PATH },
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint` — one Grafana alerting contact point, keyed by `uid`.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ NO GET-BY-UID ROUTE EXISTS — MEASURED against the SDK's generated operations (grep across
|
|
5
|
+
* `services/grafana.ts` for `routeGet.*[Cc]ontactpoint`: only `routeGetContactpoints` (list,
|
|
6
|
+
* with an optional `name` filter) and `routeGetContactpointsExport`). Unlike
|
|
7
|
+
* `Grafana.Datasource`/`Grafana.Folder`/`Grafana.Dashboard`, `fetchLive` cannot key its read
|
|
8
|
+
* directly on `uid`. Filtering server-side by `name` is NOT a substitute: `name` is a grouping
|
|
9
|
+
* label a declaration can rename (`uid` is the only field this resource keys on, and Grafana's
|
|
10
|
+
* own doc calls `name` "used as grouping key in the UI" — several contact points can share one),
|
|
11
|
+
* so narrowing the GET to the DECLARED name would miss a live object still filed under an OLDER
|
|
12
|
+
* name mid-rename, reporting it absent and attempting a duplicate `create`. `fetchLive` instead
|
|
13
|
+
* lists every contact point (`routeGetContactpoints({})`, no filter) and matches `uid`
|
|
14
|
+
* client-side — the same ambiguity `resource.ts`'s header and netbox's `soleMatch` name, resolved
|
|
15
|
+
* the same way: requiring the one field the API is actually keyed on.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ SECRET SETTINGS NEVER APPEAR AS A LITERAL PROP (S25) — same shape as `Grafana.Datasource`'s
|
|
18
|
+
* `secureJsonDataRefs` (`secret-refs.ts`, shared). Grafana does not use a separate top-level
|
|
19
|
+
* "secure settings" field for contact points the way it does for datasources; a secure key (a
|
|
20
|
+
* webhook URL with an embedded token, an API key, …) lives INSIDE `settings` on write, and comes
|
|
21
|
+
* back inside `settings` REDACTED on read — Grafana's own docs for the contact-points EXPORT
|
|
22
|
+
* route say as much explicitly ("Redacted settings will contain RedactedValue instead"; the
|
|
23
|
+
* plain, non-export GET this resource calls has no `decrypt` option at all, so it is redacted
|
|
24
|
+
* unconditionally). `secureSettingsRefs` therefore names which keys of `settings` are secret —
|
|
25
|
+
* value resolved from an env var NAME fresh inside `create`/`update`'s own effect (S24), merged
|
|
26
|
+
* into `settings` only at that point, never stored and never diffed (see `matches` below).
|
|
27
|
+
*
|
|
28
|
+
* ⛔ A FOREIGN-PROVENANCE CONTACT POINT REFUSES EVERY UPDATE AND DESTROY — `alerting-provenance.ts`,
|
|
29
|
+
* shared with `Grafana.MessageTemplate`/`Grafana.AlertRuleGroup`/`Grafana.NotificationPolicy`.
|
|
30
|
+
* See that file's header for the full reasoning and citations. `create` is never at risk: it only
|
|
31
|
+
* runs when `fetchLive` already found nothing with this `uid`.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ `X-Disable-Provenance` IS NEVER SET, BY DESIGN. Every create/update through this resource
|
|
34
|
+
* therefore leaves the contact point with `provenance: "api"` (Grafana's own default for an
|
|
35
|
+
* object this API just wrote) — which the docs describe as blocking that same object from being
|
|
36
|
+
* edited in the Grafana UI afterward. That is the intended outcome, not an oversight: an object
|
|
37
|
+
* this family declares is meant to be owned by the declaration, the same "one owner per resource"
|
|
38
|
+
* posture `provisioned.ts` establishes for folders/dashboards — handing it back to UI-editability
|
|
39
|
+
* by default would silently create the two-owner situation this whole file exists to prevent.
|
|
40
|
+
*/
|
|
41
|
+
import { Resource } from 'alchemy';
|
|
42
|
+
import * as Provider from 'alchemy/Provider';
|
|
43
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
44
|
+
import * as Effect from 'effect/Effect';
|
|
45
|
+
import {
|
|
46
|
+
type GrafanaProvisionedObjectError,
|
|
47
|
+
isForeignProvenance,
|
|
48
|
+
refuseIfForeignProvenance,
|
|
49
|
+
} from './alerting-provenance.ts';
|
|
50
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
51
|
+
import { GrafanaSecretRefUnsetError, resolveSecretRefs } from './secret-refs.ts';
|
|
52
|
+
import { declaredContentMatches } from './subset-match.ts';
|
|
53
|
+
|
|
54
|
+
export { GrafanaSecretRefUnsetError };
|
|
55
|
+
|
|
56
|
+
export interface ContactPointProps {
|
|
57
|
+
uid: string;
|
|
58
|
+
name: string;
|
|
59
|
+
/** Grafana integration id — `slack`, `webhook`, `email`, `pagerduty`, `opsgenie`, … */
|
|
60
|
+
type: string;
|
|
61
|
+
/** Non-secret settings — shape depends on `type`. Never put a secret VALUE here — declare it
|
|
62
|
+
* in `secureSettingsRefs` instead; see the file header. */
|
|
63
|
+
settings: Record<string, unknown>;
|
|
64
|
+
/** Secure field name (e.g. `url`, `token`, `password`) -> env var NAME holding its value. */
|
|
65
|
+
secureSettingsRefs?: Record<string, string>;
|
|
66
|
+
disableResolveMessage?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ContactPointAttributes {
|
|
70
|
+
uid: string;
|
|
71
|
+
name: string;
|
|
72
|
+
type: string;
|
|
73
|
+
/** As Grafana returns it — any secure key is a redaction placeholder, never the real value. */
|
|
74
|
+
settings: Record<string, unknown>;
|
|
75
|
+
disableResolveMessage: boolean;
|
|
76
|
+
/** `""`/`"api"` (this family may write); anything else, INCLUDING `undefined` (Grafana didn't
|
|
77
|
+
* report the field), refuses — see alerting-provenance.ts's "fails open" note. */
|
|
78
|
+
provenance: string | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GrafanaContactPoint extends Resource<
|
|
82
|
+
'Grafana.ContactPoint',
|
|
83
|
+
ContactPointProps,
|
|
84
|
+
ContactPointAttributes,
|
|
85
|
+
never
|
|
86
|
+
> {}
|
|
87
|
+
|
|
88
|
+
export const GrafanaContactPoint = Resource<GrafanaContactPoint>('Grafana.ContactPoint');
|
|
89
|
+
|
|
90
|
+
/** `settings` minus every key `secureSettingsRefs` names — the only part `matches` ever compares
|
|
91
|
+
* (a secure key's live value is always a redaction placeholder, never comparable). */
|
|
92
|
+
const nonSecretSettings = (props: ContactPointProps): Record<string, unknown> => {
|
|
93
|
+
const refs = props.secureSettingsRefs;
|
|
94
|
+
if (refs === undefined) return props.settings;
|
|
95
|
+
const out: Record<string, unknown> = {};
|
|
96
|
+
for (const [key, value] of Object.entries(props.settings)) {
|
|
97
|
+
if (!(key in refs)) out[key] = value;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolveSettings = (props: ContactPointProps) =>
|
|
103
|
+
Effect.map(
|
|
104
|
+
resolveSecretRefs(props.secureSettingsRefs, (field) => `settings.${field}`),
|
|
105
|
+
(secrets) => (secrets === undefined ? props.settings : { ...props.settings, ...secrets }),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
export const spec: GrafanaSpec<
|
|
109
|
+
ContactPointProps,
|
|
110
|
+
grafana.EmbeddedContactPoint,
|
|
111
|
+
ContactPointAttributes,
|
|
112
|
+
| grafana.RouteGetContactpointsError
|
|
113
|
+
| grafana.RoutePostContactpointsError
|
|
114
|
+
| grafana.RoutePutContactpointError
|
|
115
|
+
| grafana.RouteDeleteContactpointsError
|
|
116
|
+
| GrafanaProvisionedObjectError
|
|
117
|
+
| GrafanaSecretRefUnsetError
|
|
118
|
+
> = {
|
|
119
|
+
attributes: (live) => ({
|
|
120
|
+
disableResolveMessage: live.disableResolveMessage ?? false,
|
|
121
|
+
name: live.name ?? '',
|
|
122
|
+
// ⛔ NEVER `?? ''` — an absent field must stay distinguishable from an explicit `""`
|
|
123
|
+
// (ProvenanceNone); see alerting-provenance.ts's "fails open" note.
|
|
124
|
+
provenance: live.provenance,
|
|
125
|
+
settings: (live.settings ?? {}) as Record<string, unknown>,
|
|
126
|
+
type: live.type,
|
|
127
|
+
uid: live.uid ?? '',
|
|
128
|
+
}),
|
|
129
|
+
create: (props) =>
|
|
130
|
+
Effect.flatMap(resolveSettings(props), (settings) =>
|
|
131
|
+
grafana.routePostContactpoints({
|
|
132
|
+
name: props.name,
|
|
133
|
+
settings,
|
|
134
|
+
type: props.type,
|
|
135
|
+
uid: props.uid,
|
|
136
|
+
...(props.disableResolveMessage === undefined
|
|
137
|
+
? {}
|
|
138
|
+
: { disableResolveMessage: props.disableResolveMessage }),
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
destroy: (props, live) =>
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
if (isForeignProvenance(live.provenance)) {
|
|
144
|
+
return yield* refuseIfForeignProvenance('Grafana.ContactPoint', props.uid, live.provenance);
|
|
145
|
+
}
|
|
146
|
+
yield* grafana.routeDeleteContactpoints({ UID: props.uid });
|
|
147
|
+
}),
|
|
148
|
+
fetchLive: (props) =>
|
|
149
|
+
grafana
|
|
150
|
+
.routeGetContactpoints({})
|
|
151
|
+
.pipe(Effect.map((points) => points.find((point) => point.uid === props.uid))),
|
|
152
|
+
matches: (attributes, props) =>
|
|
153
|
+
attributes.name === props.name &&
|
|
154
|
+
attributes.type === props.type &&
|
|
155
|
+
attributes.disableResolveMessage === (props.disableResolveMessage ?? false) &&
|
|
156
|
+
declaredContentMatches(nonSecretSettings(props), attributes.settings),
|
|
157
|
+
update: (props, live) =>
|
|
158
|
+
Effect.gen(function* () {
|
|
159
|
+
if (isForeignProvenance(live.provenance)) {
|
|
160
|
+
return yield* refuseIfForeignProvenance('Grafana.ContactPoint', props.uid, live.provenance);
|
|
161
|
+
}
|
|
162
|
+
const settings = yield* resolveSettings(props);
|
|
163
|
+
yield* grafana.routePutContactpoint({
|
|
164
|
+
UID: props.uid,
|
|
165
|
+
name: props.name,
|
|
166
|
+
settings,
|
|
167
|
+
type: props.type,
|
|
168
|
+
uid: props.uid,
|
|
169
|
+
...(props.disableResolveMessage === undefined
|
|
170
|
+
? {}
|
|
171
|
+
: { disableResolveMessage: props.disableResolveMessage }),
|
|
172
|
+
});
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const handlers = grafanaHandlers(spec);
|
|
177
|
+
|
|
178
|
+
export const GrafanaContactPointProvider = () =>
|
|
179
|
+
Provider.effect(GrafanaContactPoint, Effect.succeed(GrafanaContactPoint.Provider.of(handlers)));
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `normalizeModel`/`modelTitle`/`declaredContentMatches` in isolation — no SDK, no fake, no
|
|
3
|
+
* Effect. Proves the volatile fields (`id`, `version`, `iteration`) are stripped and `uid` is
|
|
4
|
+
* forced to the canonical value regardless of where — or whether — the source model carried one,
|
|
5
|
+
* and that `declaredContentMatches` tolerates Grafana's own schema-migration auto-decoration
|
|
6
|
+
* without hiding genuine content drift. All of which is what makes a pasted "Export as JSON" and
|
|
7
|
+
* a freshly-read live model compare equal in dashboard.test.ts.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import { declaredContentMatches, modelTitle, normalizeModel } from './dashboard-model.ts';
|
|
11
|
+
|
|
12
|
+
describe('normalizeModel', () => {
|
|
13
|
+
test('strips id, version and iteration', () => {
|
|
14
|
+
const model = { id: 42, iteration: 1700000000, title: 'Fleet', version: 7 };
|
|
15
|
+
expect(normalizeModel(model, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('forces uid to the canonical value, overwriting a stale embedded one', () => {
|
|
19
|
+
const exported = { title: 'Fleet', uid: 'some-old-uid-from-an-export' };
|
|
20
|
+
expect(normalizeModel(exported, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('injects uid when the source model has none at all — a never-saved dashboard', () => {
|
|
24
|
+
const neverSaved = { title: 'Fleet' };
|
|
25
|
+
expect(normalizeModel(neverSaved, 'fleet')).toEqual({ title: 'Fleet', uid: 'fleet' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a declared model and a live model that only disagree on volatile fields normalize equal', () => {
|
|
29
|
+
const declared = { panels: [{ id: 1, title: 'CPU' }], title: 'Fleet' };
|
|
30
|
+
const live = {
|
|
31
|
+
id: 42,
|
|
32
|
+
iteration: 1700000000,
|
|
33
|
+
panels: [{ id: 1, title: 'CPU' }],
|
|
34
|
+
title: 'Fleet',
|
|
35
|
+
uid: 'fleet',
|
|
36
|
+
version: 9,
|
|
37
|
+
};
|
|
38
|
+
expect(normalizeModel(declared, 'fleet')).toEqual(normalizeModel(live, 'fleet'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('preserves array order — panels and targets are position-significant, not a set', () => {
|
|
42
|
+
const model = { panels: [{ title: 'B' }, { title: 'A' }] };
|
|
43
|
+
expect(normalizeModel(model, 'x').panels).toEqual([{ title: 'B' }, { title: 'A' }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('leaves an unrelated field genuinely different, so a real content change still shows', () => {
|
|
47
|
+
const declared = { title: 'Fleet v2' };
|
|
48
|
+
const live = { title: 'Fleet', uid: 'fleet' };
|
|
49
|
+
expect(normalizeModel(declared, 'fleet')).not.toEqual(normalizeModel(live, 'fleet'));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('modelTitle', () => {
|
|
54
|
+
test('reads the model title', () => {
|
|
55
|
+
expect(modelTitle({ title: 'Fleet' })).toBe('Fleet');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('answers empty for a model with no title', () => {
|
|
59
|
+
expect(modelTitle({})).toBe('');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('declaredContentMatches', () => {
|
|
64
|
+
// ⛔ THE REGRESSION THIS FUNCTION EXISTS TO FIX — found by an adversarial review of this PR.
|
|
65
|
+
// Grafana's own dashboard-save schema migration decorates a panel/target whose `datasource`
|
|
66
|
+
// is absent with an explicit `{type, uid}` reference. Before this function existed, a
|
|
67
|
+
// declared panel that never set `datasource` disagreed with what it read back FOREVER.
|
|
68
|
+
test('tolerates Grafana auto-decorating a panel/target with a datasource it never declared', () => {
|
|
69
|
+
const declared = {
|
|
70
|
+
panels: [{ targets: [{ refId: 'A' }], title: 'CPU' }],
|
|
71
|
+
title: 'Fleet',
|
|
72
|
+
};
|
|
73
|
+
const live = {
|
|
74
|
+
panels: [
|
|
75
|
+
{
|
|
76
|
+
datasource: { type: 'postgres', uid: 'teslamate' },
|
|
77
|
+
targets: [{ datasource: { type: 'postgres', uid: 'teslamate' }, refId: 'A' }],
|
|
78
|
+
title: 'CPU',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
title: 'Fleet',
|
|
82
|
+
};
|
|
83
|
+
expect(declaredContentMatches(declared, live)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('still catches a genuine change to a field the declaration DOES mention', () => {
|
|
87
|
+
const declared = { panels: [{ title: 'CPU' }] };
|
|
88
|
+
const live = { panels: [{ datasource: { uid: 'teslamate' }, title: 'Memory' }] };
|
|
89
|
+
expect(declaredContentMatches(declared, live)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('still fails when live is missing a field the declaration mentions', () => {
|
|
93
|
+
expect(declaredContentMatches({ title: 'Fleet' }, {})).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('still fails on an array length mismatch — panels are not padded away', () => {
|
|
97
|
+
const declared = { panels: [{ title: 'A' }, { title: 'B' }] };
|
|
98
|
+
const live = { panels: [{ title: 'A' }] };
|
|
99
|
+
expect(declaredContentMatches(declared, live)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('ignores an extra top-level field live carries that the declaration never mentioned', () => {
|
|
103
|
+
expect(declaredContentMatches({ title: 'Fleet' }, { schemaVersion: 39, title: 'Fleet' })).toBe(
|
|
104
|
+
true,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('null (or undefined) on a declared leaf never mismatches, mirroring stripNullish', () => {
|
|
109
|
+
expect(declaredContentMatches({ description: null }, { description: 'set live' })).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions over a dashboard JSON model — no SDK call, no Effect. Split out of dashboard.ts
|
|
3
|
+
* so that file stays under the house's 250-line cap, and so `normalizeModel`'s no-change-is-noop
|
|
4
|
+
* behavior is exercised directly in dashboard-model.test.ts without a fake Grafana in the way.
|
|
5
|
+
*/
|
|
6
|
+
export type DashboardModel = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
/** Grafana injects or increments these on every save; a declared model never sets them meaningfully. */
|
|
9
|
+
const VOLATILE_FIELDS = ['id', 'version', 'iteration'] as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ★ NORMALIZES BOTH SIDES THE SAME WAY, so a no-change plan is a true noop
|
|
13
|
+
* (docs/grafana-folder-dashboard.md). Strips `id`/`version`/`iteration` (Grafana-managed, never
|
|
14
|
+
* declared content) and forces `uid` to this resource's OWN canonical value, wherever — or
|
|
15
|
+
* whether — the source object carried one. That second part is "uid placement": a dashboard
|
|
16
|
+
* pasted from Grafana's own "Export as JSON" always embeds the OLD live uid (or none, for a
|
|
17
|
+
* never-saved dashboard), while the live model this resource reads back always carries the
|
|
18
|
+
* CURRENT one; forcing both to `props.uid` before comparing is what makes those two shapes equal
|
|
19
|
+
* instead of permanently disagreeing on one field neither side is meant to declare independently
|
|
20
|
+
* (`DashboardProps.uid` — see dashboard.ts — is the single source of truth).
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ EVERY OTHER DECLARED FIELD COMPARES STRUCTURALLY, ARRAY ORDER INCLUDED — dashboard.ts's
|
|
23
|
+
* `matches` runs this output through THIS FILE's own `declaredContentMatches` (below), which
|
|
24
|
+
* never reorders an array. That is correct here: `panels` is position-significant (screen layout
|
|
25
|
+
* via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
|
|
26
|
+
* any `repeat` behavior) and each panel's `targets` is execution/legend order. Nothing in a
|
|
27
|
+
* dashboard model is a genuinely unordered SET the way UniFi's `trustedDhcpServerIpAddresses` is
|
|
28
|
+
* (unifi/network-form.ts's `sortedSet`) — this family has no field that needs one.
|
|
29
|
+
* ⚠️ `schemaVersion` IS DELIBERATELY **NOT** NORMALIZED — NOT MEASURED against a live instance (no
|
|
30
|
+
* live calls in this PR). Community reports say Grafana can rewrite an old dashboard's
|
|
31
|
+
* `schemaVersion` upward on load; IF that also happens on every plain save of an
|
|
32
|
+
* already-current-schema dashboard, every plan against it would show a false `update` forever —
|
|
33
|
+
* but silently normalizing it away on a guess would just as easily hide a genuine schema
|
|
34
|
+
* mismatch. Left comparing until measured: add it to `VOLATILE_FIELDS` with a dated, measured
|
|
35
|
+
* comment the day it is seen live, the same standard every other row in this file meets.
|
|
36
|
+
*/
|
|
37
|
+
export const normalizeModel = (model: DashboardModel, uid: string): DashboardModel => {
|
|
38
|
+
const normalized: DashboardModel = {};
|
|
39
|
+
for (const [key, value] of Object.entries(model)) {
|
|
40
|
+
if (key === 'uid') continue;
|
|
41
|
+
if ((VOLATILE_FIELDS as readonly string[]).includes(key)) continue;
|
|
42
|
+
normalized[key] = value;
|
|
43
|
+
}
|
|
44
|
+
normalized.uid = uid;
|
|
45
|
+
return normalized;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The model's own declared title — reporting only. It is never compared on its own; the whole
|
|
49
|
+
* normalized model is, in dashboard.ts's `matches`, so a title-only mismatch still shows `update`. */
|
|
50
|
+
export const modelTitle = (model: DashboardModel): string =>
|
|
51
|
+
typeof model.title === 'string' ? model.title : '';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `declared` need only be a subset of `live` — dashboard.ts's `matches` runs `normalizeModel`'s
|
|
55
|
+
* output through this. Moved to `subset-match.ts` once `Grafana.ContactPoint`'s `settings` needed
|
|
56
|
+
* the identical "declaration silent about a field means don't compare it" rule; re-exported here
|
|
57
|
+
* under its original name so this file's own callers and dashboard-model.test.ts are unchanged.
|
|
58
|
+
* See subset-match.ts for the full reasoning, including the regression this exists to fix
|
|
59
|
+
* (Grafana's dashboard-save schema migration auto-decorating a panel's `datasource`) and the
|
|
60
|
+
* trade-off it accepts (an omitted field can no longer force-clear a previously-set one).
|
|
61
|
+
*/
|
|
62
|
+
export { declaredContentMatches } from './subset-match.ts';
|