@homeflare/alchemy 0.29.0 → 0.30.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.
Files changed (104) hide show
  1. package/README.md +20 -0
  2. package/dist/caddy/config-reconcile.d.ts.map +1 -1
  3. package/dist/caddy/config.d.ts.map +1 -1
  4. package/dist/caddy/format-warnings.d.ts +29 -0
  5. package/dist/caddy/format-warnings.d.ts.map +1 -0
  6. package/dist/caddy/format.d.ts +50 -0
  7. package/dist/caddy/format.d.ts.map +1 -0
  8. package/dist/caddy/index.d.ts +1 -0
  9. package/dist/caddy/index.d.ts.map +1 -1
  10. package/dist/caddy/index.js +78 -10
  11. package/dist/caddy/index.js.map +7 -5
  12. package/dist/github/declare-repo-baseline.d.ts.map +1 -1
  13. package/dist/github/index.js +21 -2
  14. package/dist/github/index.js.map +6 -5
  15. package/dist/github/repo-apply-order-fake-providers.d.ts +48 -0
  16. package/dist/github/repo-apply-order-fake-providers.d.ts.map +1 -0
  17. package/dist/github/repo-apply-order-fake-stack.d.ts +13 -0
  18. package/dist/github/repo-apply-order-fake-stack.d.ts.map +1 -0
  19. package/dist/github/repo-auto-merge-gate.d.ts +54 -0
  20. package/dist/github/repo-auto-merge-gate.d.ts.map +1 -0
  21. package/dist/github/repo-policy.d.ts.map +1 -1
  22. package/dist/google-workspace/credentials.d.ts +54 -0
  23. package/dist/google-workspace/credentials.d.ts.map +1 -0
  24. package/dist/google-workspace/domain-alias.d.ts +74 -0
  25. package/dist/google-workspace/domain-alias.d.ts.map +1 -0
  26. package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
  27. package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
  28. package/dist/google-workspace/group-member.d.ts +73 -0
  29. package/dist/google-workspace/group-member.d.ts.map +1 -0
  30. package/dist/google-workspace/group.d.ts +87 -0
  31. package/dist/google-workspace/group.d.ts.map +1 -0
  32. package/dist/google-workspace/index.d.ts +19 -0
  33. package/dist/google-workspace/index.d.ts.map +1 -0
  34. package/dist/google-workspace/index.js +283 -0
  35. package/dist/google-workspace/index.js.map +17 -0
  36. package/dist/google-workspace/org-unit.d.ts +75 -0
  37. package/dist/google-workspace/org-unit.d.ts.map +1 -0
  38. package/dist/google-workspace/providers.d.ts +16 -0
  39. package/dist/google-workspace/providers.d.ts.map +1 -0
  40. package/dist/google-workspace/resource.d.ts +80 -0
  41. package/dist/google-workspace/resource.d.ts.map +1 -0
  42. package/dist/google-workspace/values.d.ts +28 -0
  43. package/dist/google-workspace/values.d.ts.map +1 -0
  44. package/dist/grafana/credentials.d.ts +38 -0
  45. package/dist/grafana/credentials.d.ts.map +1 -0
  46. package/dist/grafana/datasource.d.ts +90 -0
  47. package/dist/grafana/datasource.d.ts.map +1 -0
  48. package/dist/grafana/fake-grafana.d.ts +48 -0
  49. package/dist/grafana/fake-grafana.d.ts.map +1 -0
  50. package/dist/grafana/index.d.ts +17 -0
  51. package/dist/grafana/index.d.ts.map +1 -0
  52. package/dist/grafana/index.js +149 -0
  53. package/dist/grafana/index.js.map +13 -0
  54. package/dist/grafana/providers.d.ts +17 -0
  55. package/dist/grafana/providers.d.ts.map +1 -0
  56. package/dist/grafana/resource.d.ts +68 -0
  57. package/dist/grafana/resource.d.ts.map +1 -0
  58. package/docs/caddy-fmt.md +59 -0
  59. package/docs/caddy.md +7 -0
  60. package/docs/credentials.md +11 -0
  61. package/docs/google-workspace.md +105 -0
  62. package/docs/grafana.md +102 -0
  63. package/docs/peers.md +9 -0
  64. package/docs/upstream-conformance.md +37 -0
  65. package/package.json +9 -1
  66. package/src/caddy/config-format-warning.test.ts +91 -0
  67. package/src/caddy/config-reconcile.ts +6 -0
  68. package/src/caddy/config.ts +8 -1
  69. package/src/caddy/format-warnings.test.ts +50 -0
  70. package/src/caddy/format-warnings.ts +39 -0
  71. package/src/caddy/format.test.ts +105 -0
  72. package/src/caddy/format.ts +121 -0
  73. package/src/caddy/index.ts +7 -0
  74. package/src/caddy/providers.test.ts +8 -0
  75. package/src/github/declare-repo-baseline-apply-order.test.ts +86 -0
  76. package/src/github/declare-repo-baseline.ts +34 -1
  77. package/src/github/repo-apply-order-fake-providers.ts +119 -0
  78. package/src/github/repo-apply-order-fake-stack.ts +74 -0
  79. package/src/github/repo-auto-merge-gate.ts +57 -0
  80. package/src/github/repo-policy-apply-order.test.ts +72 -0
  81. package/src/github/repo-policy.ts +28 -1
  82. package/src/google-workspace/credentials.test.ts +40 -0
  83. package/src/google-workspace/credentials.ts +75 -0
  84. package/src/google-workspace/domain-alias.test.ts +69 -0
  85. package/src/google-workspace/domain-alias.ts +116 -0
  86. package/src/google-workspace/fake-google-workspace.ts +54 -0
  87. package/src/google-workspace/group-member.test.ts +91 -0
  88. package/src/google-workspace/group-member.ts +126 -0
  89. package/src/google-workspace/group.test.ts +93 -0
  90. package/src/google-workspace/group.ts +128 -0
  91. package/src/google-workspace/index.ts +54 -0
  92. package/src/google-workspace/org-unit.test.ts +81 -0
  93. package/src/google-workspace/org-unit.ts +143 -0
  94. package/src/google-workspace/providers.ts +26 -0
  95. package/src/google-workspace/resource.test.ts +186 -0
  96. package/src/google-workspace/resource.ts +162 -0
  97. package/src/google-workspace/values.ts +36 -0
  98. package/src/grafana/credentials.ts +65 -0
  99. package/src/grafana/datasource.test.ts +183 -0
  100. package/src/grafana/datasource.ts +171 -0
  101. package/src/grafana/fake-grafana.ts +64 -0
  102. package/src/grafana/index.ts +22 -0
  103. package/src/grafana/providers.ts +19 -0
  104. package/src/grafana/resource.ts +120 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Grafana credentials for this family — a URL plus a TOKEN REFERENCE (an env var NAME), never a
