@homeflare/alchemy 0.29.1 → 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.
- package/README.md +20 -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/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 +11 -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 +37 -0
- package/package.json +9 -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/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,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
|
+
};
|