@homeflare/alchemy 0.29.1 → 0.31.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 +42 -0
- package/dist/caddy/config-reconcile.d.ts.map +1 -1
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/format-warnings.d.ts +29 -0
- package/dist/caddy/format-warnings.d.ts.map +1 -0
- package/dist/caddy/format.d.ts +50 -0
- package/dist/caddy/format.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +1 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +78 -10
- package/dist/caddy/index.js.map +7 -5
- package/dist/discord/application-command.d.ts +52 -0
- package/dist/discord/application-command.d.ts.map +1 -0
- package/dist/discord/command-form.d.ts +62 -0
- package/dist/discord/command-form.d.ts.map +1 -0
- package/dist/discord/credentials.d.ts +35 -0
- package/dist/discord/credentials.d.ts.map +1 -0
- package/dist/discord/fake-discord.d.ts +44 -0
- package/dist/discord/fake-discord.d.ts.map +1 -0
- package/dist/discord/guild-application-command.d.ts +53 -0
- package/dist/discord/guild-application-command.d.ts.map +1 -0
- package/dist/discord/index.d.ts +12 -0
- package/dist/discord/index.d.ts.map +1 -0
- package/dist/discord/index.js +193 -0
- package/dist/discord/index.js.map +15 -0
- package/dist/discord/providers.d.ts +15 -0
- package/dist/discord/providers.d.ts.map +1 -0
- package/dist/discord/resource.d.ts +75 -0
- package/dist/discord/resource.d.ts.map +1 -0
- package/dist/google-workspace/credentials.d.ts +54 -0
- package/dist/google-workspace/credentials.d.ts.map +1 -0
- package/dist/google-workspace/domain-alias.d.ts +74 -0
- package/dist/google-workspace/domain-alias.d.ts.map +1 -0
- package/dist/google-workspace/fake-google-workspace.d.ts +41 -0
- package/dist/google-workspace/fake-google-workspace.d.ts.map +1 -0
- package/dist/google-workspace/group-member.d.ts +73 -0
- package/dist/google-workspace/group-member.d.ts.map +1 -0
- package/dist/google-workspace/group.d.ts +87 -0
- package/dist/google-workspace/group.d.ts.map +1 -0
- package/dist/google-workspace/index.d.ts +19 -0
- package/dist/google-workspace/index.d.ts.map +1 -0
- package/dist/google-workspace/index.js +283 -0
- package/dist/google-workspace/index.js.map +17 -0
- package/dist/google-workspace/org-unit.d.ts +75 -0
- package/dist/google-workspace/org-unit.d.ts.map +1 -0
- package/dist/google-workspace/providers.d.ts +16 -0
- package/dist/google-workspace/providers.d.ts.map +1 -0
- package/dist/google-workspace/resource.d.ts +80 -0
- package/dist/google-workspace/resource.d.ts.map +1 -0
- package/dist/google-workspace/values.d.ts +28 -0
- package/dist/google-workspace/values.d.ts.map +1 -0
- package/dist/grafana/credentials.d.ts +38 -0
- package/dist/grafana/credentials.d.ts.map +1 -0
- package/dist/grafana/datasource.d.ts +90 -0
- package/dist/grafana/datasource.d.ts.map +1 -0
- package/dist/grafana/fake-grafana.d.ts +48 -0
- package/dist/grafana/fake-grafana.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +17 -0
- package/dist/grafana/index.d.ts.map +1 -0
- package/dist/grafana/index.js +149 -0
- package/dist/grafana/index.js.map +13 -0
- package/dist/grafana/providers.d.ts +17 -0
- package/dist/grafana/providers.d.ts.map +1 -0
- package/dist/grafana/resource.d.ts +68 -0
- package/dist/grafana/resource.d.ts.map +1 -0
- package/docs/caddy-fmt.md +59 -0
- package/docs/caddy.md +7 -0
- package/docs/credentials.md +20 -0
- package/docs/discord.md +129 -0
- package/docs/google-workspace.md +105 -0
- package/docs/grafana.md +102 -0
- package/docs/peers.md +9 -0
- package/docs/upstream-conformance.md +53 -0
- package/package.json +13 -1
- package/src/caddy/config-format-warning.test.ts +91 -0
- package/src/caddy/config-reconcile.ts +6 -0
- package/src/caddy/config.ts +8 -1
- package/src/caddy/format-warnings.test.ts +50 -0
- package/src/caddy/format-warnings.ts +39 -0
- package/src/caddy/format.test.ts +105 -0
- package/src/caddy/format.ts +121 -0
- package/src/caddy/index.ts +7 -0
- package/src/caddy/providers.test.ts +8 -0
- package/src/discord/application-command.test.ts +103 -0
- package/src/discord/application-command.ts +109 -0
- package/src/discord/command-form.test.ts +144 -0
- package/src/discord/command-form.ts +133 -0
- package/src/discord/credentials.ts +36 -0
- package/src/discord/fake-discord.ts +70 -0
- package/src/discord/guild-application-command.test.ts +215 -0
- package/src/discord/guild-application-command.ts +129 -0
- package/src/discord/index.ts +31 -0
- package/src/discord/providers.ts +18 -0
- package/src/discord/resource.ts +164 -0
- package/src/discord/retry.test.ts +74 -0
- package/src/google-workspace/credentials.test.ts +40 -0
- package/src/google-workspace/credentials.ts +75 -0
- package/src/google-workspace/domain-alias.test.ts +69 -0
- package/src/google-workspace/domain-alias.ts +116 -0
- package/src/google-workspace/fake-google-workspace.ts +54 -0
- package/src/google-workspace/group-member.test.ts +91 -0
- package/src/google-workspace/group-member.ts +126 -0
- package/src/google-workspace/group.test.ts +93 -0
- package/src/google-workspace/group.ts +128 -0
- package/src/google-workspace/index.ts +54 -0
- package/src/google-workspace/org-unit.test.ts +81 -0
- package/src/google-workspace/org-unit.ts +143 -0
- package/src/google-workspace/providers.ts +26 -0
- package/src/google-workspace/resource.test.ts +186 -0
- package/src/google-workspace/resource.ts +162 -0
- package/src/google-workspace/values.ts +36 -0
- package/src/grafana/credentials.ts +65 -0
- package/src/grafana/datasource.test.ts +183 -0
- package/src/grafana/datasource.ts +171 -0
- package/src/grafana/fake-grafana.ts +64 -0
- package/src/grafana/index.ts +22 -0
- package/src/grafana/providers.ts +19 -0
- package/src/grafana/resource.ts +120 -0
|
@@ -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
|
+
};
|