@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,145 @@
|
|
|
1
|
+
# `Grafana.Folder` and `Grafana.Dashboard` — `@homeflare/alchemy/grafana`
|
|
2
|
+
|
|
3
|
+
Built 2026-09-24 on the same `@distilled.cloud/grafana` (aliased onto
|
|
4
|
+
`@homeflare/distilled-grafana@0.2.0`) operations that fixed the SDK gap
|
|
5
|
+
[grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) recorded:
|
|
6
|
+
`createFolder`/`getFolderByUID`/`updateFolder`/`deleteFolder`/`getFolders` and
|
|
7
|
+
`postDashboard`/`getDashboard`/`deleteDashboard`, plus a typed `PreconditionFailed` (412) for
|
|
8
|
+
dashboard version conflicts. No live call was made writing this PR — see "Measured vs inferred"
|
|
9
|
+
below for exactly which claims that limits.
|
|
10
|
+
|
|
11
|
+
## Both follow `Grafana.Datasource`'s doctrine
|
|
12
|
+
|
|
13
|
+
Same file shape (`resource.ts`'s shared `GrafanaSpec`/`grafanaOperations`/`grafanaHandlers`), same
|
|
14
|
+
credentials layer (`grafanaProviders(target)`), same **uid required, never generated** reasoning —
|
|
15
|
+
`getFolderByUID`/`updateFolder`/`deleteFolder` and `getDashboard`/`postDashboard`/`deleteDashboard`
|
|
16
|
+
are all UID-keyed, and neither `getFolders` nor a dashboard list endpoint takes a per-uid filter,
|
|
17
|
+
so locating an object this resource didn't create would mean listing everything and matching by
|
|
18
|
+
name — the ambiguity requiring `uid` up front sidesteps.
|
|
19
|
+
|
|
20
|
+
## `Grafana.Folder`
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { GrafanaFolder, grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
24
|
+
|
|
25
|
+
export class Infra extends GrafanaFolder('infra-folder', {
|
|
26
|
+
uid: 'infra',
|
|
27
|
+
title: 'Infrastructure',
|
|
28
|
+
}) {}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **`description` is write-only** — MEASURED against the SDK's generated types: `Folder` (the GET
|
|
32
|
+
response) has no `description` field at all, though `CreateFolderRequest`/`UpdateFolderRequest`
|
|
33
|
+
both accept one. Sent on every create/update that declares it; never compared, the same
|
|
34
|
+
treatment `Grafana.Datasource` gives `secureJsonDataRefs` for the same reason (nothing to diff
|
|
35
|
+
against).
|
|
36
|
+
- **Nesting (`parentUid`) is create-only** — MEASURED: `UpdateFolderRequest` has no `parentUid`
|
|
37
|
+
field (`folder_uid`, `description`, `overwrite`, `title`, `version` only). A declaration whose
|
|
38
|
+
`parentUid` no longer matches the live folder REFUSES with `GrafanaFolderReparentError` rather
|
|
39
|
+
than silently doing nothing or deleting-and-recreating under the new parent (which — next
|
|
40
|
+
bullet — takes every dashboard and alert rule in the folder with it). Move a folder by hand in
|
|
41
|
+
Grafana, then update the declaration.
|
|
42
|
+
- **Delete defaults to `retain`** — `deleteFolder`'s own operation description says it deletes
|
|
43
|
+
"all dashboards (and their alerts) stored in the folder... cannot be reverted... also deletes
|
|
44
|
+
all the subfolders." That is the multi-object cascade `openbao/mount.ts` guards with
|
|
45
|
+
`defaultRemovalPolicy: 'retain'`, verified there against Alchemy's own `Apply.ts` (not
|
|
46
|
+
re-verified here; same engine, same option — see `folder.ts`'s header). A stack that wants the
|
|
47
|
+
cascade opts in with `.pipe(RemovalPolicy.destroy())`; `destroy` still implements the real
|
|
48
|
+
`DELETE` in full either way (S11). `Grafana.Dashboard` does **not** default to retain — see its
|
|
49
|
+
own section below.
|
|
50
|
+
|
|
51
|
+
## `Grafana.Dashboard`
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { GrafanaDashboard, grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
55
|
+
|
|
56
|
+
export class FleetOverview extends GrafanaDashboard('fleet-overview-dashboard', {
|
|
57
|
+
uid: 'fleet-overview',
|
|
58
|
+
folderUid: 'infra',
|
|
59
|
+
dashboard: {
|
|
60
|
+
title: 'Fleet Overview',
|
|
61
|
+
panels: [/* … exported straight from Grafana's own "Export as JSON" … */],
|
|
62
|
+
},
|
|
63
|
+
}) {}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **The declared content is the dashboard JSON model** — the same shape Grafana's own "Export as
|
|
67
|
+
JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips `id`/`version`/`iteration`
|
|
68
|
+
(Grafana-managed, injected/incremented on every save) and forces `uid` to this resource's own
|
|
69
|
+
value regardless of where — or whether — the source model carried one, before ANY comparison.
|
|
70
|
+
That is what makes a pasted export (which always embeds the OLD live uid) and a freshly-read
|
|
71
|
+
live model compare equal, and what makes a no-change plan a true noop despite those fields
|
|
72
|
+
moving on every live save.
|
|
73
|
+
- **Array order is significant, and nothing here needs a `sortedSet`** — `panels` (screen layout
|
|
74
|
+
via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
|
|
75
|
+
`repeat` behavior) and each panel's `targets` (query execution/legend order) are compared
|
|
76
|
+
positionally, never reordered. Nothing in a dashboard model is a genuinely unordered SET the way
|
|
77
|
+
UniFi's `trustedDhcpServerIpAddresses` is (`unifi/network-form.ts`'s `sortedSet`) — this family
|
|
78
|
+
has no field that needs one. `schemaVersion` is deliberately left comparing as-is — NOT MEASURED
|
|
79
|
+
whether Grafana rewrites it on a plain save, so normalizing it away on a guess risked hiding a
|
|
80
|
+
genuine schema mismatch instead of a false diff; see `dashboard-model.ts` for the exact reasoning
|
|
81
|
+
and what evidence would justify adding it to the volatile-field list.
|
|
82
|
+
- **`matches` compares "is the declaration a subset of what is live", not plain equality** —
|
|
83
|
+
`dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
|
|
84
|
+
found plain structural equality (`alchemy/Diff`'s `deepEqual`, used in an earlier version of this
|
|
85
|
+
PR) made an ordinary, never-edited dashboard show `update` FOREVER: Grafana's own schema
|
|
86
|
+
migration on save decorates any panel or target whose `datasource` is absent with an explicit
|
|
87
|
+
`{type, uid}` reference — documented Grafana behavior, not re-measured against a live instance in
|
|
88
|
+
this PR. A declared panel that never sets `datasource` therefore read back with that field
|
|
89
|
+
populated, disagreed with the declaration, got written back undecorated, and got redecorated
|
|
90
|
+
again — non-convergent. `declaredContentMatches` instead asks only whether every field the
|
|
91
|
+
DECLARATION mentions, at any depth, still matches what is live; `live` may carry additional
|
|
92
|
+
fields Grafana injected that the declaration never mentioned. Real content drift (a field the
|
|
93
|
+
declaration DOES mention changing, or vanishing) is still caught exactly as before. The
|
|
94
|
+
trade-off, deliberate: **removing** a field from the declaration can no longer force Grafana to
|
|
95
|
+
drop a value it — or a previous declaration — set; only an explicit replacement value changes
|
|
96
|
+
one. This is the same "undeclared means don't care, never means force-to-default" convention this
|
|
97
|
+
whole kit already uses for a plain optional prop, now applied inside the JSON model too.
|
|
98
|
+
- **412 version conflicts fail loudly, never overwritten blindly.** `update` injects the version
|
|
99
|
+
this resource just read (from the same `fetchLive` call `reconcile` already made) into the model
|
|
100
|
+
it sends, and `overwrite` is never set. If Grafana's own check still fails — someone saved in the
|
|
101
|
+
gap between that read and this write — `PreconditionFailed` is left uncaught: the whole reconcile
|
|
102
|
+
fails and nothing is written. The next `plan`/`deploy` re-reads the new live version fresh; there
|
|
103
|
+
is no separate retry loop in this file, because that next run already is the re-read.
|
|
104
|
+
- **No retain default**, unlike `Grafana.Folder`. Deleting one dashboard is a single-object blast
|
|
105
|
+
radius — the same class `Grafana.Datasource` already accepts without a retain guard, not
|
|
106
|
+
`Grafana.Folder`'s multi-object cascade.
|
|
107
|
+
|
|
108
|
+
## Provisioned objects — refused for writes, adoption is fine
|
|
109
|
+
|
|
110
|
+
A folder or dashboard that a provisioning FILE manages (rather than this API) carries a live
|
|
111
|
+
signal — `Folder.managedBy` (non-empty) and `DashboardMeta.provisioned`/`provisionedExternalId` —
|
|
112
|
+
that `update`/`destroy` check before ever calling the SDK; `GrafanaProvisionedObjectError` names
|
|
113
|
+
the owning file when Grafana reports one (dashboards only — a folder's `managedBy` value is
|
|
114
|
+
reported as-is, since the SDK does not separately expose a folder's owning file). `create` is
|
|
115
|
+
never at risk: it only runs when `fetchLive` already found nothing.
|
|
116
|
+
|
|
117
|
+
`matches` still compares structurally regardless of provisioned status, so a mismatched
|
|
118
|
+
declaration against a provisioned object shows `update` in a plan — never a silent, permanently
|
|
119
|
+
hidden noop — and only fails, loudly, when reconcile actually tries to write. A declaration that
|
|
120
|
+
already matches what is live never reaches `update`/`destroy` at all (the shared engine's
|
|
121
|
+
observe-before-write path), so **adopting a provisioned object read-only is safe.**
|
|
122
|
+
|
|
123
|
+
⚠️ **Measured vs inferred (no live call in this PR).** `DashboardMeta.provisioned`/
|
|
124
|
+
`provisionedExternalId` are exactly the fields Grafana's classic dashboard-read response types for
|
|
125
|
+
this purpose — high confidence, standard shape. `Folder.managedBy` is INFERRED from the SDK's
|
|
126
|
+
schema only: no live instance in this house currently has a file-provisioned FOLDER to read against
|
|
127
|
+
(`teslamate-grafana` has none at all — see grafana.md). Treating any non-empty `managedBy` as
|
|
128
|
+
provisioned, rather than matching specific string values that were never observed live, is the
|
|
129
|
+
conservative reading; confirm against a real provisioned folder before relying on the exact value in
|
|
130
|
+
an error message. The panel/target `datasource` auto-decoration `declaredContentMatches` (above)
|
|
131
|
+
exists to tolerate is documented Grafana schema-migration behavior, also not re-measured live in
|
|
132
|
+
this PR. And `updateFolder`'s `version` field — sent optimistically for concurrency — carries the
|
|
133
|
+
SDK's own caveat "only used by the legacy folder implementation"; whether Grafana's newer
|
|
134
|
+
unified-storage folder backend honors it at all is unverified (`folder.ts`'s `update`).
|
|
135
|
+
|
|
136
|
+
## Tests
|
|
137
|
+
|
|
138
|
+
`folder.test.ts`, `dashboard.test.ts` and `dashboard-model.test.ts` mirror `datasource.test.ts`'s
|
|
139
|
+
fake-Grafana harness (`fake-grafana.ts` — the real distilled protocol, only the wire responses
|
|
140
|
+
faked). Each proves: a GET never carries a body (`fake.bodies[0]` is `undefined` — the exact
|
|
141
|
+
"distilled JSON-encodes unknown keys as a body" trap this family's shared memory names); a
|
|
142
|
+
provisioned object refuses update and destroy with no write sent; a transient failure (403/401)
|
|
143
|
+
propagates rather than folding to absent; an unchanged declaration is a noop despite `id`/
|
|
144
|
+
`version`/`iteration` differing; a 412 propagates uncaught; and the folder `parentUid` reparent
|
|
145
|
+
refusal sends no write.
|
package/docs/grafana.md
CHANGED
|
@@ -6,15 +6,14 @@ any status check. Built 2026-09-24 against the live target `teslamate-grafana.se
|
|
|
6
6
|
measured version **13.1.3** (`teslamate/grafana:4.2.0`), listening on `127.0.0.1:3100`, served
|
|
7
7
|
publicly at `tesla.homeflare.dev/dash/`.
|
|
8
8
|
|
|
9
|
-
## The SDK gap —
|
|
9
|
+
## The SDK gap — history, and what shipped on top of the fix
|
|
10
10
|
|
|
11
11
|
✅ **SDK-level gap fixed 2026-09-24** (`@distilled.cloud/grafana`, aliased onto
|
|
12
|
-
`@homeflare/distilled-grafana` — 0.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
17
|
-
the now-available SDK operations, not part of this fix.
|
|
12
|
+
`@homeflare/distilled-grafana` — 0.2.0; see [docs/distilled-interim.md](./distilled-interim.md)).
|
|
13
|
+
The gap below is left as evidence of what was measured and why. `Grafana.Folder` and
|
|
14
|
+
`Grafana.Dashboard` now ship on top of the unblocked operations —
|
|
15
|
+
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md) covers both in full. `Grafana.AlertRule`,
|
|
16
|
+
`ContactPoint`, `NotificationPolicy` and `MuteTiming` remain follow-up work, not part of this PR.
|
|
18
17
|
|
|
19
18
|
Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
|
|
20
19
|
`lib/services/grafana.d.ts`, 4,938 lines): the package had **no create/read/update/delete
|
|
@@ -125,8 +124,11 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
|
|
|
125
124
|
|
|
126
125
|
## Not covered
|
|
127
126
|
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
- **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
|
|
128
|
+
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
|
|
129
|
+
- **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship** — see
|
|
130
|
+
[grafana-alerting.md](./grafana-alerting.md), not repeated here.
|
|
131
|
+
- **`Grafana.AlertRuleGroup` and `Grafana.NotificationPolicy`** — stacked follow-up PRs on the same
|
|
132
|
+
operations, per grafana-alerting.md's own ordering; not blocked on anything.
|
|
131
133
|
- **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
|
|
132
134
|
still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
|
|
@@ -259,10 +259,37 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
|
|
|
259
259
|
`@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
|
|
260
260
|
releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
|
|
261
261
|
`@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
|
|
262
|
-
are unchanged (diffed operation-by-operation against the prior commit).
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
262
|
+
are unchanged (diffed operation-by-operation against the prior commit).
|
|
263
|
+
|
|
264
|
+
✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (PR 245, on top of the same
|
|
265
|
+
`@distilled.cloud/grafana` operations S22 unblocked) — the uid-required doctrine, the
|
|
266
|
+
provisioned-object refusal, the parentUid-is-create-only refusal and the volatile-field
|
|
267
|
+
normalization that makes an unchanged dashboard a true noop are all in
|
|
268
|
+
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
|
|
269
|
+
|
|
270
|
+
✅ **`Grafana.ContactPoint`/`Grafana.MuteTiming`/`Grafana.MessageTemplate` shipped** (this PR,
|
|
271
|
+
first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy` follow):
|
|
272
|
+
the foreign-provenance refusal (`alerting-provenance.ts`, an allowlist of Grafana's own
|
|
273
|
+
`ProvenanceNone`/`ProvenanceAPI` values, measured against `pkg/services/ngalert/models` and
|
|
274
|
+
Grafana's v13.1 docs) and the secret-settings-ref seam (`secret-refs.ts`, shared with
|
|
275
|
+
`Grafana.Datasource`) are both new shared building blocks — full detail:
|
|
276
|
+
[grafana-alerting.md](./grafana-alerting.md).
|
|
277
|
+
- 🔴 **New SDK gap found, not fixed here:** `MuteTimeInterval`'s generated type has no
|
|
278
|
+
`provenance`/`version` fields, and the SDK's own `TimeInterval` type is missing the real
|
|
279
|
+
Alertmanager time-interval fields (`weekdays`/`times`/`months`/etc.) entirely — Grafana's real
|
|
280
|
+
API returns and accepts all of them. NOT a data-loss gap: `@distilled.cloud/core`'s response
|
|
281
|
+
decoding never runs a strict schema decode (`JSON.parse` + a key-rename pass that leaves any
|
|
282
|
+
unmodeled key verbatim — `protocol-http.ts`'s `mapKeys`, confirmed by driving the real
|
|
283
|
+
operation through this family's fake-Grafana harness), so `mute-timing.ts` reads/writes both
|
|
284
|
+
through a locally-widened type instead. Same S22 fix route as the other named gaps
|
|
285
|
+
(regenerate the distilled clone's spec, copy forward) — not attempted here, since
|
|
286
|
+
`packages/distilled-grafana/src/` is vendored and never hand-edited.
|
|
287
|
+
- Measured, read-only, same census as PR 245's: `teslamate-grafana` has no contact points, mute
|
|
288
|
+
timings or templates configured either (consistent with the alerting-provisioning gap having
|
|
289
|
+
been real, not merely unexploited).
|
|
290
|
+
|
|
291
|
+
**Still open, not this PR's scope:** `Grafana.AlertRuleGroup`/`Grafana.NotificationPolicy` —
|
|
292
|
+
the next two stacked PRs, no longer blocked on the SDK.
|
|
266
293
|
- ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
|
|
267
294
|
(H1), and credentials come from an explicit env var NAME at call time rather than an
|
|
268
295
|
`alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared refusal for a foreign-provenance ALERTING-PROVISIONING object — `Grafana.ContactPoint`,
|
|
3
|
+
* `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and `Grafana.NotificationPolicy` each read a
|
|
4
|
+
* `provenance` string off what Grafana returns (`EmbeddedContactPoint.provenance`,
|
|
5
|
+
* `NotificationTemplate.provenance`, `ProvisionedAlertRule.provenance` per rule, `Route.provenance`
|
|
6
|
+
* on the policy tree — all measured against `@distilled.cloud/grafana`'s generated types,
|
|
7
|
+
* `packages/distilled-grafana/src/services/grafana.ts`) and refuse a write when it disagrees with
|
|
8
|
+
* what this family is allowed to own. Sibling to `provisioned.ts`, not a rename of it — the two
|
|
9
|
+
* refusals guard different fields with different value shapes (`managedBy`/`provisioned` there,
|
|
10
|
+
* `provenance` here) for two different Grafana API surfaces (classic folder/dashboard vs. alerting
|
|
11
|
+
* provisioning), but both end in the SAME tagged error, reused from `provisioned.ts` rather than
|
|
12
|
+
* declared twice: "a write was refused because something else owns this object" is one condition
|
|
13
|
+
* across the whole family, the spirit PR 245's provisioned refusal already established.
|
|
14
|
+
*
|
|
15
|
+
* ★ THE ALLOWLIST, MEASURED AGAINST GRAFANA'S OWN SOURCE (not this SDK, which only echoes the
|
|
16
|
+
* value back on read — never re-fetched live in this PR): Grafana's `pkg/services/ngalert/
|
|
17
|
+
* models` package defines a `Provenance` type with `ProvenanceNone = ""` (nothing has claimed
|
|
18
|
+
* the object — fresh, or written through the classic non-provisioning path), `ProvenanceAPI =
|
|
19
|
+
* "api"` (created or last written through THIS provisioning API — what this family's own
|
|
20
|
+
* create/update leaves behind), `ProvenanceFile = "file"` (Grafana's file-based provisioning),
|
|
21
|
+
* and `ProvenanceConvertedPrometheus = "converted_prometheus"` (the Prometheus-rules importer).
|
|
22
|
+
* Corroborated by Grafana's own docs (grafana.com/docs/grafana/v13.1/alerting/set-up/
|
|
23
|
+
* provision-alerting-resources/http-api-provisioning/), whose example payloads show
|
|
24
|
+
* `"provenance": "api"` on an alert rule this API created and `"provenance": "file"` on a
|
|
25
|
+
* file-provisioned mute timing and template.
|
|
26
|
+
*
|
|
27
|
+
* `WRITABLE_PROVENANCES` is therefore an ALLOWLIST of `{"", "api"}`, not a denylist of the two
|
|
28
|
+
* named foreign values — a provenance source not yet named above (a plugin importer, a future
|
|
29
|
+
* Grafana release) refuses by default instead of silently being treated as writable, the safer
|
|
30
|
+
* failure direction for a control-plane write.
|
|
31
|
+
*
|
|
32
|
+
* ⛔ A MISSING `provenance` FIELD IS TREATED AS FOREIGN, NEVER AS `ProvenanceNone` — fixed after an
|
|
33
|
+
* adversarial review of this PR found the first version failed OPEN: every resource mapped
|
|
34
|
+
* `live.provenance ?? ''`, and `''` (`ProvenanceNone`, writable) is indistinguishable from a
|
|
35
|
+
* field the response simply never included once both collapse through `??`. Every `provenance`
|
|
36
|
+
* field in this family is optional on the wire (`EmbeddedContactPoint.provenance?: string`, etc.
|
|
37
|
+
* — genuinely absent is a real, typed possibility, not a hypothetical), and `Grafana.MuteTiming`'s
|
|
38
|
+
* depends on an unmodeled-key passthrough this family has never observed against a real Grafana
|
|
39
|
+
* response (see its own file header) — a version, edge case or bug in that decode path that drops
|
|
40
|
+
* the field would, under the old `?? ''` rule, silently make a file-provisioned object look
|
|
41
|
+
* writable. `isForeignProvenance`/`refuseIfForeignProvenance` below keep `undefined` as its own
|
|
42
|
+
* value all the way through: ONLY a field Grafana explicitly reported as `""` or `"api"` is
|
|
43
|
+
* writable; missing entirely refuses, with a message that says so rather than naming a fabricated
|
|
44
|
+
* provenance value.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ CLIENT-SIDE DEFENSE, NOT THE ONLY GUARD — AND NOT RE-MEASURED LIVE IN THIS PR. Grafana's own
|
|
47
|
+
* alerting provisioning API server ALSO enforces this (unlike the classic folder/dashboard write
|
|
48
|
+
* API `provisioned.ts` guards, which has no such server-side check at all): grafana/grafana#103597
|
|
49
|
+
* ("Unable to delete file provisioned alert rules") reports exactly this block firing for a
|
|
50
|
+
* `file`-provenance object, and the `X-Disable-Provenance` header's own documented purpose is "by
|
|
51
|
+
* default, you cannot edit API-provisioned alerting resources in Grafana" (same doc page). This
|
|
52
|
+
* function exists anyway, for the same reason `provisioned.ts` does: a clear, typed, house error
|
|
53
|
+
* instead of whatever 400-with-a-string-message Grafana's server happens to return this version —
|
|
54
|
+
* and as a defense against the known server-side bug that same issue documents
|
|
55
|
+
* (`X-Disable-Provenance` failing to actually unlock a `file`-provenance object), which could
|
|
56
|
+
* otherwise leave a write silently rejected with only a generic `BadRequest` and no explanation
|
|
57
|
+
* of why.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ `Grafana.MuteTiming` ALSO USES THIS FUNCTION, BUT THROUGH A WIDENED LIVE TYPE — the SDK's
|
|
60
|
+
* generated `MuteTimeInterval` interface (the TS type for both `GET .../mute-timings` and
|
|
61
|
+
* `GET .../mute-timings/{name}`) declares no `provenance` field at all, unlike every other type
|
|
62
|
+
* named above. A first pass at this family assumed that meant the DATA was gone; it is not —
|
|
63
|
+
* MEASURED (see mute-timing.ts's own file header for the full account): `@distilled.cloud/core`'s
|
|
64
|
+
* response decoding never runs a strict schema decode, only `JSON.parse` + a key-rename pass that
|
|
65
|
+
* passes any key a type doesn't model through verbatim, so `provenance` survives on the actual
|
|
66
|
+
* runtime value even though the TS type doesn't know about it. `mute-timing.ts` reads it through
|
|
67
|
+
* a small local `& { provenance?: string }` intersection type rather than the SDK's own.
|
|
68
|
+
*/
|
|
69
|
+
import * as Effect from 'effect/Effect';
|
|
70
|
+
import { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
71
|
+
|
|
72
|
+
export { GrafanaProvisionedObjectError };
|
|
73
|
+
|
|
74
|
+
const WRITABLE_PROVENANCES = new Set(['', 'api']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when `provenance` names an owner other than "this API" — INCLUDING when Grafana did not
|
|
78
|
+
* report the field at all (`undefined`). `undefined` is deliberately NEVER folded into `''`
|
|
79
|
+
* (`ProvenanceNone`) here — see the file header's "fails open" note. Only a value Grafana
|
|
80
|
+
* explicitly reported as `""` or `"api"` is writable.
|
|
81
|
+
*/
|
|
82
|
+
export const isForeignProvenance = (provenance: string | undefined): boolean =>
|
|
83
|
+
provenance === undefined || !WRITABLE_PROVENANCES.has(provenance);
|
|
84
|
+
|
|
85
|
+
export const refuseIfForeignProvenance = (
|
|
86
|
+
kind:
|
|
87
|
+
| 'Grafana.ContactPoint'
|
|
88
|
+
| 'Grafana.MessageTemplate'
|
|
89
|
+
| 'Grafana.MuteTiming'
|
|
90
|
+
| 'Grafana.AlertRuleGroup'
|
|
91
|
+
| 'Grafana.NotificationPolicy',
|
|
92
|
+
id: string,
|
|
93
|
+
provenance: string | undefined,
|
|
94
|
+
): Effect.Effect<never, GrafanaProvisionedObjectError> => {
|
|
95
|
+
const reason =
|
|
96
|
+
provenance === undefined
|
|
97
|
+
? `did not report a provenance at all — this family cannot tell whether it is safe to own`
|
|
98
|
+
: `has provenance '${provenance}', not '(none)' or 'api'`;
|
|
99
|
+
return Effect.fail(
|
|
100
|
+
new GrafanaProvisionedObjectError({
|
|
101
|
+
message:
|
|
102
|
+
`${kind} '${id}' ${reason} — something other than this API may own it (file ` +
|
|
103
|
+
`provisioning, the Prometheus-rules importer, or similar), and writing here could take ` +
|
|
104
|
+
`over or corrupt an object this family does not actually control. Edit it at its actual ` +
|
|
105
|
+
`source instead — declaring this object read-only (a declaration matching what is live) ` +
|
|
106
|
+
`is safe and will never reach this refusal.`,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for `contact-point.test.ts`/`contact-point-secrets.test.ts`/
|
|
3
|
+
* `contact-point-provenance.test.ts` — split across three files to keep each under the house's
|
|
4
|
+
* 250-line cap. Deliberately NOT named `*.test.ts`: importing one test file from another would
|
|
5
|
+
* make `bun test`'s glob discovery execute its `describe` blocks twice (once directly, once as an
|
|
6
|
+
* import side effect of the importing file) — this file has none to run, only plain data.
|
|
7
|
+
*/
|
|
8
|
+
export const LIST_PATH = '/api/v1/provisioning/contact-points';
|
|
9
|
+
export const ITEM_PATH = '/api/v1/provisioning/contact-points/slack-oncall';
|
|
10
|
+
|
|
11
|
+
export const liveJson = {
|
|
12
|
+
disableResolveMessage: false,
|
|
13
|
+
name: 'On-call Slack',
|
|
14
|
+
provenance: 'api',
|
|
15
|
+
settings: { recipient: '#oncall' },
|
|
16
|
+
type: 'slack',
|
|
17
|
+
uid: 'slack-oncall',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const props = {
|
|
21
|
+
name: 'On-call Slack',
|
|
22
|
+
settings: { recipient: '#oncall' },
|
|
23
|
+
type: 'slack',
|
|
24
|
+
uid: 'slack-oncall',
|
|
25
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint`'s provenance refusal — both the explicit-foreign case (`provenance:
|
|
3
|
+
* 'file'`) and the missing-field-fails-closed case an adversarial review of this PR found. Split
|
|
4
|
+
* out of `contact-point.test.ts` to keep each file under the house's 250-line cap.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
|
|
9
|
+
import { ITEM_PATH, LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
|
|
10
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
11
|
+
import { grafanaOperations } from './resource.ts';
|
|
12
|
+
import { spec } from './contact-point.ts';
|
|
13
|
+
|
|
14
|
+
describe('update and destroy — foreign provenance refuses', () => {
|
|
15
|
+
test('a file-provisioned contact point refuses update and sends no PUT', async () => {
|
|
16
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
17
|
+
const fake = fakeGrafana((method, url) =>
|
|
18
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
19
|
+
? Response.json([fileProvisioned])
|
|
20
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
21
|
+
);
|
|
22
|
+
const failure = await Effect.runPromise(
|
|
23
|
+
Effect.flip(
|
|
24
|
+
grafanaOperations(spec)
|
|
25
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
26
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
30
|
+
expect(failure.message).toContain('file');
|
|
31
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a file-provisioned contact point refuses destroy and sends no DELETE', async () => {
|
|
35
|
+
const fileProvisioned = { ...liveJson, provenance: 'file' };
|
|
36
|
+
const fake = fakeGrafana((method, url) =>
|
|
37
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
38
|
+
? Response.json([fileProvisioned])
|
|
39
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
40
|
+
);
|
|
41
|
+
const failure = await Effect.runPromise(
|
|
42
|
+
Effect.flip(
|
|
43
|
+
grafanaOperations(spec)
|
|
44
|
+
.destroy(props)
|
|
45
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
49
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('missing provenance fails closed', () => {
|
|
54
|
+
// ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND. An earlier version mapped
|
|
55
|
+
// `live.provenance ?? ''`, so a response that simply omitted the field looked identical to an
|
|
56
|
+
// explicit `ProvenanceNone` and was treated as writable — the guard failed OPEN. These three
|
|
57
|
+
// tests pin the fix: missing refuses (both writes), an EXPLICIT `""` is the only thing that
|
|
58
|
+
// allows one.
|
|
59
|
+
const noProvenanceField: Record<string, unknown> = { ...liveJson };
|
|
60
|
+
delete noProvenanceField.provenance;
|
|
61
|
+
|
|
62
|
+
test('a response with no provenance field at all refuses update', async () => {
|
|
63
|
+
const fake = fakeGrafana((method, url) =>
|
|
64
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
65
|
+
? Response.json([noProvenanceField])
|
|
66
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
67
|
+
);
|
|
68
|
+
const failure = await Effect.runPromise(
|
|
69
|
+
Effect.flip(
|
|
70
|
+
grafanaOperations(spec)
|
|
71
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
72
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
76
|
+
expect(failure.message).toContain('did not report a provenance');
|
|
77
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a response with no provenance field at all refuses destroy', async () => {
|
|
81
|
+
const fake = fakeGrafana((method, url) =>
|
|
82
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
83
|
+
? Response.json([noProvenanceField])
|
|
84
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
85
|
+
);
|
|
86
|
+
const failure = await Effect.runPromise(
|
|
87
|
+
Effect.flip(
|
|
88
|
+
grafanaOperations(spec)
|
|
89
|
+
.destroy(props)
|
|
90
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
94
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
|
|
98
|
+
const explicitlyNone = { ...liveJson, provenance: '' };
|
|
99
|
+
const fake = fakeGrafana((method, url) => {
|
|
100
|
+
if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([explicitlyNone]);
|
|
101
|
+
if (method === 'PUT' && url.pathname === ITEM_PATH) {
|
|
102
|
+
return Response.json({ ...explicitlyNone, name: 'Renamed' });
|
|
103
|
+
}
|
|
104
|
+
return fakeFailure(500, 'unexpected request');
|
|
105
|
+
});
|
|
106
|
+
await Effect.runPromise(
|
|
107
|
+
grafanaOperations(spec)
|
|
108
|
+
.reconcile({ ...props, name: 'Renamed' })
|
|
109
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
110
|
+
);
|
|
111
|
+
expect(fake.seen.some((s) => s.method === 'PUT' && s.path === ITEM_PATH)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.ContactPoint`'s `secureSettingsRefs` seam (S25) — split out of `contact-point.test.ts`
|
|
3
|
+
* to keep each file under the house's 250-line cap. A secret value must reach the wire on write and
|
|
4
|
+
* never appear anywhere this resource returns or would persist.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
|
|
9
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
10
|
+
import { grafanaOperations } from './resource.ts';
|
|
11
|
+
import { GrafanaSecretRefUnsetError, spec } from './contact-point.ts';
|
|
12
|
+
|
|
13
|
+
const ENV_VAR = 'GRAFANA_FAMILY_TEST_CP_TOKEN';
|
|
14
|
+
|
|
15
|
+
describe('secure settings references (S25)', () => {
|
|
16
|
+
test('create resolves the env var fresh and merges it into settings, never as a prop', async () => {
|
|
17
|
+
process.env[ENV_VAR] = 'xoxb-s3cret';
|
|
18
|
+
try {
|
|
19
|
+
const fake = fakeGrafana((method, url) =>
|
|
20
|
+
method === 'POST' && url.pathname === LIST_PATH
|
|
21
|
+
? Response.json(liveJson)
|
|
22
|
+
: fakeFailure(404, 'x'),
|
|
23
|
+
);
|
|
24
|
+
await Effect.runPromise(
|
|
25
|
+
spec
|
|
26
|
+
.create({ ...props, secureSettingsRefs: { url: ENV_VAR } })
|
|
27
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
28
|
+
);
|
|
29
|
+
expect(fake.bodies[0]).toMatchObject({
|
|
30
|
+
settings: { recipient: '#oncall', url: 'xoxb-s3cret' },
|
|
31
|
+
});
|
|
32
|
+
} finally {
|
|
33
|
+
delete process.env[ENV_VAR];
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the secret reaches the wire on create but never the RETURNED attributes — proven end to end', async () => {
|
|
38
|
+
// ⛔ THE POINT OF THIS FAMILY'S TESTS, TIGHTENED AFTER AN ADVERSARIAL REVIEW OF THIS PR: the
|
|
39
|
+
// earlier version only asserted `JSON.stringify(props)` never contained the secret — trivially
|
|
40
|
+
// true, since `props` never held it to begin with (it lives in `secureSettingsRefs`, an env
|
|
41
|
+
// var NAME). This drives a REAL `reconcile` (create, then the read-back `resource.ts` always
|
|
42
|
+
// does) through the fake and stringifies what it actually RETURNS — the shape that would be
|
|
43
|
+
// persisted to state — proving the secret never survives there, not just that it was never in
|
|
44
|
+
// an input we already knew was secret-free.
|
|
45
|
+
process.env[ENV_VAR] = 'xoxb-s3cret';
|
|
46
|
+
try {
|
|
47
|
+
let created = false;
|
|
48
|
+
const fake = fakeGrafana((method, url) => {
|
|
49
|
+
if (method === 'GET' && url.pathname === LIST_PATH) {
|
|
50
|
+
return created
|
|
51
|
+
? Response.json([
|
|
52
|
+
{ ...liveJson, settings: { recipient: '#oncall', url: 'RedactedValue' } },
|
|
53
|
+
])
|
|
54
|
+
: Response.json([]);
|
|
55
|
+
}
|
|
56
|
+
if (method === 'POST' && url.pathname === LIST_PATH) {
|
|
57
|
+
created = true;
|
|
58
|
+
return Response.json(liveJson);
|
|
59
|
+
}
|
|
60
|
+
return fakeFailure(500, 'unexpected request');
|
|
61
|
+
});
|
|
62
|
+
const declared = { ...props, secureSettingsRefs: { url: ENV_VAR } };
|
|
63
|
+
const attrs = await Effect.runPromise(
|
|
64
|
+
grafanaOperations(spec)
|
|
65
|
+
.reconcile(declared)
|
|
66
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
67
|
+
);
|
|
68
|
+
const createBody = fake.bodies[fake.seen.findIndex((s) => s.method === 'POST')];
|
|
69
|
+
// The wire genuinely carried it — the write would be useless otherwise.
|
|
70
|
+
expect(createBody).toMatchObject({ settings: { recipient: '#oncall', url: 'xoxb-s3cret' } });
|
|
71
|
+
// Nothing this resource returns or would persist does — the declaration, and the attributes
|
|
72
|
+
// `reconcile` hands back after reading the object right back through Grafana's own redaction.
|
|
73
|
+
expect(JSON.stringify(declared)).not.toContain('xoxb-s3cret');
|
|
74
|
+
expect(JSON.stringify(attrs)).not.toContain('xoxb-s3cret');
|
|
75
|
+
expect(attrs.settings).toEqual({ recipient: '#oncall', url: 'RedactedValue' });
|
|
76
|
+
} finally {
|
|
77
|
+
delete process.env[ENV_VAR];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('create refuses with a typed error when the ref is unset — and sends nothing', async () => {
|
|
82
|
+
const fake = fakeGrafana(() => fakeFailure(500, 'should not be reached'));
|
|
83
|
+
const failure = await Effect.runPromise(
|
|
84
|
+
Effect.flip(
|
|
85
|
+
spec
|
|
86
|
+
.create({ ...props, secureSettingsRefs: { url: 'DEFINITELY_UNSET_VAR_XYZ' } })
|
|
87
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
expect(failure).toBeInstanceOf(GrafanaSecretRefUnsetError);
|
|
91
|
+
expect(fake.seen).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a redacted secure field on read never fails matches, even if props still names a stale value', async () => {
|
|
95
|
+
// ⛔ THE POINT: `settings.url` is declared here (a stale placeholder — the real value comes
|
|
96
|
+
// from `secureSettingsRefs` instead) specifically so a plain subset-match tolerance (live
|
|
97
|
+
// simply carrying an UNDECLARED extra key) is not what makes this pass — `nonSecretSettings`
|
|
98
|
+
// must actively exclude a key `secureSettingsRefs` names, even when `settings` also mentions
|
|
99
|
+
// it, or a redacted live value would show a permanent false `update`.
|
|
100
|
+
const declared = {
|
|
101
|
+
...props,
|
|
102
|
+
secureSettingsRefs: { url: 'IRRELEVANT' },
|
|
103
|
+
settings: { recipient: '#oncall', url: 'https://placeholder.invalid' },
|
|
104
|
+
};
|
|
105
|
+
const redactedLive = { ...liveJson, settings: { recipient: '#oncall', url: 'RedactedValue' } };
|
|
106
|
+
const fake = fakeGrafana((method, url) =>
|
|
107
|
+
method === 'GET' && url.pathname === LIST_PATH
|
|
108
|
+
? Response.json([redactedLive])
|
|
109
|
+
: fakeFailure(500, 'x'),
|
|
110
|
+
);
|
|
111
|
+
const live = await Effect.runPromise(
|
|
112
|
+
spec.fetchLive(declared).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
113
|
+
);
|
|
114
|
+
const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
|
|
115
|
+
expect(attrs !== undefined && spec.matches(attrs, declared)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|