3
+ * literal secret. Wraps `@distilled.cloud/grafana`'s own `Credentials` context directly (S23 —
4
+ * reuse the SDK's own client rather than inventing a second one); this file only adds the house's
5
+ * lazy, per-instance resolution on top of it.
6
+ *
7
+ * ★ ONE `GRAFANA_TOKEN`/`GRAFANA_URL` PAIR ISN'T ENOUGH FOR THIS HOUSE. Unlike Forgejo (one
8
+ * instance), the estate runs at least two Grafanas — `grafana.homeflare.dev` and
9
+ * `teslamate-grafana` on CT100, which the unit's own comment says has "Own Access SaaS OIDC
10
+ * client, never grafana.homeflare.dev's". The SDK's `CredentialsFromEnv` reads one fixed pair
11
+ * of names, so a stack declaring against more than one instance needs a target per instance —
12
+ * `grafanaCredentials(target)` below, parameterized the way `litellm/credentials.ts` and
13
+ * `proxmox/credentials.ts` already are in this kit.
14
+ *
15
+ * ⛔ THE TOKEN NEVER TOUCHES A PROP OR AN ATTRIBUTE (S25). `tokenEnv` is a NAME, resolved from
16
+ * `process.env` fresh inside the credentials effect on every request (S24) — a layer built once
17
+ * still re-reads the environment each time an operation runs, the same laziness
18
+ * `litellm/credentials.ts` and the SDK's own `forgejo`/`netbox` `CredentialsFromEnv` have.
19
+ *
20
+ * ⚠️ THE SDK's `Credentials` SERVICE VALUE TYPE IS `Effect.Effect<Config, never, never>` — the
21
+ * error channel is `never` (credentials.d.ts). A missing env var can only surface as a defect
22
+ * here, via `Effect.orDie`, the same trade-off `forgejo/resource.ts` and `netbox/resource.ts`
23
+ * already accept for their own `CredentialsFromEnv`; it is not a choice this file introduces.
24
+ */
25
+ import { type Config, Credentials } from '@distilled.cloud/grafana/Credentials';
26
+ import { ConfigError } from '@distilled.cloud/grafana/Errors';
27
+ import * as Effect from 'effect/Effect';
28
+ import * as Layer from 'effect/Layer';
29
+ import * as Redacted from 'effect/Redacted';
30
+
31
+ export interface GrafanaTarget {
32
+ /** Instance origin, e.g. `https://tesla.homeflare.dev/dash` — never `/api`; the SDK appends it. */
33
+ readonly baseUrl: string;
34
+ /** Env var NAME holding a Grafana service-account token. Never the value itself. */
35
+ readonly tokenEnv: string;
36
+ /** Selects the org a call applies to, via `X-Grafana-Org-Id`. Omit for the default org. */
37
+ readonly orgId?: number | string;
38
+ }
39
+
40
+ const resolve = (target: GrafanaTarget): Effect.Effect<Config, never, never> =>
41
+ Effect.sync(() => process.env[target.tokenEnv]).pipe(
42
+ Effect.flatMap((raw) =>
43
+ raw === undefined || raw.trim() === ''
44
+ ? Effect.fail(
45
+ new ConfigError({
46
+ message:
47
+ `${target.tokenEnv} is not set. Export the Grafana service-account token there ` +
48
+ `— never as an Alchemy prop — before running a stack that declares a resource ` +
49
+ `against ${target.baseUrl}.`,
50
+ }),
51
+ )
52
+ : Effect.succeed({
53
+ apiBaseUrl: target.baseUrl.replace(/\/+$/, ''),
54
+ token: Redacted.make(raw.trim()),
55
+ ...(target.orgId === undefined ? {} : { orgId: target.orgId }),
56
+ } satisfies Config),
57
+ ),
58
+ Effect.orDie,
59
+ );
60
+
61
+ /** The lazy, per-instance layer `grafanaProviders(target)` composes by default. */
62
+ export const grafanaCredentials = (target: GrafanaTarget): Layer.Layer<Credentials> =>
63
+ Layer.succeed(Credentials, resolve(target));
64
+
65
+ export type { Credentials };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `Grafana.Datasource` against a fake Grafana, proving the tagged-error paths, the secure-field
3
+ * reference seam (S25) and the observe-before-write "adopt" posture (S9/S10) — all through the
4
+ * REAL `@distilled.cloud/grafana` protocol (path assembly, JSON decode, status→typed-error
5
+ * matching), not re-implemented by hand. Mirrors `../forgejo/repository.test.ts`.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
10
+ import { grafanaOperations } from './resource.ts';
11
+ import { GrafanaSecretRefUnsetError, spec } from './datasource.ts';
12
+
13
+ const DS_PATH = '/api/datasources/uid/teslamate';
14
+ const CREATE_PATH = '/api/datasources';
15
+
16
+ const liveJson = {
17
+ access: 'proxy',
18
+ id: 7,
19
+ isDefault: true,
20
+ name: 'TeslaMate',
21
+ orgId: 1,
22
+ readOnly: false,
23
+ type: 'postgres',
24
+ uid: 'teslamate',
25
+ url: '127.0.0.1:5432',
26
+ version: 1,
27
+ };
28
+
29
+ const props = { name: 'TeslaMate', type: 'postgres', uid: 'teslamate' };
30
+
31
+ describe('spec.fetchLive', () => {
32
+ test('a live datasource decodes into typed attributes', async () => {
33
+ const fake = fakeGrafana((method, url) =>
34
+ method === 'GET' && url.pathname === DS_PATH
35
+ ? Response.json(liveJson)
36
+ : fakeFailure(404, 'not found'),
37
+ );
38
+ const live = await Effect.runPromise(
39
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
40
+ );
41
+ expect(live?.name).toBe('TeslaMate');
42
+ expect(live !== undefined && spec.attributes(live, props)).toEqual({
43
+ access: 'proxy',
44
+ id: 7,
45
+ isDefault: true,
46
+ name: 'TeslaMate',
47
+ orgId: 1,
48
+ readOnly: false,
49
+ type: 'postgres',
50
+ uid: 'teslamate',
51
+ url: '127.0.0.1:5432',
52
+ version: 1,
53
+ });
54
+ expect(fake.seen).toEqual([{ method: 'GET', path: DS_PATH }]);
55
+ });
56
+
57
+ test('a 404 folds to undefined — the exact idiom this family uses', async () => {
58
+ const fake = fakeGrafana(() => fakeFailure(404, 'Data source not found'));
59
+ const result = await Effect.runPromise(
60
+ spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
61
+ );
62
+ expect(result).toBeUndefined();
63
+ });
64
+
65
+ test('a 401 propagates — Unauthorized is a real failure, never folded to absent', async () => {
66
+ const fake = fakeGrafana(() => fakeFailure(401, 'bad token'));
67
+ const failure = await Effect.runPromise(
68
+ Effect.flip(spec.fetchLive(props).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch)))),
69
+ );
70
+ expect(failure._tag).toBe('Unauthorized');
71
+ });
72
+ });
73
+
74
+ describe('secure field references (S25)', () => {
75
+ const ENV_VAR = 'GRAFANA_FAMILY_TEST_DS_PASSWORD';
76
+
77
+ test('create resolves the env var fresh and sends it as secureJsonData, never as a prop', async () => {
78
+ process.env[ENV_VAR] = 's3cret';
79
+ try {
80
+ const fake = fakeGrafana((method, url) =>
81
+ method === 'POST' && url.pathname === CREATE_PATH
82
+ ? Response.json({ datasource: liveJson, id: 7, message: 'ok', name: 'TeslaMate' })
83
+ : fakeFailure(404, 'unexpected request'),
84
+ );
85
+ await Effect.runPromise(
86
+ spec
87
+ .create({ ...props, secureJsonDataRefs: { password: ENV_VAR } })
88
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
89
+ );
90
+ expect(fake.seen).toEqual([{ method: 'POST', path: CREATE_PATH }]);
91
+ // ⛔ THE POINT OF THIS TEST. Asserting only that a POST landed proves nothing about what it
92
+ // carried — this is where a dropped `secureJsonData` spread would actually be caught.
93
+ expect(fake.bodies[0]).toMatchObject({ secureJsonData: { password: 's3cret' } });
94
+ } finally {
95
+ delete process.env[ENV_VAR];
96
+ }
97
+ });
98
+
99
+ test('create refuses with a typed error when the ref is unset — and never sends the request', async () => {
100
+ const fake = fakeGrafana(() => fakeFailure(500, 'should not be reached'));
101
+ const failure = await Effect.runPromise(
102
+ Effect.flip(
103
+ spec
104
+ .create({ ...props, secureJsonDataRefs: { password: 'DEFINITELY_UNSET_VAR_XYZ' } })
105
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
106
+ ),
107
+ );
108
+ expect(failure).toBeInstanceOf(GrafanaSecretRefUnsetError);
109
+ expect(fake.seen).toEqual([]);
110
+ });
111
+ });
112
+
113
+ describe('reconcile — adopt semantics', () => {
114
+ test('declaring exactly what is live is a noop: no create or update is ever sent', async () => {
115
+ const fake = fakeGrafana((method, url) =>
116
+ method === 'GET' && url.pathname === DS_PATH
117
+ ? Response.json(liveJson)
118
+ : fakeFailure(500, 'reconcile should not write when nothing changed'),
119
+ );
120
+ const ops = grafanaOperations(spec);
121
+ const after = await Effect.runPromise(
122
+ ops
123
+ .reconcile({ ...props, access: 'proxy', isDefault: true })
124
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
125
+ );
126
+ expect(after.uid).toBe('teslamate');
127
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
128
+ });
129
+
130
+ test('a genuinely absent uid is created', async () => {
131
+ // ⚠️ STATEFUL ON PURPOSE. `reconcile` reads back AFTER writing (S9/S10) — a fake that always
132
+ // 404s the GET would make that read-back indistinguishable from the "write lied" defect
133
+ // `resource.ts` raises, so the second GET must see what the POST just created.
134
+ let created = false;
135
+ const newOne = { ...liveJson, uid: 'new-one' };
136
+ const fake = fakeGrafana((method, url) => {
137
+ if (method === 'GET' && url.pathname === '/api/datasources/uid/new-one') {
138
+ return created ? Response.json(newOne) : fakeFailure(404, 'not found');
139
+ }
140
+ if (method === 'POST' && url.pathname === CREATE_PATH) {
141
+ created = true;
142
+ return Response.json({ datasource: newOne, id: 7, message: 'ok', name: 'TeslaMate' });
143
+ }
144
+ return fakeFailure(500, 'unexpected request');
145
+ });
146
+ const ops = grafanaOperations(spec);
147
+ await Effect.runPromise(
148
+ ops
149
+ .reconcile({ ...props, uid: 'new-one' })
150
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
151
+ );
152
+ expect(fake.seen.some((s) => s.method === 'POST' && s.path === CREATE_PATH)).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe('destroy', () => {
157
+ test('deletes a live datasource by uid', async () => {
158
+ const fake = fakeGrafana((method, url) => {
159
+ if (method === 'GET' && url.pathname === DS_PATH) return Response.json(liveJson);
160
+ if (method === 'DELETE' && url.pathname === DS_PATH) return Response.json({ message: 'ok' });
161
+ return fakeFailure(500, 'unexpected request');
162
+ });
163
+ await Effect.runPromise(
164
+ grafanaOperations(spec)
165
+ .destroy(props)
166
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
167
+ );
168
+ expect(fake.seen).toEqual([
169
+ { method: 'GET', path: DS_PATH },
170
+ { method: 'DELETE', path: DS_PATH },
171
+ ]);
172
+ });
173
+
174
+ test('destroying an absent uid sends no DELETE — idempotent (S11)', async () => {
175
+ const fake = fakeGrafana(() => fakeFailure(404, 'not found'));
176
+ await Effect.runPromise(
177
+ grafanaOperations(spec)
178
+ .destroy(props)
179
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
180
+ );
181
+ expect(fake.seen).toEqual([{ method: 'GET', path: DS_PATH }]);
182
+ });
183
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `Grafana.Datasource` — one Grafana data source, keyed by `uid`.
3
+ *
4
+ * ★ WHY `uid` IS REQUIRED, NOT GENERATED. `getDataSourceByUID`/`updateDataSourceByUID`/
5
+ * `deleteDataSourceByUID` are all UID-keyed, and `getDataSources` (list) takes no filter — so
6
+ * locating a datasource this resource didn't create means listing the whole org and matching by
7
+ * name, the same ambiguity netbox/resource.ts's `soleMatch` exists to catch. Requiring `uid` up
8
+ * front (Grafana accepts a caller-chosen one on create — `AddDataSourceRequest.uid`) sidesteps
9
+ * it entirely: `read`/`reconcile`/`destroy` all key on the one field the API itself is keyed on.
10
+ * `teslamate-grafana`'s live datasource (provisioned from a file, not this resource) has a
11
+ * Grafana-generated uid; the census in the handoff script records it so a first declaration can
12
+ * name it explicitly.
13
+ *
14
+ * ⛔ `secureJsonData` (e.g. a datasource password) NEVER APPEARS AS A LITERAL PROP (S25).
15
+ * `secureJsonDataRefs` takes env var NAMES instead; `resolveSecureJsonData` reads them from
16
+ * `process.env` fresh inside `create`/`update`'s own effect, never at plan time and never
17
+ * returned in `attributes` — Grafana's own read API agrees: `DataSource.secureJsonFields` on a
18
+ * GET is a map of booleans (is-it-set), never the value.
19
+ */
20
+ import { Resource } from 'alchemy';
21
+ import * as Provider from 'alchemy/Provider';
22
+ import * as grafana from '@distilled.cloud/grafana';
23
+ import * as Data from 'effect/Data';
24
+ import * as Effect from 'effect/Effect';
25
+ import { type GrafanaSpec, grafanaHandlers } from './resource.ts';
26
+
27
+ export interface DatasourceProps {
28
+ uid: string;
29
+ name: string;
30
+ /** e.g. `postgres`, `prometheus`, `influxdb` — the Grafana plugin id. */
31
+ type: string;
32
+ url?: string;
33
+ access?: 'proxy' | 'direct';
34
+ database?: string;
35
+ user?: string;
36
+ basicAuth?: boolean;
37
+ basicAuthUser?: string;
38
+ isDefault?: boolean;
39
+ withCredentials?: boolean;
40
+ jsonData?: Record<string, unknown>;
41
+ /** Secure field name (e.g. `password`) -> env var NAME holding its value. Never the value. */
42
+ secureJsonDataRefs?: Record<string, string>;
43
+ }
44
+
45
+ export interface DatasourceAttributes {
46
+ uid: string;
47
+ id: number;
48
+ name: string;
49
+ type: string;
50
+ url: string;
51
+ access: string;
52
+ orgId: number;
53
+ isDefault: boolean;
54
+ readOnly: boolean;
55
+ version: number;
56
+ }
57
+
58
+ export interface GrafanaDatasource extends Resource<
59
+ 'Grafana.Datasource',
60
+ DatasourceProps,
61
+ DatasourceAttributes,
62
+ never
63
+ > {}
64
+
65
+ export const GrafanaDatasource = Resource<GrafanaDatasource>('Grafana.Datasource');
66
+
67
+ /** A named secure-field env var is unset — a domain refusal, not a distilled error. */
68
+ export class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{
69
+ readonly message: string;
70
+ }> {}
71
+
72
+ /** Reads every named env var fresh, inside the caller's effect (S24) — never at plan time. */
73
+ const resolveSecureJsonData = (
74
+ refs: Record<string, string> | undefined,
75
+ ): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>
76
+ Effect.gen(function* () {
77
+ if (refs === undefined) return undefined;
78
+ const out: Record<string, string> = {};
79
+ for (const [field, envVar] of Object.entries(refs)) {
80
+ const raw = process.env[envVar];
81
+ if (raw === undefined || raw.trim() === '') {
82
+ return yield* Effect.fail(
83
+ new GrafanaSecretRefUnsetError({
84
+ message:
85
+ `${envVar} is unset. Export the value of secureJsonData.${field} there — ` +
86
+ 'never as an Alchemy prop.',
87
+ }),
88
+ );
89
+ }
90
+ out[field] = raw.trim();
91
+ }
92
+ return out;
93
+ });
94
+
95
+ /** `exactOptionalPropertyTypes` means an undeclared field must be OMITTED, never sent as `undefined`. */
96
+ const fields = (props: DatasourceProps) => ({
97
+ name: props.name,
98
+ type: props.type,
99
+ ...(props.access === undefined ? {} : { access: props.access }),
100
+ ...(props.basicAuth === undefined ? {} : { basicAuth: props.basicAuth }),
101
+ ...(props.basicAuthUser === undefined ? {} : { basicAuthUser: props.basicAuthUser }),
102
+ ...(props.database === undefined ? {} : { database: props.database }),
103
+ ...(props.isDefault === undefined ? {} : { isDefault: props.isDefault }),
104
+ ...(props.jsonData === undefined ? {} : { jsonData: props.jsonData }),
105
+ ...(props.url === undefined ? {} : { url: props.url }),
106
+ ...(props.user === undefined ? {} : { user: props.user }),
107
+ ...(props.withCredentials === undefined ? {} : { withCredentials: props.withCredentials }),
108
+ });
109
+
110
+ export const spec: GrafanaSpec<
111
+ DatasourceProps,
112
+ grafana.DataSource,
113
+ DatasourceAttributes,
114
+ | grafana.AddDataSourceError
115
+ | grafana.GetDataSourceByUIDError
116
+ | grafana.UpdateDataSourceByUIDError
117
+ | grafana.DeleteDataSourceByUIDError
118
+ | GrafanaSecretRefUnsetError
119
+ > = {
120
+ attributes: (live) => ({
121
+ access: live.access ?? '',
122
+ id: live.id ?? 0,
123
+ isDefault: live.isDefault ?? false,
124
+ name: live.name ?? '',
125
+ orgId: live.orgId ?? 0,
126
+ readOnly: live.readOnly ?? false,
127
+ type: live.type ?? '',
128
+ uid: live.uid ?? '',
129
+ url: live.url ?? '',
130
+ version: live.version ?? 0,
131
+ }),
132
+ create: (props) =>
133
+ Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>
134
+ grafana.addDataSource({
135
+ ...fields(props),
136
+ ...(secureJsonData === undefined ? {} : { secureJsonData }),
137
+ uid: props.uid,
138
+ }),
139
+ ),
140
+ destroy: (props) => grafana.deleteDataSourceByUID({ uid: props.uid }),
141
+ fetchLive: (props) =>
142
+ grafana
143
+ .getDataSourceByUID({ uid: props.uid })
144
+ .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
145
+ /**
146
+ * ⛔ AN UNDECLARED FIELD IS NEITHER COMPARED NOR SENT — the same rule every family in this
147
+ * estate follows. `secureJsonDataRefs` is never compared: Grafana never returns the value to
148
+ * diff against, only whether a field is set (`secureJsonFields`), so this resource cannot
149
+ * detect drift in a secret's value — only in whether one is configured at all.
150
+ */
151
+ matches: (attributes, props) =>
152
+ (props.name === undefined || attributes.name === props.name) &&
153
+ (props.type === undefined || attributes.type === props.type) &&
154
+ (props.url === undefined || attributes.url === props.url) &&
155
+ (props.access === undefined || attributes.access === props.access) &&
156
+ (props.isDefault === undefined || attributes.isDefault === props.isDefault),
157
+ update: (props, live) =>
158
+ Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>
159
+ grafana.updateDataSourceByUID({
160
+ ...fields(props),
161
+ ...(secureJsonData === undefined ? {} : { secureJsonData }),
162
+ ...(live.version === undefined ? {} : { version: live.version }),
163
+ uid: props.uid,
164
+ }),
165
+ ),
166
+ };
167
+
168
+ export const handlers = grafanaHandlers(spec);
169
+
170
+ export const GrafanaDatasourceProvider = () =>
171
+ Provider.effect(GrafanaDatasource, Effect.succeed(GrafanaDatasource.Provider.of(handlers)));
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A minimal in-memory Grafana HTTP API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring
4
+ * `../forgejo/fake-forgejo.ts` and `../netbox/fake-netbox.ts`. Tests exercise distilled's REAL
5
+ * path assembly, JSON encode/decode and status→typed-error matching (`404` → `NotFound`, `403` →
6
+ * `Forbidden`, …) — nothing about that matching is re-implemented here, only the wire responses a
7
+ * scenario needs.
8
+ *
9
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
10
+ * `grafana.example.com`, and the token is a placeholder.
11
+ */
12
+ import { type Credentials, fromApiKey } from '@distilled.cloud/grafana/Credentials';
13
+ import * as Layer from 'effect/Layer';
14
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
15
+
16
+ export const FAKE_BASE = 'https://grafana.example.com';
17
+ export const FAKE_TOKEN = 'placeholder-grafana-token';
18
+
19
+ export type Seen = { readonly method: string; readonly path: string };
20
+
21
+ /** Grafana's `{ message?, status? }` failure envelope — matching the SDK's own protocol.ts note. */
22
+ export const fakeFailure = (status: number, message: string) =>
23
+ Response.json({ message }, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature. A test that expects no request at all asserts `seen` is empty.
28
+ *
29
+ * ★ `bodies` IS A SEPARATE, PARALLEL ARRAY (index-matched to `seen`), NOT FOLDED INTO IT — so
30
+ * every existing `expect(fake.seen).toEqual([{method, path}, …])` stays exact rather than
31
+ * growing an unrelated `body` key it would then have to repeat on every entry. A test that
32
+ * cares what was actually sent (not just that a request was) reads `bodies[n]` instead of only
33
+ * checking a request landed — the request landing is not proof of what it carried.
34
+ */
35
+ export const fakeGrafana = (route: (method: string, url: URL, body: unknown) => Response) => {
36
+ const seen: Seen[] = [];
37
+ const bodies: unknown[] = [];
38
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
39
+ const request =
40
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
41
+ const url = new URL(request.url);
42
+ const text = await request.text();
43
+ const body: unknown = text === '' ? undefined : JSON.parse(text);
44
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
45
+ bodies.push(body);
46
+ return route(request.method, url, body);
47
+ }) as typeof globalThis.fetch;
48
+ return { fetch, seen, bodies };
49
+ };
50
+
51
+ /**
52
+ * distilled `Credentials` (via the SDK's own `fromApiKey`) + the real `FetchHttpClient` over the
53
+ * fake — `GrafanaOpContext`, what every resource file's exported `spec.fetchLive`/`create`/
54
+ * `update`/`destroy` needs. Unlike `grafanaHandlers`'s production callers, this never touches
55
+ * `process.env` — `grafanaCredentials` reads `tokenEnv` fresh per call (credentials.ts's own
56
+ * note), which a test-time `process.env` mutation cannot reliably reach either (measured for the
57
+ * same reason in the forgejo/netbox migrations: Effect's default `Config` provider snapshots the
58
+ * environment). Passing an explicit layer exercises exactly the same production code without
59
+ * that seam.
60
+ */
61
+ export const fakeGrafanaLayer = (
62
+ fetchFn: typeof globalThis.fetch,
63
+ creds: Layer.Layer<Credentials> = fromApiKey({ apiBaseUrl: FAKE_BASE, apiKey: FAKE_TOKEN }),
64
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Grafana providers for Alchemy.
3
+ *
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, fake-grafana.ts)
6
+ * are internals a provider needs but a consumer should not depend on.
7
+ *
8
+ * ⛔ ONLY `Grafana.Datasource` SHIPS HERE. `@distilled.cloud/grafana@1.0.0-rc.12` has no folder,
9
+ * dashboard, alert-rule or contact-point CRUD operations — see docs/grafana.md and
10
+ * docs/upstream-conformance.md for the measured gap. Adding those resources now would mean
11
+ * hand-rolling an `HttpClient` client for them, which S23 forbids for a vendor distilled
12
+ * already covers in part; the right fix is upstream, in distilled, not a house workaround here.
13
+ */
14
+ export {
15
+ type DatasourceAttributes,
16
+ type DatasourceProps,
17
+ GrafanaDatasource,
18
+ GrafanaDatasourceProvider,
19
+ GrafanaSecretRefUnsetError,
20
+ } from './datasource.ts';
21
+ export { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
22
+ export { grafanaProviders } from './providers.ts';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The Grafana providers for one instance, with its credentials, as one layer for a stack.
3
+ *
4
+ * Layer.mergeAll(grafanaProviders({ baseUrl: '...', tokenEnv: 'GRAFANA_TOKEN' }), …)
5
+ *
6
+ * ★ ONE CALL PER INSTANCE. A stack declaring resources against both `grafana.homeflare.dev` and
7
+ * `teslamate-grafana` composes `grafanaProviders(targetA)` and `grafanaProviders(targetB)` —
8
+ * each resource type's `Resource` constructor is a single global registration, so the SAME
9
+ * `Grafana.Datasource` type serves both; only the credential layer differs per call.
10
+ * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime, the same as
11
+ * `litellmProviders`/`netboxProviders`-style families in this kit assume; a stack that already
12
+ * deploys anything over HTTP already has one.
13
+ */
14
+ import * as Layer from 'effect/Layer';
15
+ import { type GrafanaTarget, grafanaCredentials } from './credentials.ts';
16
+ import { GrafanaDatasourceProvider } from './datasource.ts';
17
+
18
+ export const grafanaProviders = (target: GrafanaTarget) =>
19
+ GrafanaDatasourceProvider().pipe(Layer.provide(grafanaCredentials(target)));
@@ -0,0 +1,120 @@
1
+ /**
2
+ * One shape for every Grafana object this family declares — mirroring `../forgejo/resource.ts`
3
+ * and `../netbox/resource.ts` exactly, minus the credentials wrapping those two bake in.
4
+ *
5
+ * ★ CREDENTIALS ARE NOT PROVIDED HERE. Forgejo and NetBox each manage exactly one instance for
6
+ * this house, so their engines close over a hardcoded `CredentialsFromEnv`. Grafana does not —
7
+ * see `credentials.ts`'s note on `grafana.homeflare.dev` vs `teslamate-grafana` — so `Credentials`
8
+ * stays a context requirement on every handler here, and `providers.ts`'s `grafanaProviders`
9
+ * supplies it per target with `Layer.provide`, the same composition `litellm/providers.ts` uses.
10
+ *
11
+ * ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
12
+ * same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs
13
+ * a concrete error union to resolve `"NotFound"` against; a spec's `E` is only concrete at each
14
+ * resource file's own call site.
15
+ */
16
+ import { isResolved } from 'alchemy/Diff';
17
+ import type { Input } from 'alchemy/Input';
18
+ import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
19
+ import * as Effect from 'effect/Effect';
20
+
21
+ /**
22
+ * One Grafana object's distilled calls. `Live` is whatever the SDK decodes (a `DataSource`, …) —
23
+ * no untyped `Record<string, unknown>`. `E` is left to each resource file to declare (the union
24
+ * of the distilled operations it actually calls plus any domain refusal, e.g. an unset secret-ref
25
+ * env var), so a call site's precise error union flows straight through instead of being widened
26
+ * by hand here.
27
+ */
28
+ export type GrafanaSpec<Props extends object, Live, Attributes, E> = {
29
+ /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
30
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, GrafanaOpContext>;
31
+ readonly attributes: (live: Live, props: Props) => Attributes | undefined;
32
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
33
+ readonly create: (props: Props) => Effect.Effect<unknown, E, GrafanaOpContext>;
34
+ readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
35
+ readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
36
+ };
37
+
38
+ export const grafanaOperations = <Props extends object, Live, Attributes, E>(
39
+ spec: GrafanaSpec<Props, Live, Attributes, E>,
40
+ ) => {
41
+ const read = (props: Props) =>
42
+ spec
43
+ .fetchLive(props)
44
+ .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
45
+
46
+ return {
47
+ read,
48
+
49
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
50
+ Effect.gen(function* () {
51
+ if (output === undefined || !isResolved(news)) return undefined;
52
+ const live = yield* read(news);
53
+ if (live === undefined) return { action: 'update' } as const;
54
+ if (spec.matches(live, news)) return { action: 'noop' } as const;
55
+ return spec.update === undefined
56
+ ? ({ action: 'replace' } as const)
57
+ : ({ action: 'update' } as const);
58
+ }),
59
+
60
+ /**
61
+ * ★ OBSERVES BEFORE IT WRITES (S9, S10) — THE WHOLE OF "ADOPT" FOR THIS FAMILY. A `uid` this
62
+ * Grafana instance already has is bound and, when it already matches the declaration,
63
+ * left alone; only a genuinely absent `uid` is created. Declaring exactly what is live
64
+ * changes nothing on `reconcile`, the same posture netbox/resource.ts documents as
65
+ * "adopt is the default posture, not a flag".
66
+ */
67
+ reconcile: (news: Props) =>
68
+ Effect.gen(function* () {
69
+ const before = yield* spec.fetchLive(news);
70
+ if (before === undefined) {
71
+ yield* spec.create(news);
72
+ } else if (spec.update !== undefined) {
73
+ const mapped = spec.attributes(before, news);
74
+ if (mapped === undefined || spec.matches(mapped, news)) {
75
+ /* adoption or already converged — no write */
76
+ } else {
77
+ yield* spec.update(news, before);
78
+ }
79
+ }
80
+ const after = yield* read(news);
81
+ if (after === undefined) {
82
+ return yield* Effect.die(
83
+ new Error(
84
+ 'grafanaOperations: the write returned no error but the object is still absent. ' +
85
+ 'Read back rather than trusting the response status.',
86
+ ),
87
+ );
88
+ }
89
+ return after;
90
+ }),
91
+
92
+ destroy: (olds: Props) =>
93
+ Effect.gen(function* () {
94
+ const live = yield* spec.fetchLive(olds);
95
+ if (live === undefined) return;
96
+ yield* spec.destroy(olds, live);
97
+ }),
98
+ };
99
+ };
100
+
101
+ /**
102
+ * The five provider handlers for a spec'd Grafana object, wired once. `Credentials` and
103
+ * `HttpClient.HttpClient` stay ambient context requirements — see the file header.
104
+ *
105
+ * ⚠️ `list` answers empty — an org's full datasource list is not a page this family sweeps for
106
+ * adoption; adoption stays explicit, the same reasoning forgejo's and netbox's engines give.
107
+ */
108
+ export const grafanaHandlers = <Props extends object, Live, Attributes extends object, E>(
109
+ spec: GrafanaSpec<Props, Live, Attributes, E>,
110
+ ) => {
111
+ const ops = grafanaOperations(spec);
112
+ return {
113
+ list: () => Effect.succeed([]),
114
+ read: ({ olds }: { olds: Props }) => ops.read(olds),
115
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
116
+ ops.diff(news, output),
117
+ reconcile: ({ news }: { news: Props }) => ops.reconcile(news),
118
+ delete: ({ olds }: { olds: Props }) => ops.destroy(olds),
119
+ };
120
+ };