@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,149 @@
|
|
|
1
|
+
// src/grafana/datasource.ts
|
|
2
|
+
import { Resource } from "alchemy";
|
|
3
|
+
import * as Provider from "alchemy/Provider";
|
|
4
|
+
import * as grafana from "@distilled.cloud/grafana";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Effect2 from "effect/Effect";
|
|
7
|
+
|
|
8
|
+
// src/grafana/resource.ts
|
|
9
|
+
import { isResolved } from "alchemy/Diff";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
var grafanaOperations = (spec) => {
|
|
12
|
+
const read = (props) => spec.fetchLive(props).pipe(Effect.map((live) => live === undefined ? undefined : spec.attributes(live, props)));
|
|
13
|
+
return {
|
|
14
|
+
read,
|
|
15
|
+
diff: (news, output) => Effect.gen(function* () {
|
|
16
|
+
if (output === undefined || !isResolved(news))
|
|
17
|
+
return;
|
|
18
|
+
const live = yield* read(news);
|
|
19
|
+
if (live === undefined)
|
|
20
|
+
return { action: "update" };
|
|
21
|
+
if (spec.matches(live, news))
|
|
22
|
+
return { action: "noop" };
|
|
23
|
+
return spec.update === undefined ? { action: "replace" } : { action: "update" };
|
|
24
|
+
}),
|
|
25
|
+
reconcile: (news) => Effect.gen(function* () {
|
|
26
|
+
const before = yield* spec.fetchLive(news);
|
|
27
|
+
if (before === undefined) {
|
|
28
|
+
yield* spec.create(news);
|
|
29
|
+
} else if (spec.update !== undefined) {
|
|
30
|
+
const mapped = spec.attributes(before, news);
|
|
31
|
+
if (mapped === undefined || spec.matches(mapped, news)) {} else {
|
|
32
|
+
yield* spec.update(news, before);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const after = yield* read(news);
|
|
36
|
+
if (after === undefined) {
|
|
37
|
+
return yield* Effect.die(new Error("grafanaOperations: the write returned no error but the object is still absent. " + "Read back rather than trusting the response status."));
|
|
38
|
+
}
|
|
39
|
+
return after;
|
|
40
|
+
}),
|
|
41
|
+
destroy: (olds) => Effect.gen(function* () {
|
|
42
|
+
const live = yield* spec.fetchLive(olds);
|
|
43
|
+
if (live === undefined)
|
|
44
|
+
return;
|
|
45
|
+
yield* spec.destroy(olds, live);
|
|
46
|
+
})
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
var grafanaHandlers = (spec) => {
|
|
50
|
+
const ops = grafanaOperations(spec);
|
|
51
|
+
return {
|
|
52
|
+
list: () => Effect.succeed([]),
|
|
53
|
+
read: ({ olds }) => ops.read(olds),
|
|
54
|
+
diff: ({ news, output }) => ops.diff(news, output),
|
|
55
|
+
reconcile: ({ news }) => ops.reconcile(news),
|
|
56
|
+
delete: ({ olds }) => ops.destroy(olds)
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/grafana/datasource.ts
|
|
61
|
+
var GrafanaDatasource = Resource("Grafana.Datasource");
|
|
62
|
+
|
|
63
|
+
class GrafanaSecretRefUnsetError extends Data.TaggedError("GrafanaSecretRefUnsetError") {
|
|
64
|
+
}
|
|
65
|
+
var resolveSecureJsonData = (refs) => Effect2.gen(function* () {
|
|
66
|
+
if (refs === undefined)
|
|
67
|
+
return;
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [field, envVar] of Object.entries(refs)) {
|
|
70
|
+
const raw = process.env[envVar];
|
|
71
|
+
if (raw === undefined || raw.trim() === "") {
|
|
72
|
+
return yield* Effect2.fail(new GrafanaSecretRefUnsetError({
|
|
73
|
+
message: `${envVar} is unset. Export the value of secureJsonData.${field} there — ` + "never as an Alchemy prop."
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
out[field] = raw.trim();
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
});
|
|
80
|
+
var fields = (props) => ({
|
|
81
|
+
name: props.name,
|
|
82
|
+
type: props.type,
|
|
83
|
+
...props.access === undefined ? {} : { access: props.access },
|
|
84
|
+
...props.basicAuth === undefined ? {} : { basicAuth: props.basicAuth },
|
|
85
|
+
...props.basicAuthUser === undefined ? {} : { basicAuthUser: props.basicAuthUser },
|
|
86
|
+
...props.database === undefined ? {} : { database: props.database },
|
|
87
|
+
...props.isDefault === undefined ? {} : { isDefault: props.isDefault },
|
|
88
|
+
...props.jsonData === undefined ? {} : { jsonData: props.jsonData },
|
|
89
|
+
...props.url === undefined ? {} : { url: props.url },
|
|
90
|
+
...props.user === undefined ? {} : { user: props.user },
|
|
91
|
+
...props.withCredentials === undefined ? {} : { withCredentials: props.withCredentials }
|
|
92
|
+
});
|
|
93
|
+
var spec = {
|
|
94
|
+
attributes: (live) => ({
|
|
95
|
+
access: live.access ?? "",
|
|
96
|
+
id: live.id ?? 0,
|
|
97
|
+
isDefault: live.isDefault ?? false,
|
|
98
|
+
name: live.name ?? "",
|
|
99
|
+
orgId: live.orgId ?? 0,
|
|
100
|
+
readOnly: live.readOnly ?? false,
|
|
101
|
+
type: live.type ?? "",
|
|
102
|
+
uid: live.uid ?? "",
|
|
103
|
+
url: live.url ?? "",
|
|
104
|
+
version: live.version ?? 0
|
|
105
|
+
}),
|
|
106
|
+
create: (props) => Effect2.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) => grafana.addDataSource({
|
|
107
|
+
...fields(props),
|
|
108
|
+
...secureJsonData === undefined ? {} : { secureJsonData },
|
|
109
|
+
uid: props.uid
|
|
110
|
+
})),
|
|
111
|
+
destroy: (props) => grafana.deleteDataSourceByUID({ uid: props.uid }),
|
|
112
|
+
fetchLive: (props) => grafana.getDataSourceByUID({ uid: props.uid }).pipe(Effect2.catchTag("NotFound", () => Effect2.succeed(undefined))),
|
|
113
|
+
matches: (attributes, props) => (props.name === undefined || attributes.name === props.name) && (props.type === undefined || attributes.type === props.type) && (props.url === undefined || attributes.url === props.url) && (props.access === undefined || attributes.access === props.access) && (props.isDefault === undefined || attributes.isDefault === props.isDefault),
|
|
114
|
+
update: (props, live) => Effect2.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) => grafana.updateDataSourceByUID({
|
|
115
|
+
...fields(props),
|
|
116
|
+
...secureJsonData === undefined ? {} : { secureJsonData },
|
|
117
|
+
...live.version === undefined ? {} : { version: live.version },
|
|
118
|
+
uid: props.uid
|
|
119
|
+
}))
|
|
120
|
+
};
|
|
121
|
+
var handlers = grafanaHandlers(spec);
|
|
122
|
+
var GrafanaDatasourceProvider = () => Provider.effect(GrafanaDatasource, Effect2.succeed(GrafanaDatasource.Provider.of(handlers)));
|
|
123
|
+
// src/grafana/credentials.ts
|
|
124
|
+
import { Credentials } from "@distilled.cloud/grafana/Credentials";
|
|
125
|
+
import { ConfigError } from "@distilled.cloud/grafana/Errors";
|
|
126
|
+
import * as Effect3 from "effect/Effect";
|
|
127
|
+
import * as Layer from "effect/Layer";
|
|
128
|
+
import * as Redacted from "effect/Redacted";
|
|
129
|
+
var resolve = (target) => Effect3.sync(() => process.env[target.tokenEnv]).pipe(Effect3.flatMap((raw) => raw === undefined || raw.trim() === "" ? Effect3.fail(new ConfigError({
|
|
130
|
+
message: `${target.tokenEnv} is not set. Export the Grafana service-account token there ` + `— never as an Alchemy prop — before running a stack that declares a resource ` + `against ${target.baseUrl}.`
|
|
131
|
+
})) : Effect3.succeed({
|
|
132
|
+
apiBaseUrl: target.baseUrl.replace(/\/+$/, ""),
|
|
133
|
+
token: Redacted.make(raw.trim()),
|
|
134
|
+
...target.orgId === undefined ? {} : { orgId: target.orgId }
|
|
135
|
+
})), Effect3.orDie);
|
|
136
|
+
var grafanaCredentials = (target) => Layer.succeed(Credentials, resolve(target));
|
|
137
|
+
// src/grafana/providers.ts
|
|
138
|
+
import * as Layer2 from "effect/Layer";
|
|
139
|
+
var grafanaProviders = (target) => GrafanaDatasourceProvider().pipe(Layer2.provide(grafanaCredentials(target)));
|
|
140
|
+
export {
|
|
141
|
+
GrafanaDatasource,
|
|
142
|
+
GrafanaDatasourceProvider,
|
|
143
|
+
GrafanaSecretRefUnsetError,
|
|
144
|
+
grafanaCredentials,
|
|
145
|
+
grafanaProviders
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
//# debugId=DA921F3915B68A6164756E2164756E21
|
|
149
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/grafana/datasource.ts", "../src/grafana/resource.ts", "../src/grafana/credentials.ts", "../src/grafana/providers.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * `Grafana.Datasource` — one Grafana data source, keyed by `uid`.\n *\n * ★ WHY `uid` IS REQUIRED, NOT GENERATED. `getDataSourceByUID`/`updateDataSourceByUID`/\n * `deleteDataSourceByUID` are all UID-keyed, and `getDataSources` (list) takes no filter — so\n * locating a datasource this resource didn't create means listing the whole org and matching by\n * name, the same ambiguity netbox/resource.ts's `soleMatch` exists to catch. Requiring `uid` up\n * front (Grafana accepts a caller-chosen one on create — `AddDataSourceRequest.uid`) sidesteps\n * it entirely: `read`/`reconcile`/`destroy` all key on the one field the API itself is keyed on.\n * `teslamate-grafana`'s live datasource (provisioned from a file, not this resource) has a\n * Grafana-generated uid; the census in the handoff script records it so a first declaration can\n * name it explicitly.\n *\n * ⛔ `secureJsonData` (e.g. a datasource password) NEVER APPEARS AS A LITERAL PROP (S25).\n * `secureJsonDataRefs` takes env var NAMES instead; `resolveSecureJsonData` reads them from\n * `process.env` fresh inside `create`/`update`'s own effect, never at plan time and never\n * returned in `attributes` — Grafana's own read API agrees: `DataSource.secureJsonFields` on a\n * GET is a map of booleans (is-it-set), never the value.\n */\nimport { Resource } from 'alchemy';\nimport * as Provider from 'alchemy/Provider';\nimport * as grafana from '@distilled.cloud/grafana';\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\nimport { type GrafanaSpec, grafanaHandlers } from './resource.ts';\n\nexport interface DatasourceProps {\n uid: string;\n name: string;\n /** e.g. `postgres`, `prometheus`, `influxdb` — the Grafana plugin id. */\n type: string;\n url?: string;\n access?: 'proxy' | 'direct';\n database?: string;\n user?: string;\n basicAuth?: boolean;\n basicAuthUser?: string;\n isDefault?: boolean;\n withCredentials?: boolean;\n jsonData?: Record<string, unknown>;\n /** Secure field name (e.g. `password`) -> env var NAME holding its value. Never the value. */\n secureJsonDataRefs?: Record<string, string>;\n}\n\nexport interface DatasourceAttributes {\n uid: string;\n id: number;\n name: string;\n type: string;\n url: string;\n access: string;\n orgId: number;\n isDefault: boolean;\n readOnly: boolean;\n version: number;\n}\n\nexport interface GrafanaDatasource extends Resource<\n 'Grafana.Datasource',\n DatasourceProps,\n DatasourceAttributes,\n never\n> {}\n\nexport const GrafanaDatasource = Resource<GrafanaDatasource>('Grafana.Datasource');\n\n/** A named secure-field env var is unset — a domain refusal, not a distilled error. */\nexport class GrafanaSecretRefUnsetError extends Data.TaggedError('GrafanaSecretRefUnsetError')<{\n readonly message: string;\n}> {}\n\n/** Reads every named env var fresh, inside the caller's effect (S24) — never at plan time. */\nconst resolveSecureJsonData = (\n refs: Record<string, string> | undefined,\n): Effect.Effect<Record<string, string> | undefined, GrafanaSecretRefUnsetError> =>\n Effect.gen(function* () {\n if (refs === undefined) return undefined;\n const out: Record<string, string> = {};\n for (const [field, envVar] of Object.entries(refs)) {\n const raw = process.env[envVar];\n if (raw === undefined || raw.trim() === '') {\n return yield* Effect.fail(\n new GrafanaSecretRefUnsetError({\n message:\n `${envVar} is unset. Export the value of secureJsonData.${field} there — ` +\n 'never as an Alchemy prop.',\n }),\n );\n }\n out[field] = raw.trim();\n }\n return out;\n });\n\n/** `exactOptionalPropertyTypes` means an undeclared field must be OMITTED, never sent as `undefined`. */\nconst fields = (props: DatasourceProps) => ({\n name: props.name,\n type: props.type,\n ...(props.access === undefined ? {} : { access: props.access }),\n ...(props.basicAuth === undefined ? {} : { basicAuth: props.basicAuth }),\n ...(props.basicAuthUser === undefined ? {} : { basicAuthUser: props.basicAuthUser }),\n ...(props.database === undefined ? {} : { database: props.database }),\n ...(props.isDefault === undefined ? {} : { isDefault: props.isDefault }),\n ...(props.jsonData === undefined ? {} : { jsonData: props.jsonData }),\n ...(props.url === undefined ? {} : { url: props.url }),\n ...(props.user === undefined ? {} : { user: props.user }),\n ...(props.withCredentials === undefined ? {} : { withCredentials: props.withCredentials }),\n});\n\nexport const spec: GrafanaSpec<\n DatasourceProps,\n grafana.DataSource,\n DatasourceAttributes,\n | grafana.AddDataSourceError\n | grafana.GetDataSourceByUIDError\n | grafana.UpdateDataSourceByUIDError\n | grafana.DeleteDataSourceByUIDError\n | GrafanaSecretRefUnsetError\n> = {\n attributes: (live) => ({\n access: live.access ?? '',\n id: live.id ?? 0,\n isDefault: live.isDefault ?? false,\n name: live.name ?? '',\n orgId: live.orgId ?? 0,\n readOnly: live.readOnly ?? false,\n type: live.type ?? '',\n uid: live.uid ?? '',\n url: live.url ?? '',\n version: live.version ?? 0,\n }),\n create: (props) =>\n Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>\n grafana.addDataSource({\n ...fields(props),\n ...(secureJsonData === undefined ? {} : { secureJsonData }),\n uid: props.uid,\n }),\n ),\n destroy: (props) => grafana.deleteDataSourceByUID({ uid: props.uid }),\n fetchLive: (props) =>\n grafana\n .getDataSourceByUID({ uid: props.uid })\n .pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),\n /**\n * ⛔ AN UNDECLARED FIELD IS NEITHER COMPARED NOR SENT — the same rule every family in this\n * estate follows. `secureJsonDataRefs` is never compared: Grafana never returns the value to\n * diff against, only whether a field is set (`secureJsonFields`), so this resource cannot\n * detect drift in a secret's value — only in whether one is configured at all.\n */\n matches: (attributes, props) =>\n (props.name === undefined || attributes.name === props.name) &&\n (props.type === undefined || attributes.type === props.type) &&\n (props.url === undefined || attributes.url === props.url) &&\n (props.access === undefined || attributes.access === props.access) &&\n (props.isDefault === undefined || attributes.isDefault === props.isDefault),\n update: (props, live) =>\n Effect.flatMap(resolveSecureJsonData(props.secureJsonDataRefs), (secureJsonData) =>\n grafana.updateDataSourceByUID({\n ...fields(props),\n ...(secureJsonData === undefined ? {} : { secureJsonData }),\n ...(live.version === undefined ? {} : { version: live.version }),\n uid: props.uid,\n }),\n ),\n};\n\nexport const handlers = grafanaHandlers(spec);\n\nexport const GrafanaDatasourceProvider = () =>\n Provider.effect(GrafanaDatasource, Effect.succeed(GrafanaDatasource.Provider.of(handlers)));\n",
|
|
6
|
+
"/**\n * One shape for every Grafana object this family declares — mirroring `../forgejo/resource.ts`\n * and `../netbox/resource.ts` exactly, minus the credentials wrapping those two bake in.\n *\n * ★ CREDENTIALS ARE NOT PROVIDED HERE. Forgejo and NetBox each manage exactly one instance for\n * this house, so their engines close over a hardcoded `CredentialsFromEnv`. Grafana does not —\n * see `credentials.ts`'s note on `grafana.homeflare.dev` vs `teslamate-grafana` — so `Credentials`\n * stays a context requirement on every handler here, and `providers.ts`'s `grafanaProviders`\n * supplies it per target with `Layer.provide`, the same composition `litellm/providers.ts` uses.\n *\n * ⚠️ `NotFound` IS FOLDED TO \"ABSENT\" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE — the\n * same split forgejo's and netbox's engines use. `Effect.catchTag`'s tag-literal inference needs\n * a concrete error union to resolve `\"NotFound\"` against; a spec's `E` is only concrete at each\n * resource file's own call site.\n */\nimport { isResolved } from 'alchemy/Diff';\nimport type { Input } from 'alchemy/Input';\nimport type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';\nimport * as Effect from 'effect/Effect';\n\n/**\n * One Grafana object's distilled calls. `Live` is whatever the SDK decodes (a `DataSource`, …) —\n * no untyped `Record<string, unknown>`. `E` is left to each resource file to declare (the union\n * of the distilled operations it actually calls plus any domain refusal, e.g. an unset secret-ref\n * env var), so a call site's precise error union flows straight through instead of being widened\n * by hand here.\n */\nexport type GrafanaSpec<Props extends object, Live, Attributes, E> = {\n /** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */\n readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, GrafanaOpContext>;\n readonly attributes: (live: Live, props: Props) => Attributes | undefined;\n readonly matches: (attributes: Attributes, props: Props) => boolean;\n readonly create: (props: Props) => Effect.Effect<unknown, E, GrafanaOpContext>;\n readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;\n readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;\n};\n\nexport const grafanaOperations = <Props extends object, Live, Attributes, E>(\n spec: GrafanaSpec<Props, Live, Attributes, E>,\n) => {\n const read = (props: Props) =>\n spec\n .fetchLive(props)\n .pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));\n\n return {\n read,\n\n diff: (news: Input<Props>, output: Attributes | undefined) =>\n Effect.gen(function* () {\n if (output === undefined || !isResolved(news)) return undefined;\n const live = yield* read(news);\n if (live === undefined) return { action: 'update' } as const;\n if (spec.matches(live, news)) return { action: 'noop' } as const;\n return spec.update === undefined\n ? ({ action: 'replace' } as const)\n : ({ action: 'update' } as const);\n }),\n\n /**\n * ★ OBSERVES BEFORE IT WRITES (S9, S10) — THE WHOLE OF \"ADOPT\" FOR THIS FAMILY. A `uid` this\n * Grafana instance already has is bound and, when it already matches the declaration,\n * left alone; only a genuinely absent `uid` is created. Declaring exactly what is live\n * changes nothing on `reconcile`, the same posture netbox/resource.ts documents as\n * \"adopt is the default posture, not a flag\".\n */\n reconcile: (news: Props) =>\n Effect.gen(function* () {\n const before = yield* spec.fetchLive(news);\n if (before === undefined) {\n yield* spec.create(news);\n } else if (spec.update !== undefined) {\n const mapped = spec.attributes(before, news);\n if (mapped === undefined || spec.matches(mapped, news)) {\n /* adoption or already converged — no write */\n } else {\n yield* spec.update(news, before);\n }\n }\n const after = yield* read(news);\n if (after === undefined) {\n return yield* Effect.die(\n new Error(\n 'grafanaOperations: the write returned no error but the object is still absent. ' +\n 'Read back rather than trusting the response status.',\n ),\n );\n }\n return after;\n }),\n\n destroy: (olds: Props) =>\n Effect.gen(function* () {\n const live = yield* spec.fetchLive(olds);\n if (live === undefined) return;\n yield* spec.destroy(olds, live);\n }),\n };\n};\n\n/**\n * The five provider handlers for a spec'd Grafana object, wired once. `Credentials` and\n * `HttpClient.HttpClient` stay ambient context requirements — see the file header.\n *\n * ⚠️ `list` answers empty — an org's full datasource list is not a page this family sweeps for\n * adoption; adoption stays explicit, the same reasoning forgejo's and netbox's engines give.\n */\nexport const grafanaHandlers = <Props extends object, Live, Attributes extends object, E>(\n spec: GrafanaSpec<Props, Live, Attributes, E>,\n) => {\n const ops = grafanaOperations(spec);\n return {\n list: () => Effect.succeed([]),\n read: ({ olds }: { olds: Props }) => ops.read(olds),\n diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>\n ops.diff(news, output),\n reconcile: ({ news }: { news: Props }) => ops.reconcile(news),\n delete: ({ olds }: { olds: Props }) => ops.destroy(olds),\n };\n};\n",
|
|
7
|
+
"/**\n * Grafana credentials for this family — a URL plus a TOKEN REFERENCE (an env var NAME), never a\n * literal secret. Wraps `@distilled.cloud/grafana`'s own `Credentials` context directly (S23 —\n * reuse the SDK's own client rather than inventing a second one); this file only adds the house's\n * lazy, per-instance resolution on top of it.\n *\n * ★ ONE `GRAFANA_TOKEN`/`GRAFANA_URL` PAIR ISN'T ENOUGH FOR THIS HOUSE. Unlike Forgejo (one\n * instance), the estate runs at least two Grafanas — `grafana.homeflare.dev` and\n * `teslamate-grafana` on CT100, which the unit's own comment says has \"Own Access SaaS OIDC\n * client, never grafana.homeflare.dev's\". The SDK's `CredentialsFromEnv` reads one fixed pair\n * of names, so a stack declaring against more than one instance needs a target per instance —\n * `grafanaCredentials(target)` below, parameterized the way `litellm/credentials.ts` and\n * `proxmox/credentials.ts` already are in this kit.\n *\n * ⛔ THE TOKEN NEVER TOUCHES A PROP OR AN ATTRIBUTE (S25). `tokenEnv` is a NAME, resolved from\n * `process.env` fresh inside the credentials effect on every request (S24) — a layer built once\n * still re-reads the environment each time an operation runs, the same laziness\n * `litellm/credentials.ts` and the SDK's own `forgejo`/`netbox` `CredentialsFromEnv` have.\n *\n * ⚠️ THE SDK's `Credentials` SERVICE VALUE TYPE IS `Effect.Effect<Config, never, never>` — the\n * error channel is `never` (credentials.d.ts). A missing env var can only surface as a defect\n * here, via `Effect.orDie`, the same trade-off `forgejo/resource.ts` and `netbox/resource.ts`\n * already accept for their own `CredentialsFromEnv`; it is not a choice this file introduces.\n */\nimport { type Config, Credentials } from '@distilled.cloud/grafana/Credentials';\nimport { ConfigError } from '@distilled.cloud/grafana/Errors';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\nimport * as Redacted from 'effect/Redacted';\n\nexport interface GrafanaTarget {\n /** Instance origin, e.g. `https://tesla.homeflare.dev/dash` — never `/api`; the SDK appends it. */\n readonly baseUrl: string;\n /** Env var NAME holding a Grafana service-account token. Never the value itself. */\n readonly tokenEnv: string;\n /** Selects the org a call applies to, via `X-Grafana-Org-Id`. Omit for the default org. */\n readonly orgId?: number | string;\n}\n\nconst resolve = (target: GrafanaTarget): Effect.Effect<Config, never, never> =>\n Effect.sync(() => process.env[target.tokenEnv]).pipe(\n Effect.flatMap((raw) =>\n raw === undefined || raw.trim() === ''\n ? Effect.fail(\n new ConfigError({\n message:\n `${target.tokenEnv} is not set. Export the Grafana service-account token there ` +\n `— never as an Alchemy prop — before running a stack that declares a resource ` +\n `against ${target.baseUrl}.`,\n }),\n )\n : Effect.succeed({\n apiBaseUrl: target.baseUrl.replace(/\\/+$/, ''),\n token: Redacted.make(raw.trim()),\n ...(target.orgId === undefined ? {} : { orgId: target.orgId }),\n } satisfies Config),\n ),\n Effect.orDie,\n );\n\n/** The lazy, per-instance layer `grafanaProviders(target)` composes by default. */\nexport const grafanaCredentials = (target: GrafanaTarget): Layer.Layer<Credentials> =>\n Layer.succeed(Credentials, resolve(target));\n\nexport type { Credentials };\n",
|
|
8
|
+
"/**\n * The Grafana providers for one instance, with its credentials, as one layer for a stack.\n *\n * Layer.mergeAll(grafanaProviders({ baseUrl: '...', tokenEnv: 'GRAFANA_TOKEN' }), …)\n *\n * ★ ONE CALL PER INSTANCE. A stack declaring resources against both `grafana.homeflare.dev` and\n * `teslamate-grafana` composes `grafanaProviders(targetA)` and `grafanaProviders(targetB)` —\n * each resource type's `Resource` constructor is a single global registration, so the SAME\n * `Grafana.Datasource` type serves both; only the credential layer differs per call.\n * ⚠️ `HttpClient` is NOT provided here. It is ambient in every Alchemy runtime, the same as\n * `litellmProviders`/`netboxProviders`-style families in this kit assume; a stack that already\n * deploys anything over HTTP already has one.\n */\nimport * as Layer from 'effect/Layer';\nimport { type GrafanaTarget, grafanaCredentials } from './credentials.ts';\nimport { GrafanaDatasourceProvider } from './datasource.ts';\n\nexport const grafanaProviders = (target: GrafanaTarget) =>\n GrafanaDatasourceProvider().pipe(Layer.provide(grafanaCredentials(target)));\n"
|
|
9
|
+
],
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "DA921F3915B68A6164756E2164756E21",
|
|
12
|
+
"names": []
|
|
13
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 } from './credentials.ts';
|
|
16
|
+
export declare const grafanaProviders: (target: GrafanaTarget) => Layer.Layer<import("alchemy").Provider<import("./datasource.ts").GrafanaDatasource>, never, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
17
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.d.ts","sourceRoot":"","sources":["../../src/grafana/providers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AAG1E,eAAO,MAAM,gBAAgB,WAAY,aAAa,sJACuB,CAAC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Input } from 'alchemy/Input';
|
|
2
|
+
import type { GrafanaOpContext } from '@distilled.cloud/grafana/Protocol';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
/**
|
|
5
|
+
* One Grafana object's distilled calls. `Live` is whatever the SDK decodes (a `DataSource`, …) —
|
|
6
|
+
* no untyped `Record<string, unknown>`. `E` is left to each resource file to declare (the union
|
|
7
|
+
* of the distilled operations it actually calls plus any domain refusal, e.g. an unset secret-ref
|
|
8
|
+
* env var), so a call site's precise error union flows straight through instead of being widened
|
|
9
|
+
* by hand here.
|
|
10
|
+
*/
|
|
11
|
+
export type GrafanaSpec<Props extends object, Live, Attributes, E> = {
|
|
12
|
+
/** Already folds the SDK's `NotFound` to `undefined` via its own `catchTag` — see the note above. */
|
|
13
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, GrafanaOpContext>;
|
|
14
|
+
readonly attributes: (live: Live, props: Props) => Attributes | undefined;
|
|
15
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
16
|
+
readonly create: (props: Props) => Effect.Effect<unknown, E, GrafanaOpContext>;
|
|
17
|
+
readonly update?: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
|
|
18
|
+
readonly destroy: (props: Props, live: Live) => Effect.Effect<unknown, E, GrafanaOpContext>;
|
|
19
|
+
};
|
|
20
|
+
export declare const grafanaOperations: <Props extends object, Live, Attributes, E>(spec: GrafanaSpec<Props, Live, Attributes, E>) => {
|
|
21
|
+
read: (props: Props) => Effect.Effect<Attributes | undefined, E, GrafanaOpContext>;
|
|
22
|
+
diff: (news: Input<Props>, output: Attributes | undefined) => Effect.Effect<{
|
|
23
|
+
readonly action: 'update';
|
|
24
|
+
} | {
|
|
25
|
+
readonly action: 'noop';
|
|
26
|
+
} | {
|
|
27
|
+
readonly action: 'replace';
|
|
28
|
+
} | undefined, E, GrafanaOpContext>;
|
|
29
|
+
/**
|
|
30
|
+
* ★ OBSERVES BEFORE IT WRITES (S9, S10) — THE WHOLE OF "ADOPT" FOR THIS FAMILY. A `uid` this
|
|
31
|
+
* Grafana instance already has is bound and, when it already matches the declaration,
|
|
32
|
+
* left alone; only a genuinely absent `uid` is created. Declaring exactly what is live
|
|
33
|
+
* changes nothing on `reconcile`, the same posture netbox/resource.ts documents as
|
|
34
|
+
* "adopt is the default posture, not a flag".
|
|
35
|
+
*/
|
|
36
|
+
reconcile: (news: Props) => Effect.Effect<Attributes & ({} | null), E, GrafanaOpContext>;
|
|
37
|
+
destroy: (olds: Props) => Effect.Effect<void, E, GrafanaOpContext>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The five provider handlers for a spec'd Grafana object, wired once. `Credentials` and
|
|
41
|
+
* `HttpClient.HttpClient` stay ambient context requirements — see the file header.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ `list` answers empty — an org's full datasource list is not a page this family sweeps for
|
|
44
|
+
* adoption; adoption stays explicit, the same reasoning forgejo's and netbox's engines give.
|
|
45
|
+
*/
|
|
46
|
+
export declare const grafanaHandlers: <Props extends object, Live, Attributes extends object, E>(spec: GrafanaSpec<Props, Live, Attributes, E>) => {
|
|
47
|
+
list: () => Effect.Effect<never[], never, never>;
|
|
48
|
+
read: ({ olds }: {
|
|
49
|
+
olds: Props;
|
|
50
|
+
}) => Effect.Effect<Attributes | undefined, E, GrafanaOpContext>;
|
|
51
|
+
diff: ({ news, output }: {
|
|
52
|
+
news: Input<Props>;
|
|
53
|
+
output: Attributes | undefined;
|
|
54
|
+
}) => Effect.Effect<{
|
|
55
|
+
readonly action: 'update';
|
|
56
|
+
} | {
|
|
57
|
+
readonly action: 'noop';
|
|
58
|
+
} | {
|
|
59
|
+
readonly action: 'replace';
|
|
60
|
+
} | undefined, E, GrafanaOpContext>;
|
|
61
|
+
reconcile: ({ news }: {
|
|
62
|
+
news: Props;
|
|
63
|
+
}) => Effect.Effect<Attributes, E, GrafanaOpContext>;
|
|
64
|
+
delete: ({ olds }: {
|
|
65
|
+
olds: Props;
|
|
66
|
+
}) => Effect.Effect<void, E, GrafanaOpContext>;
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=resource.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/grafana/resource.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,IAAI;IACnE,qGAAqG;IACrG,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC3F,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,UAAU,GAAG,SAAS,CAAC;IAC1E,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;IAC5F,QAAQ,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,EAAE,gBAAgB,CAAC,CAAC;CAC7F,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,QACnE,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;kBAExB,KAAK;iBAQX,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBACF,MAAM;;yBAEtC,SAAS;;IAI5B;;;;;;OAMG;sBACe,KAAK;oBAyBP,KAAK;CAOxB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,QAChF,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;IAI3C,IAAI;IACJ,IAAI,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IAChC,IAAI,qBAAqB;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBA9DlC,QAAQ;;yBACF,MAAM;;yBAEtC,SAAS;;IA6D5B,SAAS,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IACrC,MAAM,aAAa;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAErC,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Formatting a Caddyfile — `formatCaddyfile()`
|
|
2
|
+
|
|
3
|
+
`caddy fmt` is a CLI-only feature (`cmd/commandfuncs.go`); nothing under `/adapt`, `/load` or any
|
|
4
|
+
other admin endpoint formats a Caddyfile — measured 2026-09-24. `formatCaddyfile(text)` shells out
|
|
5
|
+
to the LOCAL `caddy` binary instead — `caddy fmt -`, the text on stdin, the formatted text on
|
|
6
|
+
stdout — the same formatter the CLI command runs. It never reimplements the Caddyfile grammar's
|
|
7
|
+
formatting rules in TypeScript.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { layer as BunServicesLayer } from '@effect/platform-bun/BunServices'; // or platform-node's
|
|
12
|
+
import { formatCaddyfile } from '@homeflare/alchemy/caddy';
|
|
13
|
+
|
|
14
|
+
const formatted = await Effect.runPromise(
|
|
15
|
+
formatCaddyfile(caddyfile).pipe(Effect.provide(BunServicesLayer)),
|
|
16
|
+
);
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| failure | when |
|
|
20
|
+
| ------------------ | ---------------------------------------------------------------------------------- |
|
|
21
|
+
| `CaddyFmtNotFound` | the binary is not on `PATH` (or not executable) |
|
|
22
|
+
| `CaddyFmtFailed` | it ran but exited non-zero — a syntax error in the text, most likely |
|
|
23
|
+
| `PlatformError` | anything else the OS reported spawning it (a permission problem, a killed process) |
|
|
24
|
+
|
|
25
|
+
⛔ **Never silently skipped.** A missing or failing binary always fails the Effect — there is no
|
|
26
|
+
fallback path that returns the input back unformatted. Point at a specific build with
|
|
27
|
+
`{ binaryPath }` (default: `DEFAULT_CADDY_BINARY`, i.e. `caddy`, resolved on `PATH`) — ideally the
|
|
28
|
+
exact one the target Caddy runs, since `caddy fmt`'s own output can change between versions.
|
|
29
|
+
|
|
30
|
+
Requires `ChildProcessSpawner` in scope (S19: no `node:child_process`, no `async`/`await`) —
|
|
31
|
+
`@effect/platform-bun`'s or `@effect/platform-node`'s layer, or anything smaller that provides
|
|
32
|
+
just that service.
|
|
33
|
+
|
|
34
|
+
## Why it matters: the adapter's own warning
|
|
35
|
+
|
|
36
|
+
Caddy's Caddyfile adapter warns `Caddyfile input is not formatted` on **every** unformatted input
|
|
37
|
+
(`adapter.go` `FormattingDifference`) — not an edge case, the common one. That warning shares the
|
|
38
|
+
`/load` response body with the refused-but-200 trap [caddy.md](./caddy.md)'s digest note and
|
|
39
|
+
admin-calls.ts's module doc describe, so on its own it is easy to miss among other diagnostics.
|
|
40
|
+
|
|
41
|
+
`Caddy.Config`'s plan (`diffConfig`) and deploy (`reconcile`) both pull it out of the warning list
|
|
42
|
+
and log it as its own clear line naming the fix, separately from any other adapter warnings:
|
|
43
|
+
|
|
44
|
+
> `Caddy.Config at <endpoint>: Caddyfile is not formatted — run `caddy fmt` on it, or format it
|
|
45
|
+
with this package's formatCaddyfile() before declaring it, to silence this warning.`
|
|
46
|
+
|
|
47
|
+
Before this, the warning was invisible at plan time (never logged at all) and, at deploy time,
|
|
48
|
+
just one more line among the rest — easy to read past.
|
|
49
|
+
|
|
50
|
+
★ **Formatting should never change the adapted JSON, so it should never cause drift.** `digest.ts`
|
|
51
|
+
compares the adapted config; `caddy fmt` only changes whitespace and brace style in the Caddyfile
|
|
52
|
+
TEXT, and the Caddyfile grammar treats runs of whitespace as plain token separators outside a
|
|
53
|
+
quoted value, so re-flowing them should not change the token stream `/adapt` produces from it.
|
|
54
|
+
⚠️ **Reasoned, not measured against a real Caddy in this repo** — this package has no caddy binary
|
|
55
|
+
to adapt both forms and diff the JSON against. homeflare-mini's own "adapts with zero warnings"
|
|
56
|
+
test (its own docs) is where that gets checked against a real `/adapt` call, on real rendered
|
|
57
|
+
Caddyfiles. Running a declared Caddyfile through `formatCaddyfile()` before handing it to
|
|
58
|
+
`CaddyConfig` silences the warning; it is not expected to ever show up as an update on the next
|
|
59
|
+
plan, but that expectation rests on the grammar, not on a measurement taken here.
|
package/docs/caddy.md
CHANGED
|
@@ -81,6 +81,13 @@ the adapter's warnings, which the provider still reports as a refusal.
|
|
|
81
81
|
`read` and `diff` plan a create/update with a warning (the plan-time `/adapt` did not run). The apply
|
|
82
82
|
still needs Caddy, and fails loudly until it answers.
|
|
83
83
|
|
|
84
|
+
## Formatting
|
|
85
|
+
|
|
86
|
+
`formatCaddyfile(text)` runs the local `caddy fmt -` binary (there is no admin API for it). Plan
|
|
87
|
+
and deploy both surface Caddy's own "not formatted" warning as one clear line naming the fix —
|
|
88
|
+
formatting never changes the adapted JSON, so it can never cause drift. Details, the error types
|
|
89
|
+
and the exact log line: [caddy-fmt.md](./caddy-fmt.md).
|
|
90
|
+
|
|
84
91
|
## Order: file, then `/load`
|
|
85
92
|
|
|
86
93
|
`caddyWithFile()` passes the HostFile's `path` Output into the CaddyConfig, so Alchemy writes the
|
package/docs/credentials.md
CHANGED
|
@@ -45,3 +45,14 @@ the same two variable names the retired hand-rolled `credentials.ts` read (LiteL
|
|
|
45
45
|
CLI client's variable names, not a house choice), still resolved on the calling fiber per request,
|
|
46
46
|
never captured at module scope. `Authorization: Bearer <value>` is built by the package's protocol
|
|
47
47
|
layer, not by this package. ⛔ The key is never a prop, for the same reason as above.
|
|
48
|
+
|
|
49
|
+
The Google Workspace subpath (new 2026-09-24, on `@distilled.cloud/google-workspace`) resolves
|
|
50
|
+
credentials through the SDK's own `CredentialsFromEnv`, which reads `GOOGLE_ACCESS_TOKEN` — but
|
|
51
|
+
unlike NetBox/Forgejo/Paperless/LiteLLM's API-token schemes, this variable holds an OAuth2 bearer
|
|
52
|
+
access token the SDK does not know how to mint: no ADC, no service-account signing, no refresh
|
|
53
|
+
flow. A Bun wrapper (H8's shape, unwritten by this package) performs the domain-wide-delegation
|
|
54
|
+
JWT exchange against a service-account key in OpenBao and exports the short-lived result into
|
|
55
|
+
this one variable. `Authorization: Bearer <value>` is still built by the package's protocol layer.
|
|
56
|
+
`GoogleWorkspaceKeyRef`/`describeKeyRef` (this subpath's own `credentials.ts`) are a typed,
|
|
57
|
+
key-free REFERENCE to where that service-account key lives, for a stack file to keep alongside its
|
|
58
|
+
declarations — full setup: [google-workspace.md](./google-workspace.md).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Google Workspace — `@homeflare/alchemy/google-workspace`
|
|
2
|
+
|
|
3
|
+
Four Admin SDK Directory objects: `Group`, `GroupMember`, `DomainAlias`, `OrgUnit`. Built on
|
|
4
|
+
`@distilled.cloud/google-workspace@1.0.0-rc.12`'s `admin_directory_v1` service (S23) — every call
|
|
5
|
+
is one of its typed operations, every error a `catchTag`.
|
|
6
|
+
|
|
7
|
+
## Credential setup Tim needs
|
|
8
|
+
|
|
9
|
+
⛔ **The distilled SDK takes one thing: an already-minted OAuth2 bearer access token.** Its
|
|
10
|
+
`Credentials` service (`@distilled.cloud/google-workspace/Credentials`) has no service-account
|
|
11
|
+
JSON shape and no domain-wide-delegation (DWD) JWT exchange — read at the installed package,
|
|
12
|
+
2026-09-24: "API-compatible port of the distilled gcp credentials module … no ADC, no
|
|
13
|
+
service-account signing, no refresh flow". Minting the token is entirely outside the SDK.
|
|
14
|
+
|
|
15
|
+
★ **So this follows the house's existing pattern (H8), unchanged.** A Bun wrapper — the same
|
|
16
|
+
shape the kit's Proxmox subpath already uses for its own OpenBao-sourced token — mints a
|
|
17
|
+
short-lived token and exports it into the DEPLOY PROCESS's environment only:
|
|
18
|
+
|
|
19
|
+
| variable | holds |
|
|
20
|
+
| --------------------- | -------------------------------------------------------------------- |
|
|
21
|
+
| `GOOGLE_ACCESS_TOKEN` | a short-lived OAuth2 bearer token, delegated to a Workspace admin |
|
|
22
|
+
| `GOOGLE_PROJECT_ID` | optional — only if a future resource needs to scope to a GCP project |
|
|
23
|
+
|
|
24
|
+
This package does not write that wrapper. `credentials.ts` exports `GoogleWorkspaceKeyRef` — a
|
|
25
|
+
typed REFERENCE (an OpenBao path, the delegated admin, the scopes) a stack file can keep next to
|
|
26
|
+
its declarations for review, and `describeKeyRef()` to render it as one line of text. Neither
|
|
27
|
+
holds, reads or accepts anything shaped like a key (`credentials.test.ts` asserts the refusal).
|
|
28
|
+
|
|
29
|
+
### What Tim has to set up, once
|
|
30
|
+
|
|
31
|
+
1. **A service account** in a Google Cloud project linked to the Workspace account, with
|
|
32
|
+
**domain-wide delegation** enabled in the Admin console (Security → API controls →
|
|
33
|
+
Domain-wide delegation) for the service account's client ID.
|
|
34
|
+
2. **The least OAuth scopes the four resources need** — grant exactly these, nothing broader:
|
|
35
|
+
|
|
36
|
+
| resource | scope |
|
|
37
|
+
| ---------------------- | --------------------------------------------------------- |
|
|
38
|
+
| `Group`, `GroupMember` | `https://www.googleapis.com/auth/admin.directory.group` |
|
|
39
|
+
| `DomainAlias` | `https://www.googleapis.com/auth/admin.directory.domain` |
|
|
40
|
+
| `OrgUnit` | `https://www.googleapis.com/auth/admin.directory.orgunit` |
|
|
41
|
+
|
|
42
|
+
`admin.directory.group` covers group **and** member CRUD — Google does not split them further
|
|
43
|
+
without also losing group create/update. A credential that only ever manages membership (never
|
|
44
|
+
creates or edits a group) can narrow to `admin.directory.group.member` instead.
|
|
45
|
+
|
|
46
|
+
3. **The service account's key** goes in OpenBao, never on disk in this repo or anywhere a stack
|
|
47
|
+
file can reach: `kv/google-workspace/service-accounts/<name>`, matching the mount/catalog
|
|
48
|
+
pattern in `~/.claude/AGENTS.md`'s Cloudflare-credential section (same "mint, never reuse"
|
|
49
|
+
posture, different vendor).
|
|
50
|
+
4. **The wrapper** reads that key from OpenBao, performs the DWD JWT-bearer exchange
|
|
51
|
+
(`https://oauth2.googleapis.com/token`, `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,
|
|
52
|
+
`sub` = the delegated admin) for a token scoped to exactly the table above, and exports it as
|
|
53
|
+
`GOOGLE_ACCESS_TOKEN` before `alchemy.run.ts` runs — never printed, never logged, never written
|
|
54
|
+
to a file. This is the one piece this PR does not build; see
|
|
55
|
+
[upstream-conformance.md](./upstream-conformance.md) for why (S23: distilled owns the SDK
|
|
56
|
+
surface, the house owns minting, and the two should not be re-blended into a second hand-rolled
|
|
57
|
+
auth flow).
|
|
58
|
+
|
|
59
|
+
### Read-only credential, for the census handoff
|
|
60
|
+
|
|
61
|
+
The handoff script at `scratchpad/handoffs/23-workspace-census.sh` only lists — it never writes.
|
|
62
|
+
Mint it a SEPARATE, narrower credential: `admin.directory.group.readonly`,
|
|
63
|
+
`admin.directory.domain.readonly`, `admin.directory.orgunit.readonly`. Do not reuse the write
|
|
64
|
+
credential above for a read-only script.
|
|
65
|
+
|
|
66
|
+
## Adopt is the default posture
|
|
67
|
+
|
|
68
|
+
`reconcile` fetches by key (email, alias name, or org unit path) before it writes — no NetBox-style
|
|
69
|
+
list-then-disambiguate, because every object here has a stable key a human would type. An
|
|
70
|
+
instance that already has "Schenanigans - Admin" or "Schenanigans - Google" (the groups the
|
|
71
|
+
estate's Cloudflare Access policies already name — `docs/zero-trust-access-applications.md` in
|
|
72
|
+
homeflare-landscape) is BOUND, never duplicated, and `attributes` always maps the FULL live row —
|
|
73
|
+
the exact-as-live half of `adopt(true)`.
|
|
74
|
+
|
|
75
|
+
## Users are out of scope
|
|
76
|
+
|
|
77
|
+
`GoogleWorkspace.User` is not built. `insertUsers`/`patchUsers` accept a `password` field on the
|
|
78
|
+
wire — one keystroke from becoming a prop, and Alchemy persists props unencrypted. No stack in
|
|
79
|
+
this estate declares a Workspace user today (measured 2026-09-24). `GroupMember.email` references
|
|
80
|
+
a user without ever creating, reading or storing one.
|
|
81
|
+
|
|
82
|
+
## Removal is `retain` for everything but membership
|
|
83
|
+
|
|
84
|
+
`Group`, `DomainAlias` and `OrgUnit` default to `retain` — each governs something else (Access
|
|
85
|
+
policies, mail routing, service visibility) that outlives the declaration removing it.
|
|
86
|
+
`GroupMember` does not: adding someone back to a group is cheap and reversible, so it uses the
|
|
87
|
+
engine default (`destroy`).
|
|
88
|
+
|
|
89
|
+
## SDK gaps, measured 2026-09-24
|
|
90
|
+
|
|
91
|
+
- **`admin_directory_v1` lives under `unstable/`, not the SDK's preferred `services/` tree**
|
|
92
|
+
(`@distilled.cloud/google-workspace/unstable/admin_directory_v1`). The package's own `index.ts`
|
|
93
|
+
says "non-preferred versions" — this is the only Directory API version distilled generates, so
|
|
94
|
+
"non-preferred" describes distilled's own service catalog, not a deprecated Google API. Nothing
|
|
95
|
+
about the operations this family calls looked incomplete or wrong; flagged here per S23/S33 so a
|
|
96
|
+
future upgrade that moves it to `services/` is a one-line import change, not a surprise.
|
|
97
|
+
- **No per-operation typed error beyond the shared `NotFound | Forbidden | BadRequest | Conflict`
|
|
98
|
+
set.** A cycle in group membership, an unverified domain on `insertDomainAliases`, or a
|
|
99
|
+
reparent-with-children refusal on `deleteOrgunits` all surface as `BadRequest` with a message,
|
|
100
|
+
not a resource-specific tag. S21 says a missing tag is fixed in the SDK, never worked around in
|
|
101
|
+
the resource — none of these three cases are worked around here; they propagate as `BadRequest`
|
|
102
|
+
same as any other 400.
|
|
103
|
+
- **No `updateGroupsAliases`/`patchGroupsAliases`.** Group aliases are add/list/delete only,
|
|
104
|
+
matching `DomainAlias`'s own no-update shape — not modelled as a fifth resource in this PR
|
|
105
|
+
(out of scope: the task named domain aliases, not group aliases).
|
package/docs/grafana.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Grafana — `@homeflare/alchemy/grafana`
|
|
2
|
+
|
|
3
|
+
`Grafana.Datasource` declares one Grafana data source, keyed by `uid`. Every call goes through
|
|
4
|
+
`@distilled.cloud/grafana@1.0.0-rc.12`'s typed operations, `catchTag('NotFound', ...)` in place of
|
|
5
|
+
any status check. Built 2026-09-24 against the live target `teslamate-grafana.service` on CT100 —
|
|
6
|
+
measured version **13.1.3** (`teslamate/grafana:4.2.0`), listening on `127.0.0.1:3100`, served
|
|
7
|
+
publicly at `tesla.homeflare.dev/dash/`.
|
|
8
|
+
|
|
9
|
+
## The SDK gap — why only `Datasource` ships
|
|
10
|
+
|
|
11
|
+
Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
|
|
12
|
+
`lib/services/grafana.d.ts`, 4,938 lines): the package has **no create/read/update/delete
|
|
13
|
+
operations for folders, dashboards, alert rules or contact points**. It covers datasources in
|
|
14
|
+
full (`addDataSource`, `getDataSourceByUID`, `updateDataSourceByUID`, `deleteDataSourceByUID`,
|
|
15
|
+
`getDataSources`), plus org/user/team/role/report/annotation/library-element/public-dashboard/
|
|
16
|
+
snapshot management — but:
|
|
17
|
+
|
|
18
|
+
- **Folders:** only `updateFolderPermissions` exists. No `createFolder`, `getFolder` or
|
|
19
|
+
`deleteFolder` — Grafana's `/api/folders` and `/api/folders/{uid}` routes are entirely absent.
|
|
20
|
+
- **Dashboards:** only snapshot and public-dashboard routes exist (`createDashboardSnapshot`,
|
|
21
|
+
`getPublicDashboard`, …). No `POST /api/dashboards/db`, no `GET /api/dashboards/uid/{uid}`, no
|
|
22
|
+
`DELETE /api/dashboards/uid/{uid}` — the plain dashboard CRUD route family is absent.
|
|
23
|
+
- **Alert rules:** only `routeGetAlertRuleExport`/`routeGetAlertRuleGroupExport`/
|
|
24
|
+
`routeGetAlertRulesExport` exist — export (read-only) endpoints under `/api/v1/provisioning/`.
|
|
25
|
+
No create/update/delete for `/api/v1/provisioning/alert-rules{,/{uid}}`.
|
|
26
|
+
- **Contact points:** only `routeGetContactpointsExport` exists, same story — no
|
|
27
|
+
`/api/v1/provisioning/contact-points{,/{uid}}` CRUD.
|
|
28
|
+
|
|
29
|
+
This is recorded in [`docs/upstream-conformance.md`](./upstream-conformance.md) rather than worked
|
|
30
|
+
around. Per S23, the fix is a distilled PR (regenerate against a spec that includes these routes,
|
|
31
|
+
or patch them in per S22), not a hand-rolled `HttpClient` client in this package for the missing
|
|
32
|
+
pieces alone — that would put half a family through the SDK and half through a second, parallel
|
|
33
|
+
client, exactly what S23 exists to prevent.
|
|
34
|
+
|
|
35
|
+
`teslamate-grafana` itself is consistent with this gap being real, not merely unexploited: its one
|
|
36
|
+
datasource is API-manageable and already exists; its dashboards ship baked into the
|
|
37
|
+
`teslamate/grafana` image and are provisioned by the container, not by any API call; it has no
|
|
38
|
+
folders, alert rules or contact points configured at all (measured over ssh, read-only, 2026-09-24).
|
|
39
|
+
|
|
40
|
+
## Credentials
|
|
41
|
+
|
|
42
|
+
A `GrafanaTarget` — an instance origin plus a token env var NAME, never a literal value (S25):
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
46
|
+
|
|
47
|
+
const teslamateGrafana = grafanaProviders({
|
|
48
|
+
baseUrl: 'https://tesla.homeflare.dev/dash',
|
|
49
|
+
tokenEnv: 'TESLAMATE_GRAFANA_TOKEN',
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`tokenEnv` is read fresh from `process.env` inside each operation's own effect (S24) — a layer
|
|
54
|
+
built once still re-reads the environment on every request. This house wrapper exists because,
|
|
55
|
+
unlike Forgejo (one instance for this estate), Grafana is not: `grafana.homeflare.dev` and
|
|
56
|
+
`teslamate-grafana` are two instances with two different tokens, and the unit file for the latter
|
|
57
|
+
says explicitly it uses "Own Access SaaS OIDC client, never grafana.homeflare.dev's" — one global
|
|
58
|
+
`GRAFANA_TOKEN`/`GRAFANA_URL` pair (the SDK's own `CredentialsFromEnv`) cannot address both. A
|
|
59
|
+
stack declaring resources against both instances calls `grafanaProviders` twice, once per target;
|
|
60
|
+
the `Grafana.Datasource` resource type itself is one global registration shared by both calls.
|
|
61
|
+
|
|
62
|
+
A datasource's `secureJsonData` (e.g. its password) follows the same rule: `secureJsonDataRefs`
|
|
63
|
+
takes a map of field name to env var NAME, resolved the same way, never returned in `attributes` —
|
|
64
|
+
Grafana's own read API agrees, since `DataSource.secureJsonFields` on a GET is a map of booleans
|
|
65
|
+
(is-it-set), never the value.
|
|
66
|
+
|
|
67
|
+
## Adopt semantics
|
|
68
|
+
|
|
69
|
+
`reconcile` observes before it writes (S9/S10): a `uid` the target instance already has is bound,
|
|
70
|
+
and when the declaration already matches what's live, nothing is sent — no create, no update.
|
|
71
|
+
Declaring exactly what's live changes nothing. This is why `uid` is a **required** prop rather than
|
|
72
|
+
generated: `getDataSourceByUID`/`updateDataSourceByUID`/`deleteDataSourceByUID` are all UID-keyed,
|
|
73
|
+
and `getDataSources` (list) takes no filter, so locating an object this resource didn't create
|
|
74
|
+
would mean listing the whole org and matching by name — the same ambiguity
|
|
75
|
+
`netbox/resource.ts`'s `soleMatch` exists to catch. Requiring `uid` up front (Grafana accepts a
|
|
76
|
+
caller-chosen one on create) sidesteps it: every operation keys on the one field the API itself is
|
|
77
|
+
keyed on.
|
|
78
|
+
|
|
79
|
+
## Example
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { GrafanaDatasource, grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
83
|
+
|
|
84
|
+
export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource', {
|
|
85
|
+
uid: '<the live uid — see the census handoff>',
|
|
86
|
+
name: 'TeslaMate',
|
|
87
|
+
type: 'postgres',
|
|
88
|
+
access: 'proxy',
|
|
89
|
+
isDefault: true,
|
|
90
|
+
}) {}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// …then provide `grafanaProviders({ baseUrl, tokenEnv })` alongside the stack's other providers.
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Not covered
|
|
98
|
+
|
|
99
|
+
- **Folders, dashboards, alert rules, contact points.** Blocked on the SDK gap above — a
|
|
100
|
+
maintainer decision on whether to patch distilled or wait for upstream to add the routes.
|
|
101
|
+
- **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
|
|
102
|
+
still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
|
package/docs/peers.md
CHANGED
|
@@ -54,6 +54,15 @@ enforces it. `@effect/sql-pg` is optional on `alchemy`'s own manifest (its modul
|
|
|
54
54
|
does not reach `SQL/Postgres` unless a stack imports it); here it stays a plain peer like
|
|
55
55
|
every other one.
|
|
56
56
|
|
|
57
|
+
⚠️ **`@distilled.cloud/grafana` (added 2026-09-24, kit PR 222, ahead of `/grafana`) is
|
|
58
|
+
required for the same reason `@distilled.cloud/forgejo` is: a plain dependency of this
|
|
59
|
+
package, not something a hoisting installer can hide from a strict one.** Pinned to
|
|
60
|
+
`1.0.0-rc.12`, the same distilled release line as every other pin on this page. Unlike
|
|
61
|
+
Forgejo, this family is credential-parameterized per instance (`grafanaCredentials`/
|
|
62
|
+
`grafanaProviders` take a `GrafanaTarget`, not a fixed env-var pair) — see
|
|
63
|
+
[grafana.md](./grafana.md) for why one instance was never going to be enough for this
|
|
64
|
+
estate. The peer itself does not change for that: it is still one package, one pin.
|
|
65
|
+
|
|
57
66
|
★ **`@distilled.cloud/netbox` (added 2026-09-23, moving `/netbox` off a hand-rolled
|
|
58
67
|
`HttpClient` client the same way `/forgejo` did) is NOT on this install line, and that is
|
|
59
68
|
deliberate — see [distilled-interim.md](./distilled-interim.md).** The real
|