@homeflare/alchemy 0.31.2 → 0.33.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/README.md +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -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/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- 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/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- 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/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- 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/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- 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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Dashboard` — one Grafana dashboard, keyed by `uid`. Same uid-required doctrine as
|
|
3
|
+
* `datasource.ts`/`folder.ts` (`getDashboard`/`postDashboard`/`deleteDashboard` are all UID-keyed
|
|
4
|
+
* on the wire — `postDashboard`'s own `uid` travels INSIDE the `dashboard` JSON body, not as a
|
|
5
|
+
* request field, because Grafana's classic dashboard-save API has never had one; see
|
|
6
|
+
* `dashboard-model.ts`'s `normalizeModel`, which is what puts it there).
|
|
7
|
+
*
|
|
8
|
+
* ★ THE DECLARED CONTENT IS THE DASHBOARD JSON MODEL — `DashboardProps.dashboard`, the same shape
|
|
9
|
+
* Grafana's own "Export as JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips the
|
|
10
|
+
* volatile fields (`id`, `version`, `iteration`) and forces `uid` to this resource's own value
|
|
11
|
+
* before ANY comparison, so a plan against an unchanged dashboard is a true noop despite those
|
|
12
|
+
* fields moving on every live save — see that file for the full reasoning, including which
|
|
13
|
+
* fields (`panels`, `targets`) are array-ORDER-significant and compared as such.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ `matches` COMPARES "IS THE DECLARATION A SUBSET OF WHAT IS LIVE", NOT PLAIN EQUALITY —
|
|
16
|
+
* `dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
|
|
17
|
+
* found plain `deepEqual` made an ordinary dashboard show `update` FOREVER: Grafana's own
|
|
18
|
+
* schema-migration on save decorates any panel/target whose `datasource` is absent with an
|
|
19
|
+
* explicit reference, so a declaration that never sets one would permanently disagree with what
|
|
20
|
+
* it reads back. See that function's own header for the full reasoning and the trade-off it
|
|
21
|
+
* accepts (an omitted field can no longer be used to CLEAR a previously-set one).
|
|
22
|
+
*
|
|
23
|
+
* ⛔ VERSION CONFLICTS (412) FAIL LOUDLY — NEVER OVERWRITTEN BLINDLY. `update` injects the
|
|
24
|
+
* `version` this resource just read (`live.meta.version`, from the SAME `fetchLive` call
|
|
25
|
+
* `resource.ts`'s `reconcile` already made before calling `update`) into the model it sends, and
|
|
26
|
+
* `overwrite` is never set. If Grafana's own version check then still fails — someone else saved
|
|
27
|
+
* in the gap between that read and this write — `postDashboard` answers `412` and
|
|
28
|
+
* `PreconditionFailed` is left IN the error union below, uncaught: the whole reconcile fails, the
|
|
29
|
+
* deploy stops, and nothing is written. The next run's `fetchLive` re-reads the new live version
|
|
30
|
+
* fresh, which is the "re-read" half of "re-read and fail loudly" — there is no separate retry
|
|
31
|
+
* loop in this file, because the next `plan`/`deploy` invocation already IS that re-read.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ A PROVISIONED DASHBOARD (`meta.provisioned` — see provisioned.ts) REFUSES EVERY UPDATE AND
|
|
34
|
+
* DESTROY, naming `meta.provisionedExternalId` (the file) when Grafana reports one. `matches`
|
|
35
|
+
* still compares structurally regardless, so a mismatched declaration against a provisioned
|
|
36
|
+
* dashboard shows `update` in a plan rather than a silent, permanently-hidden noop.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ NO RETAIN DEFAULT, UNLIKE `Grafana.Folder`. Deleting one dashboard is a single-object blast
|
|
39
|
+
* radius — the same class `Grafana.Datasource` already accepts without a retain guard — not
|
|
40
|
+
* folder.ts's multi-object cascade (a folder's delete takes its dashboards AND their alert rules
|
|
41
|
+
* with it). See folder.ts's file header for the full contrast.
|
|
42
|
+
*/
|
|
43
|
+
import { Resource } from 'alchemy';
|
|
44
|
+
import * as Provider from 'alchemy/Provider';
|
|
45
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
46
|
+
import * as Effect from 'effect/Effect';
|
|
47
|
+
import {
|
|
48
|
+
type DashboardModel,
|
|
49
|
+
declaredContentMatches,
|
|
50
|
+
modelTitle,
|
|
51
|
+
normalizeModel,
|
|
52
|
+
} from './dashboard-model.ts';
|
|
53
|
+
import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
|
|
54
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
55
|
+
|
|
56
|
+
export type { DashboardModel };
|
|
57
|
+
|
|
58
|
+
export interface DashboardProps {
|
|
59
|
+
uid: string;
|
|
60
|
+
/** Omit for the General/root folder. */
|
|
61
|
+
folderUid?: string;
|
|
62
|
+
/** The dashboard JSON model — see the file header. `id`/`version`/`iteration`/`uid` inside it
|
|
63
|
+
* are ignored; `uid` above is the single source of truth. */
|
|
64
|
+
dashboard: DashboardModel;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DashboardAttributes {
|
|
68
|
+
uid: string;
|
|
69
|
+
title: string;
|
|
70
|
+
version: number;
|
|
71
|
+
folderUid: string;
|
|
72
|
+
url: string;
|
|
73
|
+
/** True when Grafana reports this dashboard as file-provisioned (read-only — see provisioned.ts). */
|
|
74
|
+
provisioned: boolean;
|
|
75
|
+
provisionedExternalId: string;
|
|
76
|
+
/** The live model, normalized the same way `matches` compares it. */
|
|
77
|
+
dashboard: DashboardModel;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GrafanaDashboard extends Resource<
|
|
81
|
+
'Grafana.Dashboard',
|
|
82
|
+
DashboardProps,
|
|
83
|
+
DashboardAttributes,
|
|
84
|
+
never
|
|
85
|
+
> {}
|
|
86
|
+
|
|
87
|
+
export const GrafanaDashboard = Resource<GrafanaDashboard>('Grafana.Dashboard');
|
|
88
|
+
|
|
89
|
+
const modelOf = (live: grafana.DashboardFullWithMeta): DashboardModel =>
|
|
90
|
+
live.dashboard !== null && typeof live.dashboard === 'object'
|
|
91
|
+
? (live.dashboard as DashboardModel)
|
|
92
|
+
: {};
|
|
93
|
+
|
|
94
|
+
export const spec: GrafanaSpec<
|
|
95
|
+
DashboardProps,
|
|
96
|
+
grafana.DashboardFullWithMeta,
|
|
97
|
+
DashboardAttributes,
|
|
98
|
+
| grafana.GetDashboardError
|
|
99
|
+
| grafana.PostDashboardError
|
|
100
|
+
| grafana.DeleteDashboardError
|
|
101
|
+
| GrafanaProvisionedObjectError
|
|
102
|
+
> = {
|
|
103
|
+
attributes: (live, props) => {
|
|
104
|
+
const model = normalizeModel(modelOf(live), props.uid);
|
|
105
|
+
return {
|
|
106
|
+
dashboard: model,
|
|
107
|
+
folderUid: live.meta?.folderUid ?? '',
|
|
108
|
+
provisioned: live.meta?.provisioned ?? false,
|
|
109
|
+
provisionedExternalId: live.meta?.provisionedExternalId ?? '',
|
|
110
|
+
title: modelTitle(model),
|
|
111
|
+
uid: props.uid,
|
|
112
|
+
url: live.meta?.url ?? '',
|
|
113
|
+
version: live.meta?.version ?? 0,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
create: (props) =>
|
|
117
|
+
grafana.postDashboard({
|
|
118
|
+
dashboard: normalizeModel(props.dashboard, props.uid),
|
|
119
|
+
...(props.folderUid === undefined ? {} : { folderUid: props.folderUid }),
|
|
120
|
+
}),
|
|
121
|
+
destroy: (props, live) =>
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
if (live.meta?.provisioned) {
|
|
124
|
+
return yield* refuseIfProvisioned(
|
|
125
|
+
'Grafana.Dashboard',
|
|
126
|
+
props.uid,
|
|
127
|
+
live.meta.provisionedExternalId,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
yield* grafana.deleteDashboard({ uid: props.uid });
|
|
131
|
+
}),
|
|
132
|
+
fetchLive: (props) =>
|
|
133
|
+
grafana
|
|
134
|
+
.getDashboard({ uid: props.uid })
|
|
135
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
136
|
+
matches: (attributes, props) =>
|
|
137
|
+
(props.folderUid === undefined || attributes.folderUid === props.folderUid) &&
|
|
138
|
+
declaredContentMatches(normalizeModel(props.dashboard, props.uid), attributes.dashboard),
|
|
139
|
+
update: (props, live) =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
if (live.meta?.provisioned) {
|
|
142
|
+
return yield* refuseIfProvisioned(
|
|
143
|
+
'Grafana.Dashboard',
|
|
144
|
+
props.uid,
|
|
145
|
+
live.meta.provisionedExternalId,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return yield* grafana.postDashboard({
|
|
149
|
+
dashboard: {
|
|
150
|
+
...normalizeModel(props.dashboard, props.uid),
|
|
151
|
+
...(live.meta?.version === undefined ? {} : { version: live.meta.version }),
|
|
152
|
+
},
|
|
153
|
+
...(props.folderUid === undefined ? {} : { folderUid: props.folderUid }),
|
|
154
|
+
});
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const handlers = grafanaHandlers(spec);
|
|
159
|
+
|
|
160
|
+
export const GrafanaDashboardProvider = () =>
|
|
161
|
+
Provider.effect(GrafanaDashboard, Effect.succeed(GrafanaDashboard.Provider.of(handlers)));
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Folder` against a fake Grafana — the observe-before-write "adopt" posture (S9/S10),
|
|
3
|
+
* the provisioned-object refusal (provisioned.ts) and the parentUid-is-create-only refusal, all
|
|
4
|
+
* through the REAL `@distilled.cloud/grafana` protocol. Mirrors `datasource.test.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
|
|
9
|
+
import { GrafanaFolderReparentError, spec } from './folder.ts';
|
|
10
|
+
import { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
11
|
+
import { grafanaOperations } from './resource.ts';
|
|
12
|
+
|
|
13
|
+
const FOLDER_PATH = '/api/folders/team-dashboards';
|
|
14
|
+
const CREATE_PATH = '/api/folders';
|
|
15
|
+
|
|
16
|
+
const liveJson = {
|
|
17
|
+
id: 3,
|
|
18
|
+
managedBy: '',
|
|
19
|
+
parentUid: '',
|
|
20
|
+
title: 'Team Dashboards',
|
|
21
|
+
uid: 'team-dashboards',
|
|
22
|
+
url: '/dashboards/f/team-dashboards',
|
|
23
|
+
version: 1,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const props = { title: 'Team Dashboards', uid: 'team-dashboards' };
|
|
27
|
+
|
|
28
|
+
describe('spec.fetchLive', () => {
|
|
29
|
+
test('a live folder decodes into typed attributes', async () => {
|
|
30
|
+
const fake = fakeGrafana((method, url) =>
|
|
31
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
32
|
+
? Response.json(liveJson)
|
|
33
|
+
: fakeFailure(404, 'not found'),
|
|
34
|
+
);
|
|
35
|
+
const live = await Effect.runPromise(
|
|
36
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
37
|
+
);
|
|
38
|
+
expect(live !== undefined && spec.attributes(live, props)).toEqual({
|
|
39
|
+
id: 3,
|
|
40
|
+
managedBy: '',
|
|
41
|
+
parentUid: '',
|
|
42
|
+
title: 'Team Dashboards',
|
|
43
|
+
uid: 'team-dashboards',
|
|
44
|
+
url: '/dashboards/f/team-dashboards',
|
|
45
|
+
version: 1,
|
|
46
|
+
});
|
|
47
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: FOLDER_PATH }]);
|
|
48
|
+
// ⛔ THE POINT: a GET never carries a body — see the shared memory's "distilled JSON-encodes
|
|
49
|
+
// unknown keys as a body even on a GET" trap.
|
|
50
|
+
expect(fake.bodies[0]).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a 404 folds to undefined', async () => {
|
|
54
|
+
const fake = fakeGrafana(() => fakeFailure(404, 'folder not found'));
|
|
55
|
+
const result = await Effect.runPromise(
|
|
56
|
+
spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
57
|
+
);
|
|
58
|
+
expect(result).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a 403 propagates — never folded to absent', async () => {
|
|
62
|
+
const fake = fakeGrafana(() => fakeFailure(403, 'forbidden'));
|
|
63
|
+
const failure = await Effect.runPromise(
|
|
64
|
+
Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
|
|
65
|
+
);
|
|
66
|
+
expect(failure._tag).toBe('Forbidden');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('create', () => {
|
|
71
|
+
test('sends only declared fields — parentUid omitted when undeclared', async () => {
|
|
72
|
+
const fake = fakeGrafana((method, url) =>
|
|
73
|
+
method === 'POST' && url.pathname === CREATE_PATH
|
|
74
|
+
? Response.json(liveJson)
|
|
75
|
+
: fakeFailure(404, 'unexpected request'),
|
|
76
|
+
);
|
|
77
|
+
await Effect.runPromise(spec.create(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))));
|
|
78
|
+
expect(fake.bodies[0]).toEqual({ title: 'Team Dashboards', uid: 'team-dashboards' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('sends description and parentUid when declared', async () => {
|
|
82
|
+
const fake = fakeGrafana(() => Response.json(liveJson));
|
|
83
|
+
await Effect.runPromise(
|
|
84
|
+
spec
|
|
85
|
+
.create({ ...props, description: 'squad dashboards', parentUid: 'engineering' })
|
|
86
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
87
|
+
);
|
|
88
|
+
expect(fake.bodies[0]).toMatchObject({
|
|
89
|
+
description: 'squad dashboards',
|
|
90
|
+
parentUid: 'engineering',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('reconcile — adopt semantics', () => {
|
|
96
|
+
test('declaring exactly what is live is a noop: no write is ever sent', async () => {
|
|
97
|
+
const fake = fakeGrafana((method, url) =>
|
|
98
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
99
|
+
? Response.json(liveJson)
|
|
100
|
+
: fakeFailure(500, 'reconcile should not write when nothing changed'),
|
|
101
|
+
);
|
|
102
|
+
const after = await Effect.runPromise(
|
|
103
|
+
grafanaOperations(spec)
|
|
104
|
+
.reconcile(props)
|
|
105
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
106
|
+
);
|
|
107
|
+
expect(after.uid).toBe('team-dashboards');
|
|
108
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('update', () => {
|
|
113
|
+
test('a title mismatch sends the live version for optimistic concurrency', async () => {
|
|
114
|
+
let updated = false;
|
|
115
|
+
const fake = fakeGrafana((method, url) => {
|
|
116
|
+
if (method === 'GET' && url.pathname === FOLDER_PATH) {
|
|
117
|
+
return Response.json(updated ? { ...liveJson, title: 'Renamed' } : liveJson);
|
|
118
|
+
}
|
|
119
|
+
if (method === 'PUT' && url.pathname === FOLDER_PATH) {
|
|
120
|
+
updated = true;
|
|
121
|
+
return Response.json({ ...liveJson, title: 'Renamed' });
|
|
122
|
+
}
|
|
123
|
+
return fakeFailure(500, 'unexpected request');
|
|
124
|
+
});
|
|
125
|
+
await Effect.runPromise(
|
|
126
|
+
grafanaOperations(spec)
|
|
127
|
+
.reconcile({ ...props, title: 'Renamed' })
|
|
128
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
129
|
+
);
|
|
130
|
+
const put = fake.seen.findIndex((s) => s.method === 'PUT');
|
|
131
|
+
expect(put).toBeGreaterThanOrEqual(0);
|
|
132
|
+
expect(fake.bodies[put]).toMatchObject({ title: 'Renamed', version: 1 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ★ BOTH TESTS GO THROUGH `grafanaOperations(spec).reconcile`, NOT `spec.update` DIRECTLY —
|
|
136
|
+
// `update` is optional on the shared `GrafanaSpec` type, and `reconcile` is what production
|
|
137
|
+
// code actually calls; it also exercises the same `fetchLive` → `matches` → `update` sequence.
|
|
138
|
+
test('a parentUid mismatch refuses — updateFolder cannot reparent', async () => {
|
|
139
|
+
const fake = fakeGrafana((method, url) =>
|
|
140
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
141
|
+
? Response.json(liveJson)
|
|
142
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
143
|
+
);
|
|
144
|
+
const failure = await Effect.runPromise(
|
|
145
|
+
Effect.flip(
|
|
146
|
+
grafanaOperations(spec)
|
|
147
|
+
.reconcile({ ...props, parentUid: 'engineering' })
|
|
148
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
expect(failure).toBeInstanceOf(GrafanaFolderReparentError);
|
|
152
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a provisioned (managedBy set) folder refuses the write and sends nothing', async () => {
|
|
156
|
+
const provisioned = { ...liveJson, managedBy: 'repo' };
|
|
157
|
+
const fake = fakeGrafana((method, url) =>
|
|
158
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
159
|
+
? Response.json(provisioned)
|
|
160
|
+
: fakeFailure(500, 'no write should be attempted'),
|
|
161
|
+
);
|
|
162
|
+
const failure = await Effect.runPromise(
|
|
163
|
+
Effect.flip(
|
|
164
|
+
grafanaOperations(spec)
|
|
165
|
+
.reconcile({ ...props, title: 'Renamed' })
|
|
166
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
170
|
+
expect(failure.message).toContain('repo');
|
|
171
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('destroy', () => {
|
|
176
|
+
test('deletes a live, non-provisioned folder by uid', async () => {
|
|
177
|
+
const fake = fakeGrafana((method, url) => {
|
|
178
|
+
if (method === 'GET' && url.pathname === FOLDER_PATH) return Response.json(liveJson);
|
|
179
|
+
if (method === 'DELETE' && url.pathname === FOLDER_PATH) {
|
|
180
|
+
return Response.json({ id: 3, message: 'ok', title: 'Team Dashboards' });
|
|
181
|
+
}
|
|
182
|
+
return fakeFailure(500, 'unexpected request');
|
|
183
|
+
});
|
|
184
|
+
await Effect.runPromise(
|
|
185
|
+
grafanaOperations(spec)
|
|
186
|
+
.destroy(props)
|
|
187
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
188
|
+
);
|
|
189
|
+
expect(fake.seen).toEqual([
|
|
190
|
+
{ method: 'GET', path: FOLDER_PATH },
|
|
191
|
+
{ method: 'DELETE', path: FOLDER_PATH },
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a provisioned folder refuses destroy and sends no DELETE', async () => {
|
|
196
|
+
const provisioned = { ...liveJson, managedBy: 'repo' };
|
|
197
|
+
const fake = fakeGrafana((method, url) =>
|
|
198
|
+
method === 'GET' && url.pathname === FOLDER_PATH
|
|
199
|
+
? Response.json(provisioned)
|
|
200
|
+
: fakeFailure(500, 'no DELETE should be attempted'),
|
|
201
|
+
);
|
|
202
|
+
const failure = await Effect.runPromise(
|
|
203
|
+
Effect.flip(
|
|
204
|
+
grafanaOperations(spec)
|
|
205
|
+
.destroy(props)
|
|
206
|
+
.pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
|
|
210
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: FOLDER_PATH }]);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Grafana.Folder` — one Grafana folder, keyed by `uid`. Mirrors `datasource.ts`'s shape and
|
|
3
|
+
* doctrine (uid required, not generated — same reasoning, `getFolderByUID`/`updateFolder`/
|
|
4
|
+
* `deleteFolder` are all UID-keyed and `getFolders` (list) has no per-uid filter).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ `description` IS WRITE-ONLY — MEASURED against `@distilled.cloud/grafana`'s generated types
|
|
7
|
+
* (`Folder` in services/grafana.ts has no `description` field at all, though
|
|
8
|
+
* `CreateFolderRequest`/`UpdateFolderRequest` both accept one). Grafana's classic folder GET
|
|
9
|
+
* never returns it, not even as an is-it-set flag the way `DataSource.secureJsonFields` reports
|
|
10
|
+
* for a datasource's secure fields. So `description` is sent on every create/update that
|
|
11
|
+
* declares it but is NEVER part of `matches` — this resource cannot detect drift in a value it
|
|
12
|
+
* is never shown, the same treatment `datasource.ts` gives `secureJsonDataRefs`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ NESTING (`parentUid`) IS CREATE-ONLY — MEASURED against the same generated types.
|
|
15
|
+
* `CreateFolderRequest.parentUid` exists; `UpdateFolderRequest` has no `parentUid` field at all
|
|
16
|
+
* (`folder_uid`, `description`, `overwrite`, `title`, `version` — reparenting is simply not a
|
|
17
|
+
* thing `PUT /folders/{uid}` can do). A declaration whose `parentUid` no longer matches the live
|
|
18
|
+
* folder therefore REFUSES with `GrafanaFolderReparentError` rather than either silently doing
|
|
19
|
+
* nothing (which would lie about the declaration taking effect) or deleting and recreating the
|
|
20
|
+
* folder under the new parent (which — see the ⛔ below — takes every dashboard and alert rule in
|
|
21
|
+
* it with it). Move a folder by hand in Grafana, then adjust the declaration to match.
|
|
22
|
+
*
|
|
23
|
+
* ⛔ DELETE DEFAULTS TO `retain` — DELETING A FOLDER DELETES ITS DASHBOARDS AND ALERT RULES IN
|
|
24
|
+
* GRAFANA, per `deleteFolder`'s own operation description ("along with all dashboards (and their
|
|
25
|
+
* alerts) stored in the folder... cannot be reverted... also deletes all the subfolders"). That
|
|
26
|
+
* is the same class of multi-object cascade `openbao/mount.ts` guards with
|
|
27
|
+
* `defaultRemovalPolicy: 'retain'` ("disabling a mount destroys every secret under it") — VERIFIED
|
|
28
|
+
* there against Alchemy's own `Apply.ts` (not re-verified here; same engine, same option). A
|
|
29
|
+
* stack that explicitly wants the cascade opts in with `.pipe(RemovalPolicy.destroy())` on the
|
|
30
|
+
* declared instance; `destroy` below still implements the real DELETE in full either way (S11).
|
|
31
|
+
* `Grafana.Dashboard` does NOT default to retain — deleting one dashboard is the same
|
|
32
|
+
* single-object blast radius `Grafana.Datasource` already accepts without a retain guard, not
|
|
33
|
+
* this family's multi-object cascade.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ A PROVISIONED FOLDER (`managedBy` set — see provisioned.ts) REFUSES EVERY UPDATE AND DESTROY.
|
|
36
|
+
* `matches` still compares structurally either way, so a mismatched declaration against a
|
|
37
|
+
* provisioned folder shows `update` in a plan (never a silent, permanently-hidden noop) and then
|
|
38
|
+
* fails loudly, by design, when reconcile actually tries to write.
|
|
39
|
+
*/
|
|
40
|
+
import { Resource } from 'alchemy';
|
|
41
|
+
import * as Provider from 'alchemy/Provider';
|
|
42
|
+
import * as grafana from '@distilled.cloud/grafana';
|
|
43
|
+
import * as Data from 'effect/Data';
|
|
44
|
+
import * as Effect from 'effect/Effect';
|
|
45
|
+
import { type GrafanaProvisionedObjectError, refuseIfProvisioned } from './provisioned.ts';
|
|
46
|
+
import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
|
|
47
|
+
|
|
48
|
+
export interface FolderProps {
|
|
49
|
+
uid: string;
|
|
50
|
+
title: string;
|
|
51
|
+
/** Write-only — see the file header. Sent on every create/update that declares it; never diffed. */
|
|
52
|
+
description?: string;
|
|
53
|
+
/** Parent folder UID, for nested folders. Create-only — see the file header. */
|
|
54
|
+
parentUid?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FolderAttributes {
|
|
58
|
+
uid: string;
|
|
59
|
+
id: number;
|
|
60
|
+
title: string;
|
|
61
|
+
parentUid: string;
|
|
62
|
+
/** Non-empty when Grafana reports this folder as externally managed (e.g. provisioning). */
|
|
63
|
+
managedBy: string;
|
|
64
|
+
url: string;
|
|
65
|
+
version: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface GrafanaFolder extends Resource<
|
|
69
|
+
'Grafana.Folder',
|
|
70
|
+
FolderProps,
|
|
71
|
+
FolderAttributes,
|
|
72
|
+
never
|
|
73
|
+
> {}
|
|
74
|
+
|
|
75
|
+
export const GrafanaFolder = Resource<GrafanaFolder>('Grafana.Folder', {
|
|
76
|
+
defaultRemovalPolicy: 'retain',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** A declared `parentUid` disagrees with the live folder's — `updateFolder` cannot reparent. */
|
|
80
|
+
export class GrafanaFolderReparentError extends Data.TaggedError('GrafanaFolderReparentError')<{
|
|
81
|
+
readonly message: string;
|
|
82
|
+
}> {}
|
|
83
|
+
|
|
84
|
+
export const spec: GrafanaSpec<
|
|
85
|
+
FolderProps,
|
|
86
|
+
grafana.Folder,
|
|
87
|
+
FolderAttributes,
|
|
88
|
+
| grafana.CreateFolderError
|
|
89
|
+
| grafana.GetFolderByUIDError
|
|
90
|
+
| grafana.UpdateFolderError
|
|
91
|
+
| grafana.DeleteFolderError
|
|
92
|
+
| GrafanaProvisionedObjectError
|
|
93
|
+
| GrafanaFolderReparentError
|
|
94
|
+
> = {
|
|
95
|
+
attributes: (live) => ({
|
|
96
|
+
id: live.id ?? 0,
|
|
97
|
+
managedBy: live.managedBy ?? '',
|
|
98
|
+
parentUid: live.parentUid ?? '',
|
|
99
|
+
title: live.title ?? '',
|
|
100
|
+
uid: live.uid ?? '',
|
|
101
|
+
url: live.url ?? '',
|
|
102
|
+
version: live.version ?? 0,
|
|
103
|
+
}),
|
|
104
|
+
create: (props) =>
|
|
105
|
+
grafana.createFolder({
|
|
106
|
+
uid: props.uid,
|
|
107
|
+
title: props.title,
|
|
108
|
+
...(props.description === undefined ? {} : { description: props.description }),
|
|
109
|
+
...(props.parentUid === undefined ? {} : { parentUid: props.parentUid }),
|
|
110
|
+
}),
|
|
111
|
+
destroy: (props, live) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
if (live.managedBy) {
|
|
114
|
+
return yield* refuseIfProvisioned('Grafana.Folder', props.uid, live.managedBy);
|
|
115
|
+
}
|
|
116
|
+
yield* grafana.deleteFolder({ folder_uid: props.uid });
|
|
117
|
+
}),
|
|
118
|
+
fetchLive: (props) =>
|
|
119
|
+
grafana
|
|
120
|
+
.getFolderByUID({ folder_uid: props.uid })
|
|
121
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
122
|
+
matches: (attributes, props) =>
|
|
123
|
+
attributes.title === props.title &&
|
|
124
|
+
(props.parentUid === undefined || attributes.parentUid === props.parentUid),
|
|
125
|
+
update: (props, live) =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
if (live.managedBy) {
|
|
128
|
+
return yield* refuseIfProvisioned('Grafana.Folder', props.uid, live.managedBy);
|
|
129
|
+
}
|
|
130
|
+
if (props.parentUid !== undefined && props.parentUid !== (live.parentUid ?? undefined)) {
|
|
131
|
+
return yield* Effect.fail(
|
|
132
|
+
new GrafanaFolderReparentError({
|
|
133
|
+
message:
|
|
134
|
+
`Grafana.Folder '${props.uid}' declares parentUid '${props.parentUid}' but is ` +
|
|
135
|
+
`live under '${live.parentUid ?? '(root)'}'. PUT /folders/{uid} cannot reparent a ` +
|
|
136
|
+
`folder — move it by hand in Grafana, then update the declaration to match.`,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// ⚠️ UNLIKE DASHBOARD.TS's 412, THIS HAS NO VERIFIED CONFLICT SIGNAL. `version` is sent
|
|
141
|
+
// optimistically, but the SDK's own doc comment on `UpdateFolderRequest.version` says
|
|
142
|
+
// "only used by the legacy folder implementation" — on Grafana's newer unified-storage
|
|
143
|
+
// folder backend it is plausibly ignored outright, meaning two concurrent title/description
|
|
144
|
+
// writes could silently last-write-win with no error, unlike a dashboard save. NOT
|
|
145
|
+
// MEASURED against a live instance either way (no live calls in this PR; flagged by an
|
|
146
|
+
// adversarial review) — sending it is harmless (a no-op field on a backend that ignores it)
|
|
147
|
+
// and correct on one that still honors it, so it stays, but do not read its PRESENCE here
|
|
148
|
+
// as proof this resource detects folder update conflicts the way dashboard.ts does.
|
|
149
|
+
return yield* grafana.updateFolder({
|
|
150
|
+
folder_uid: props.uid,
|
|
151
|
+
title: props.title,
|
|
152
|
+
...(props.description === undefined ? {} : { description: props.description }),
|
|
153
|
+
...(live.version === undefined ? {} : { version: live.version }),
|
|
154
|
+
});
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const handlers = grafanaHandlers(spec);
|
|
159
|
+
|
|
160
|
+
export const GrafanaFolderProvider = () =>
|
|
161
|
+
Provider.effect(GrafanaFolder, Effect.succeed(GrafanaFolder.Provider.of(handlers)));
|
package/src/grafana/index.ts
CHANGED
|
@@ -2,15 +2,13 @@
|
|
|
2
2
|
* Grafana providers for Alchemy.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THIS BARREL IS THE PUBLIC API, AND IT IS DELIBERATELY SMALLER THAN THE DIRECTORY. These are
|
|
5
|
-
* the symbols a real stack consumes; the rest of the files (resource.ts's engine,
|
|
6
|
-
* are internals a provider needs but a consumer should not
|
|
5
|
+
* the symbols a real stack consumes; the rest of the files (resource.ts's engine, provisioned.ts,
|
|
6
|
+
* dashboard-model.ts, fake-grafana.ts) are internals a provider needs but a consumer should not
|
|
7
|
+
* depend on.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* docs/upstream-conformance.md recorded is fixed. Building `Grafana.Folder`, `Grafana.Dashboard`,
|
|
12
|
-
* `Grafana.AlertRule` etc. on top of them is follow-up work this PR deliberately does not do —
|
|
13
|
-
* see docs/grafana.md for what changed and what is still open.
|
|
9
|
+
* `Grafana.Datasource`, `Grafana.Folder` and `Grafana.Dashboard` ship here. `Grafana.AlertRule`
|
|
10
|
+
* and friends are still follow-up work on the same now-available SDK operations — see
|
|
11
|
+
* docs/grafana.md and docs/grafana-folder-dashboard.md for what shipped and what is still open.
|
|
14
12
|
*/
|
|
15
13
|
export {
|
|
16
14
|
type DatasourceAttributes,
|
|
@@ -19,5 +17,20 @@ export {
|
|
|
19
17
|
GrafanaDatasourceProvider,
|
|
20
18
|
GrafanaSecretRefUnsetError,
|
|
21
19
|
} from './datasource.ts';
|
|
20
|
+
export {
|
|
21
|
+
type DashboardAttributes,
|
|
22
|
+
type DashboardModel,
|
|
23
|
+
type DashboardProps,
|
|
24
|
+
GrafanaDashboard,
|
|
25
|
+
GrafanaDashboardProvider,
|
|
26
|
+
} from './dashboard.ts';
|
|
27
|
+
export {
|
|
28
|
+
type FolderAttributes,
|
|
29
|
+
type FolderProps,
|
|
30
|
+
GrafanaFolder,
|
|
31
|
+
GrafanaFolderProvider,
|
|
32
|
+
GrafanaFolderReparentError,
|
|
33
|
+
} from './folder.ts';
|
|
34
|
+
export { GrafanaProvisionedObjectError } from './provisioned.ts';
|
|
22
35
|
export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
23
36
|
export { grafanaProviders } from './providers.ts';
|
package/src/grafana/providers.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import * as Layer from 'effect/Layer';
|
|
15
15
|
import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
|
|
16
|
+
import { GrafanaDashboardProvider } from './dashboard.ts';
|
|
16
17
|
import { GrafanaDatasourceProvider } from './datasource.ts';
|
|
18
|
+
import { GrafanaFolderProvider } from './folder.ts';
|
|
17
19
|
|
|
18
20
|
export const grafanaProviders = (target: GrafanaTarget) =>
|
|
19
|
-
|
|
21
|
+
Layer.mergeAll(
|
|
22
|
+
GrafanaDatasourceProvider(),
|
|
23
|
+
GrafanaFolderProvider(),
|
|
24
|
+
GrafanaDashboardProvider(),
|
|
25
|
+
).pipe(Layer.provide(grafanaCredentials(target)));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared refusal for a file-provisioned Grafana object — `Grafana.Folder` and
|
|
3
|
+
* `Grafana.Dashboard` both carry a live signal Grafana itself treats as read-only through the
|
|
4
|
+
* classic write API this family calls: `Folder.managedBy` (non-empty when something other than
|
|
5
|
+
* this API manages the folder — provisioning is the case this house cares about, but the field
|
|
6
|
+
* itself is Grafana's general "who owns this" marker) and `DashboardMeta.provisioned` (paired
|
|
7
|
+
* with `provisionedExternalId`, the file path). A declaration must never attempt a write against
|
|
8
|
+
* either — see docs/grafana-folder-dashboard.md's "provisioned objects" section.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ THE EXACT `managedBy` VALUES ARE INFERRED FROM THE SDK's SCHEMA, NOT MEASURED. No live
|
|
11
|
+
* instance in this house currently has a file-provisioned FOLDER to read (docs/grafana.md:
|
|
12
|
+
* `teslamate-grafana` has none at all; the mini's Grafana provisions dashboards, not folders) and
|
|
13
|
+
* this PR makes no live calls. `Folder.managedBy` and `DashboardMeta.provisioned` are exactly
|
|
14
|
+
* the fields the OpenAPI schema types for this purpose — treat any non-empty `managedBy` as
|
|
15
|
+
* provisioned rather than special-casing specific string values that were never observed live.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ ADOPTING A PROVISIONED OBJECT IS STILL FINE. `read`/`reconcile`'s no-write "already converged"
|
|
18
|
+
* path (resource.ts) never calls `create`/`update`/`destroy` at all when the declaration already
|
|
19
|
+
* matches what is live — only an actual attempted WRITE against a provisioned object is refused,
|
|
20
|
+
* by the two callers of this function inside folder.ts/dashboard.ts's own `update`/`destroy`.
|
|
21
|
+
*/
|
|
22
|
+
import * as Data from 'effect/Data';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
|
|
25
|
+
export class GrafanaProvisionedObjectError extends Data.TaggedError(
|
|
26
|
+
'GrafanaProvisionedObjectError',
|
|
27
|
+
)<{
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}> {}
|
|
30
|
+
|
|
31
|
+
export const refuseIfProvisioned = (
|
|
32
|
+
kind: 'Grafana.Folder' | 'Grafana.Dashboard',
|
|
33
|
+
uid: string,
|
|
34
|
+
owner: string | undefined,
|
|
35
|
+
): Effect.Effect<never, GrafanaProvisionedObjectError> => {
|
|
36
|
+
const ownerClause =
|
|
37
|
+
owner === undefined || owner === ''
|
|
38
|
+
? `(this Grafana instance did not report an owning file)`
|
|
39
|
+
: `by ${owner}`;
|
|
40
|
+
return Effect.fail(
|
|
41
|
+
new GrafanaProvisionedObjectError({
|
|
42
|
+
message:
|
|
43
|
+
`${kind} '${uid}' is file-provisioned ${ownerClause} and read-only through the classic ` +
|
|
44
|
+
`write API this family calls. Edit the provisioning file instead — declaring this ` +
|
|
45
|
+
`object read-only (a declaration matching what is live) is safe and will never reach ` +
|
|
46
|
+
`this refusal.`,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
};
|