@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every Google Workspace Directory object — the same four operations, different
|
|
3
|
+
* distilled call.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ALTERNATIVE IS COPYING THE SAME HANDLERS PER RESOURCE. Groups, group members, domain
|
|
6
|
+
* aliases and org units are all create / read / update / delete against
|
|
7
|
+
* `@distilled.cloud/google-workspace`'s typed `admin_directory_v1` operations. Hand-rolling the
|
|
8
|
+
* fourth copy is where someone drops the read-back or the `isResolved` guard and the plan lies.
|
|
9
|
+
*
|
|
10
|
+
* ★ SO THE FOUR OPERATIONS LIVE HERE, ONCE — mirroring `../forgejo/resource.ts`'s engine exactly.
|
|
11
|
+
* The Directory API addresses every object this family models by a stable key (an email, a
|
|
12
|
+
* domain alias name, an org unit path), so — unlike NetBox's page-then-disambiguate `locateOne`
|
|
13
|
+
* — `fetchLive` is always a plain get-by-key.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the
|
|
16
|
+
* same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs
|
|
17
|
+
* a concrete error union to resolve `"NotFound"` against; a spec's `E` is only concrete at each
|
|
18
|
+
* resource file's own call site.
|
|
19
|
+
*
|
|
20
|
+
* ★ ADOPT IS THE DEFAULT POSTURE, NOT A FLAG. `reconcile` LOCATES before it writes: a group or org
|
|
21
|
+
* unit the Workspace already has is bound, not duplicated — Tim's own domain (schenanigans.com)
|
|
22
|
+
* predates this code. `spec.attributes` always maps the FULL live object, so a bound row reads
|
|
23
|
+
* back byte-identical to what Workspace actually holds — the exact-as-live half of `adopt(true)`;
|
|
24
|
+
* the other half (refusing to bind without it) is upstream's `AdoptPolicy`, unchanged here.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ CREDENTIALS ARE NEVER A PROP (S24/S25). See `credentials.ts` for what a stack passes instead.
|
|
27
|
+
*/
|
|
28
|
+
import { isResolved } from 'alchemy/Diff';
|
|
29
|
+
import type { Input } from 'alchemy/Input';
|
|
30
|
+
import { CredentialsFromEnv } from '@distilled.cloud/google-workspace/Credentials';
|
|
31
|
+
import type { GoogleWorkspaceOpContext } from '@distilled.cloud/google-workspace/Protocol';
|
|
32
|
+
import * as Effect from 'effect/Effect';
|
|
33
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ★ WHAT EVERY HANDLER NEEDS FROM THE CALLER'S RUNTIME, after Credentials are provided below.
|
|
37
|
+
* `GoogleWorkspaceOpContext` is `Credentials | HttpClient.HttpClient`; `googleWorkspaceHandlers`
|
|
38
|
+
* closes over `CredentialsFromEnv` itself (`GOOGLE_ACCESS_TOKEN` / `GOOGLE_PROJECT_ID` — see
|
|
39
|
+
* credentials.ts), so a consuming stack only has to provide `HttpClient.HttpClient`, the same
|
|
40
|
+
* shape every other distilled-backed family in this kit asks for.
|
|
41
|
+
*/
|
|
42
|
+
export type GoogleWorkspaceRequirements = HttpClient.HttpClient;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One Directory object's distilled calls. `Live` is whatever the SDK decodes (a `Group`, a
|
|
46
|
+
* `Member`, a `DomainAlias`, an `OrgUnit`) — never an untyped response body.
|
|
47
|
+
*
|
|
48
|
+
* `E` is left to each resource file to declare (the union of the distilled operations it
|
|
49
|
+
* actually calls), so a call site's precise per-operation error union flows straight through
|
|
50
|
+
* instead of being widened by hand here.
|
|
51
|
+
*/
|
|
52
|
+
export type GoogleWorkspaceSpec<Props extends object, Live, Attributes, E> = {
|
|
53
|
+
/** Already folds a real "absent" outcome — a `catchTag('NotFound', ...)` at the call site. */
|
|
54
|
+
readonly fetchLive: (
|
|
55
|
+
props: Props,
|
|
56
|
+
) => Effect.Effect<Live | undefined, E, GoogleWorkspaceOpContext>;
|
|
57
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
58
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
59
|
+
/** Absent for an existence-only family (none here yet, but `GroupMember` could become one). */
|
|
60
|
+
readonly create?: (props: Props) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
|
|
61
|
+
readonly update?: (
|
|
62
|
+
props: Props,
|
|
63
|
+
live: Live,
|
|
64
|
+
) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
|
|
65
|
+
readonly destroy: (
|
|
66
|
+
props: Props,
|
|
67
|
+
live: Live,
|
|
68
|
+
) => Effect.Effect<unknown, E, GoogleWorkspaceOpContext>;
|
|
69
|
+
/** For the read-back-failure message. */
|
|
70
|
+
readonly describe: (props: Props) => string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const googleWorkspaceOperations = <Props extends object, Live, Attributes, E>(
|
|
74
|
+
spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
|
|
75
|
+
) => {
|
|
76
|
+
const read = (props: Props) =>
|
|
77
|
+
spec
|
|
78
|
+
.fetchLive(props)
|
|
79
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
read,
|
|
83
|
+
|
|
84
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
87
|
+
const live = yield* read(news);
|
|
88
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
89
|
+
if (spec.matches(live, news)) return { action: 'noop' } as const;
|
|
90
|
+
return spec.update === undefined
|
|
91
|
+
? ({ action: 'replace' } as const)
|
|
92
|
+
: ({ action: 'update' } as const);
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
reconcile: (news: Props) =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const before = yield* spec.fetchLive(news);
|
|
98
|
+
if (before === undefined) {
|
|
99
|
+
if (spec.create === undefined) {
|
|
100
|
+
return yield* Effect.die(
|
|
101
|
+
new Error(`${spec.describe(news)}: spec declares no create for a missing object`),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
yield* spec.create(news);
|
|
105
|
+
} else if (spec.update !== undefined) {
|
|
106
|
+
const mapped = spec.attributes(before, news);
|
|
107
|
+
// ⚠️ ADOPTION IS THE `mapped === undefined` CASE TOO: a row this spec cannot map is not
|
|
108
|
+
// a row to PATCH blindly.
|
|
109
|
+
if (mapped !== undefined && !spec.matches(mapped, news)) {
|
|
110
|
+
yield* spec.update(news, before);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const after = yield* read(news);
|
|
114
|
+
if (after === undefined) {
|
|
115
|
+
return yield* Effect.die(
|
|
116
|
+
new Error(
|
|
117
|
+
`${spec.describe(news)}: the write returned no error but the object is still ` +
|
|
118
|
+
'absent. Directory returns the object it wrote — read back rather than trusting ' +
|
|
119
|
+
'the status code.',
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return after;
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
destroy: (olds: Props) =>
|
|
127
|
+
Effect.gen(function* () {
|
|
128
|
+
const live = yield* spec.fetchLive(olds);
|
|
129
|
+
if (live === undefined) return;
|
|
130
|
+
yield* spec.destroy(olds, live);
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The five provider handlers for a spec'd Directory object, wired once.
|
|
137
|
+
*
|
|
138
|
+
* ★ CREDENTIALS ARE PROVIDED HERE, ONCE, NOT BY EVERY CALLER. `CredentialsFromEnv` resolves
|
|
139
|
+
* `GOOGLE_ACCESS_TOKEN` on the calling fiber per request (credentials.ts's own note), so this
|
|
140
|
+
* closure keeps that laziness — nothing is captured at module load.
|
|
141
|
+
*
|
|
142
|
+
* ⛔ IT STOPS AT THE HANDLERS AND DOES NOT RETURN THE LAYER — same reasoning as `forgejoHandlers`.
|
|
143
|
+
*
|
|
144
|
+
* ⚠️ `list` answers empty. The Directory API's list endpoints page the whole domain — every group,
|
|
145
|
+
* every org unit — so adoption stays explicit rather than sweeping (same posture as NetBox and
|
|
146
|
+
* Forgejo; the census handoff script, not this provider, is where a full inventory belongs).
|
|
147
|
+
*/
|
|
148
|
+
export const googleWorkspaceHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
149
|
+
spec: GoogleWorkspaceSpec<Props, Live, Attributes, E>,
|
|
150
|
+
) => {
|
|
151
|
+
const ops = googleWorkspaceOperations(spec);
|
|
152
|
+
const withCredentials = <A>(effect: Effect.Effect<A, E, GoogleWorkspaceOpContext>) =>
|
|
153
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
154
|
+
return {
|
|
155
|
+
list: () => Effect.succeed([]),
|
|
156
|
+
read: ({ olds }: { olds: Props }) => withCredentials(ops.read(olds)),
|
|
157
|
+
diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
158
|
+
withCredentials(ops.diff(news, output)),
|
|
159
|
+
reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
|
|
160
|
+
delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire coercions shared across Google Workspace resources.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ EVERY FIELD ON A GENERATED DIRECTORY TYPE IS `S.optional(...)` (the SDK does not know which
|
|
5
|
+
* fields Google always sends), so a live read is `string | undefined` even for a field the API
|
|
6
|
+
* guarantees in practice. These helpers turn that into the settled value a `matches()` compares
|
|
7
|
+
* against, mirroring `../netbox/values.ts` and `../forgejo/values.ts` for the same reason: a
|
|
8
|
+
* `diff` that compared `undefined` against a declared default would see drift forever.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** `''` for absent — Directory objects that omit a text field mean "no value", not `undefined`. */
|
|
12
|
+
export const text = (value: string | undefined): string => value ?? '';
|
|
13
|
+
|
|
14
|
+
export const bool = (value: boolean | undefined): boolean => value === true;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Wire arrays compared in sorted order. `StringList` (group aliases, org unit paths in a search)
|
|
18
|
+
* decodes to `string[] | undefined`; Directory does not promise an order.
|
|
19
|
+
*/
|
|
20
|
+
export const stringArray = (value: readonly string[] | undefined): string[] =>
|
|
21
|
+
value === undefined ? [] : [...value].toSorted();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every user- and group-facing key in this family is case-insensitive on the wire (Google lower-
|
|
25
|
+
* cases email addresses and org unit path segments internally) but a declaration may be typed in
|
|
26
|
+
* mixed case. Compare through this, never with `===`, or an adopted object drifts on every plan.
|
|
27
|
+
*/
|
|
28
|
+
export const ciEqual = (left: string, right: string): boolean =>
|
|
29
|
+
left.toLowerCase() === right.toLowerCase();
|
|
30
|
+
|
|
31
|
+
/** An org unit path as the API wants it for a path segment: no leading `/`. */
|
|
32
|
+
export const stripLeadingSlash = (path: string): string => path.replace(/^\/+/, '');
|
|
33
|
+
|
|
34
|
+
/** An org unit path as attributes should read it back: exactly one leading `/`. */
|
|
35
|
+
export const withLeadingSlash = (path: string): string =>
|
|
36
|
+
path.startsWith('/') ? path : `/${path}`;
|
|
@@ -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)));
|