@homeflare/alchemy 0.31.2 → 0.33.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 +39 -3
- package/dist/grafana/dashboard-model.d.ts +77 -0
- package/dist/grafana/dashboard-model.d.ts.map +1 -0
- package/dist/grafana/dashboard.d.ts +98 -0
- package/dist/grafana/dashboard.d.ts.map +1 -0
- package/dist/grafana/folder.d.ts +100 -0
- package/dist/grafana/folder.d.ts.map +1 -0
- package/dist/grafana/index.d.ts +9 -8
- package/dist/grafana/index.d.ts.map +1 -1
- package/dist/grafana/index.js +158 -6
- package/dist/grafana/index.js.map +8 -4
- package/dist/grafana/providers.d.ts +1 -1
- package/dist/grafana/providers.d.ts.map +1 -1
- package/dist/grafana/provisioned.d.ts +11 -0
- package/dist/grafana/provisioned.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +170 -80
- package/dist/proxmox/index.js.map +9 -7
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-form.d.ts +124 -0
- package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
- package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +27 -52
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/grafana-folder-dashboard.md +145 -0
- package/docs/grafana.md +11 -10
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +9 -4
- package/package.json +12 -2
- package/src/grafana/dashboard-model.test.ts +111 -0
- package/src/grafana/dashboard-model.ts +111 -0
- package/src/grafana/dashboard.test.ts +232 -0
- package/src/grafana/dashboard.ts +161 -0
- package/src/grafana/folder.test.ts +212 -0
- package/src/grafana/folder.ts +161 -0
- package/src/grafana/index.ts +21 -8
- package/src/grafana/providers.ts +7 -1
- package/src/grafana/provisioned.ts +49 -0
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/constraints-host-forms.test.ts +1 -1
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
- package/src/proxmox/zfs-pool-form.ts +236 -0
- package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
- package/src/proxmox/zfs-pool-wire.ts +132 -0
- package/src/proxmox/zfs-pool.ts +106 -76
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
- package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
- package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
- package/dist/proxmox/zfs-pool-write.d.ts +0 -128
- package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
- package/src/proxmox/zfs-pool-spec.ts +0 -76
- package/src/proxmox/zfs-pool-write.ts +0 -196
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as Effect from 'effect/Effect';
|
|
2
|
+
export declare const UNIFI_READ_ONLY_POLICY = "read-only by Tim's rule, 2026-09-24; lifting it is a kit change";
|
|
3
|
+
/** What kind of write was asked for and refused — for the message only, never acted on. */
|
|
4
|
+
export type UnifiWriteAction = 'create' | 'update' | 'delete';
|
|
5
|
+
declare const UnifiWriteRefused_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
6
|
+
readonly _tag: "UnifiWriteRefused";
|
|
7
|
+
} & Readonly<A>;
|
|
8
|
+
export declare class UnifiWriteRefused extends UnifiWriteRefused_base<{
|
|
9
|
+
/** The vendor type string, e.g. `Unifi.Network`. */
|
|
10
|
+
readonly type: string;
|
|
11
|
+
/** `spec.describe(props)` — the object the write would have touched. */
|
|
12
|
+
readonly identity: string;
|
|
13
|
+
readonly action: UnifiWriteAction;
|
|
14
|
+
}> {
|
|
15
|
+
get message(): string;
|
|
16
|
+
}
|
|
17
|
+
/** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
|
|
18
|
+
export declare const refuseWrite: (type: string, identity: string, action: UnifiWriteAction) => Effect.Effect<never, UnifiWriteRefused, never>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/unifi/policy.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,eAAO,MAAM,sBAAsB,oEACgC,CAAC;AAEpE,2FAA2F;AAC3F,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;;;;AAE9D,qBAAa,iBAAkB,SAAQ,uBAAsC;IAC3E,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;CACnC,CAAC;IACA,IAAa,OAAO,IAAI,MAAM,CAE7B;CACF;AAED,2FAA2F;AAC3F,eAAO,MAAM,WAAW,SAAU,MAAM,YAAY,MAAM,UAAU,gBAAgB,mDACpB,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `Unifi.*` provider as one layer for a stack.
|
|
3
|
+
*
|
|
4
|
+
* Layer.mergeAll(unifiProviders(), …the stack's other providers)
|
|
5
|
+
* .pipe(Layer.provide(FetchHttpClient.layer))
|
|
6
|
+
*
|
|
7
|
+
* S16: a subpath's `providers()` plays the role upstream's own `Providers.ts` does — one minimal
|
|
8
|
+
* insertion per family. `FetchHttpClient.layer` is the stack's own responsibility to provide,
|
|
9
|
+
* mirroring `../discord/providers.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
export declare const providers: () => Layer.Layer<import("alchemy").Provider<import("./firewall-zone.ts").UnifiFirewallZone> | import("alchemy").Provider<import("./network.ts").UnifiNetwork>, never, import("effect/unstable/http/HttpClient").HttpClient>;
|
|
13
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.d.ts","sourceRoot":"","sources":["../../src/unifi/providers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;AAItC,eAAO,MAAM,SAAS,8NAA4E,CAAC"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Input } from 'alchemy/Input';
|
|
2
|
+
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
5
|
+
/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
|
|
6
|
+
export type UnifiRequirements = HttpClient.HttpClient;
|
|
7
|
+
/**
|
|
8
|
+
* One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
|
|
9
|
+
* `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
|
|
10
|
+
* union, with `NotFound` already folded away) so the precise per-operation error union flows
|
|
11
|
+
* through instead of being widened by hand here.
|
|
12
|
+
*/
|
|
13
|
+
export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
|
|
14
|
+
/** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
|
|
15
|
+
readonly type: string;
|
|
16
|
+
/** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
|
|
17
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, UnifiNetworkOpContext>;
|
|
18
|
+
readonly attributes: (live: Live, props: Props) => Attributes;
|
|
19
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
20
|
+
/** For the refusal message and read-back failures. */
|
|
21
|
+
readonly describe: (props: Props) => string;
|
|
22
|
+
};
|
|
23
|
+
export declare const unifiOperations: <Props extends object, Live, Attributes extends object, E>(spec: UnifiSpec<Props, Live, Attributes, E>) => {
|
|
24
|
+
read: (props: Props) => Effect.Effect<Attributes | undefined, E, UnifiNetworkOpContext>;
|
|
25
|
+
readHandler: ({ olds, output }: {
|
|
26
|
+
olds: Props;
|
|
27
|
+
output: Attributes | undefined;
|
|
28
|
+
}) => Effect.Effect<Attributes | undefined, E, UnifiNetworkOpContext>;
|
|
29
|
+
diff: (news: Input<Props>, output: Attributes | undefined) => Effect.Effect<{
|
|
30
|
+
readonly action: 'update';
|
|
31
|
+
} | {
|
|
32
|
+
readonly action: 'noop';
|
|
33
|
+
} | undefined, E, UnifiNetworkOpContext>;
|
|
34
|
+
reconcile: (news: Props) => Effect.Effect<Attributes, E | import("./policy.ts").UnifiWriteRefused, UnifiNetworkOpContext>;
|
|
35
|
+
destroy: (olds: Props) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, never>;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The four provider handlers for a spec'd read-only UniFi object, wired once.
|
|
39
|
+
*
|
|
40
|
+
* ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
|
|
41
|
+
* adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
|
|
42
|
+
*/
|
|
43
|
+
export declare const unifiHandlers: <Props extends object, Live, Attributes extends object, E>(spec: UnifiSpec<Props, Live, Attributes, E>) => {
|
|
44
|
+
list: () => Effect.Effect<never[], never, never>;
|
|
45
|
+
read: (args: {
|
|
46
|
+
olds: Props;
|
|
47
|
+
output: Attributes | undefined;
|
|
48
|
+
}) => Effect.Effect<Attributes | undefined, E, HttpClient.HttpClient>;
|
|
49
|
+
diff: (args: {
|
|
50
|
+
news: Input<Props>;
|
|
51
|
+
output: Attributes | undefined;
|
|
52
|
+
}) => Effect.Effect<{
|
|
53
|
+
readonly action: 'update';
|
|
54
|
+
} | {
|
|
55
|
+
readonly action: 'noop';
|
|
56
|
+
} | undefined, E, HttpClient.HttpClient>;
|
|
57
|
+
reconcile: (args: {
|
|
58
|
+
news: Props;
|
|
59
|
+
}) => Effect.Effect<Attributes, E | import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
|
|
60
|
+
delete: (args: {
|
|
61
|
+
olds: Props;
|
|
62
|
+
}) => Effect.Effect<never, import("./policy.ts").UnifiWriteRefused, HttpClient.HttpClient>;
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=resource.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../../src/unifi/resource.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAE3C,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,yCAAyC,CAAC;AACrF,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAGnE,gGAAgG;AAChG,MAAM,MAAM,iBAAiB,GAAG,UAAU,CAAC,UAAU,CAAC;AAEtD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,CAAC,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,IAAI;IAChF,mFAAmF;IACnF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6FAA6F;IAC7F,QAAQ,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,qBAAqB,CAAC,CAAC;IAChG,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,KAAK,UAAU,CAAC;IAC9D,QAAQ,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,UAAU,EAAE,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC;IACpE,sDAAsD;IACtD,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,MAAM,CAAC;CAC7C,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,QAChF,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;kBAEtB,KAAK;oCAQQ;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;iBAUlE,KAAK,CAAC,KAAK,CAAC,UAAU,UAAU,GAAG,SAAS;yBAIZ,QAAQ;;yBAElC,MAAM;;sBAIP,KAAK;oBAYP,KAAK;CAExB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,SAAS,MAAM,EAAE,IAAI,EAAE,UAAU,SAAS,MAAM,EAAE,CAAC,QAC9E,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC;IAMzC,IAAI;IACJ,IAAI,SAAS;QAAE,IAAI,EAAE,KAAK,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;IAE5D,IAAI,SAAS;QAAE,IAAI,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAAC,MAAM,EAAE,UAAU,GAAG,SAAS,CAAA;KAAE;yBAtCtB,QAAQ;;yBAElC,MAAM;;IAsCzB,SAAS,SAAS;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;IACjC,MAAM,SAAS;QAAE,IAAI,EAAE,KAAK,CAAA;KAAE;CAEjC,CAAC"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# `Grafana.Folder` and `Grafana.Dashboard` — `@homeflare/alchemy/grafana`
|
|
2
|
+
|
|
3
|
+
Built 2026-09-24 on the same `@distilled.cloud/grafana` (aliased onto
|
|
4
|
+
`@homeflare/distilled-grafana@0.2.0`) operations that fixed the SDK gap
|
|
5
|
+
[grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) recorded:
|
|
6
|
+
`createFolder`/`getFolderByUID`/`updateFolder`/`deleteFolder`/`getFolders` and
|
|
7
|
+
`postDashboard`/`getDashboard`/`deleteDashboard`, plus a typed `PreconditionFailed` (412) for
|
|
8
|
+
dashboard version conflicts. No live call was made writing this PR — see "Measured vs inferred"
|
|
9
|
+
below for exactly which claims that limits.
|
|
10
|
+
|
|
11
|
+
## Both follow `Grafana.Datasource`'s doctrine
|
|
12
|
+
|
|
13
|
+
Same file shape (`resource.ts`'s shared `GrafanaSpec`/`grafanaOperations`/`grafanaHandlers`), same
|
|
14
|
+
credentials layer (`grafanaProviders(target)`), same **uid required, never generated** reasoning —
|
|
15
|
+
`getFolderByUID`/`updateFolder`/`deleteFolder` and `getDashboard`/`postDashboard`/`deleteDashboard`
|
|
16
|
+
are all UID-keyed, and neither `getFolders` nor a dashboard list endpoint takes a per-uid filter,
|
|
17
|
+
so locating an object this resource didn't create would mean listing everything and matching by
|
|
18
|
+
name — the ambiguity requiring `uid` up front sidesteps.
|
|
19
|
+
|
|
20
|
+
## `Grafana.Folder`
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { GrafanaFolder, grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
24
|
+
|
|
25
|
+
export class Infra extends GrafanaFolder('infra-folder', {
|
|
26
|
+
uid: 'infra',
|
|
27
|
+
title: 'Infrastructure',
|
|
28
|
+
}) {}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- **`description` is write-only** — MEASURED against the SDK's generated types: `Folder` (the GET
|
|
32
|
+
response) has no `description` field at all, though `CreateFolderRequest`/`UpdateFolderRequest`
|
|
33
|
+
both accept one. Sent on every create/update that declares it; never compared, the same
|
|
34
|
+
treatment `Grafana.Datasource` gives `secureJsonDataRefs` for the same reason (nothing to diff
|
|
35
|
+
against).
|
|
36
|
+
- **Nesting (`parentUid`) is create-only** — MEASURED: `UpdateFolderRequest` has no `parentUid`
|
|
37
|
+
field (`folder_uid`, `description`, `overwrite`, `title`, `version` only). A declaration whose
|
|
38
|
+
`parentUid` no longer matches the live folder REFUSES with `GrafanaFolderReparentError` rather
|
|
39
|
+
than silently doing nothing or deleting-and-recreating under the new parent (which — next
|
|
40
|
+
bullet — takes every dashboard and alert rule in the folder with it). Move a folder by hand in
|
|
41
|
+
Grafana, then update the declaration.
|
|
42
|
+
- **Delete defaults to `retain`** — `deleteFolder`'s own operation description says it deletes
|
|
43
|
+
"all dashboards (and their alerts) stored in the folder... cannot be reverted... also deletes
|
|
44
|
+
all the subfolders." That is the multi-object cascade `openbao/mount.ts` guards with
|
|
45
|
+
`defaultRemovalPolicy: 'retain'`, verified there against Alchemy's own `Apply.ts` (not
|
|
46
|
+
re-verified here; same engine, same option — see `folder.ts`'s header). A stack that wants the
|
|
47
|
+
cascade opts in with `.pipe(RemovalPolicy.destroy())`; `destroy` still implements the real
|
|
48
|
+
`DELETE` in full either way (S11). `Grafana.Dashboard` does **not** default to retain — see its
|
|
49
|
+
own section below.
|
|
50
|
+
|
|
51
|
+
## `Grafana.Dashboard`
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { GrafanaDashboard, grafanaProviders } from '@homeflare/alchemy/grafana';
|
|
55
|
+
|
|
56
|
+
export class FleetOverview extends GrafanaDashboard('fleet-overview-dashboard', {
|
|
57
|
+
uid: 'fleet-overview',
|
|
58
|
+
folderUid: 'infra',
|
|
59
|
+
dashboard: {
|
|
60
|
+
title: 'Fleet Overview',
|
|
61
|
+
panels: [/* … exported straight from Grafana's own "Export as JSON" … */],
|
|
62
|
+
},
|
|
63
|
+
}) {}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **The declared content is the dashboard JSON model** — the same shape Grafana's own "Export as
|
|
67
|
+
JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips `id`/`version`/`iteration`
|
|
68
|
+
(Grafana-managed, injected/incremented on every save) and forces `uid` to this resource's own
|
|
69
|
+
value regardless of where — or whether — the source model carried one, before ANY comparison.
|
|
70
|
+
That is what makes a pasted export (which always embeds the OLD live uid) and a freshly-read
|
|
71
|
+
live model compare equal, and what makes a no-change plan a true noop despite those fields
|
|
72
|
+
moving on every live save.
|
|
73
|
+
- **Array order is significant, and nothing here needs a `sortedSet`** — `panels` (screen layout
|
|
74
|
+
via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
|
|
75
|
+
`repeat` behavior) and each panel's `targets` (query execution/legend order) are compared
|
|
76
|
+
positionally, never reordered. Nothing in a dashboard model is a genuinely unordered SET the way
|
|
77
|
+
UniFi's `trustedDhcpServerIpAddresses` is (`unifi/network-form.ts`'s `sortedSet`) — this family
|
|
78
|
+
has no field that needs one. `schemaVersion` is deliberately left comparing as-is — NOT MEASURED
|
|
79
|
+
whether Grafana rewrites it on a plain save, so normalizing it away on a guess risked hiding a
|
|
80
|
+
genuine schema mismatch instead of a false diff; see `dashboard-model.ts` for the exact reasoning
|
|
81
|
+
and what evidence would justify adding it to the volatile-field list.
|
|
82
|
+
- **`matches` compares "is the declaration a subset of what is live", not plain equality** —
|
|
83
|
+
`dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
|
|
84
|
+
found plain structural equality (`alchemy/Diff`'s `deepEqual`, used in an earlier version of this
|
|
85
|
+
PR) made an ordinary, never-edited dashboard show `update` FOREVER: Grafana's own schema
|
|
86
|
+
migration on save decorates any panel or target whose `datasource` is absent with an explicit
|
|
87
|
+
`{type, uid}` reference — documented Grafana behavior, not re-measured against a live instance in
|
|
88
|
+
this PR. A declared panel that never sets `datasource` therefore read back with that field
|
|
89
|
+
populated, disagreed with the declaration, got written back undecorated, and got redecorated
|
|
90
|
+
again — non-convergent. `declaredContentMatches` instead asks only whether every field the
|
|
91
|
+
DECLARATION mentions, at any depth, still matches what is live; `live` may carry additional
|
|
92
|
+
fields Grafana injected that the declaration never mentioned. Real content drift (a field the
|
|
93
|
+
declaration DOES mention changing, or vanishing) is still caught exactly as before. The
|
|
94
|
+
trade-off, deliberate: **removing** a field from the declaration can no longer force Grafana to
|
|
95
|
+
drop a value it — or a previous declaration — set; only an explicit replacement value changes
|
|
96
|
+
one. This is the same "undeclared means don't care, never means force-to-default" convention this
|
|
97
|
+
whole kit already uses for a plain optional prop, now applied inside the JSON model too.
|
|
98
|
+
- **412 version conflicts fail loudly, never overwritten blindly.** `update` injects the version
|
|
99
|
+
this resource just read (from the same `fetchLive` call `reconcile` already made) into the model
|
|
100
|
+
it sends, and `overwrite` is never set. If Grafana's own check still fails — someone saved in the
|
|
101
|
+
gap between that read and this write — `PreconditionFailed` is left uncaught: the whole reconcile
|
|
102
|
+
fails and nothing is written. The next `plan`/`deploy` re-reads the new live version fresh; there
|
|
103
|
+
is no separate retry loop in this file, because that next run already is the re-read.
|
|
104
|
+
- **No retain default**, unlike `Grafana.Folder`. Deleting one dashboard is a single-object blast
|
|
105
|
+
radius — the same class `Grafana.Datasource` already accepts without a retain guard, not
|
|
106
|
+
`Grafana.Folder`'s multi-object cascade.
|
|
107
|
+
|
|
108
|
+
## Provisioned objects — refused for writes, adoption is fine
|
|
109
|
+
|
|
110
|
+
A folder or dashboard that a provisioning FILE manages (rather than this API) carries a live
|
|
111
|
+
signal — `Folder.managedBy` (non-empty) and `DashboardMeta.provisioned`/`provisionedExternalId` —
|
|
112
|
+
that `update`/`destroy` check before ever calling the SDK; `GrafanaProvisionedObjectError` names
|
|
113
|
+
the owning file when Grafana reports one (dashboards only — a folder's `managedBy` value is
|
|
114
|
+
reported as-is, since the SDK does not separately expose a folder's owning file). `create` is
|
|
115
|
+
never at risk: it only runs when `fetchLive` already found nothing.
|
|
116
|
+
|
|
117
|
+
`matches` still compares structurally regardless of provisioned status, so a mismatched
|
|
118
|
+
declaration against a provisioned object shows `update` in a plan — never a silent, permanently
|
|
119
|
+
hidden noop — and only fails, loudly, when reconcile actually tries to write. A declaration that
|
|
120
|
+
already matches what is live never reaches `update`/`destroy` at all (the shared engine's
|
|
121
|
+
observe-before-write path), so **adopting a provisioned object read-only is safe.**
|
|
122
|
+
|
|
123
|
+
⚠️ **Measured vs inferred (no live call in this PR).** `DashboardMeta.provisioned`/
|
|
124
|
+
`provisionedExternalId` are exactly the fields Grafana's classic dashboard-read response types for
|
|
125
|
+
this purpose — high confidence, standard shape. `Folder.managedBy` is INFERRED from the SDK's
|
|
126
|
+
schema only: no live instance in this house currently has a file-provisioned FOLDER to read against
|
|
127
|
+
(`teslamate-grafana` has none at all — see grafana.md). Treating any non-empty `managedBy` as
|
|
128
|
+
provisioned, rather than matching specific string values that were never observed live, is the
|
|
129
|
+
conservative reading; confirm against a real provisioned folder before relying on the exact value in
|
|
130
|
+
an error message. The panel/target `datasource` auto-decoration `declaredContentMatches` (above)
|
|
131
|
+
exists to tolerate is documented Grafana schema-migration behavior, also not re-measured live in
|
|
132
|
+
this PR. And `updateFolder`'s `version` field — sent optimistically for concurrency — carries the
|
|
133
|
+
SDK's own caveat "only used by the legacy folder implementation"; whether Grafana's newer
|
|
134
|
+
unified-storage folder backend honors it at all is unverified (`folder.ts`'s `update`).
|
|
135
|
+
|
|
136
|
+
## Tests
|
|
137
|
+
|
|
138
|
+
`folder.test.ts`, `dashboard.test.ts` and `dashboard-model.test.ts` mirror `datasource.test.ts`'s
|
|
139
|
+
fake-Grafana harness (`fake-grafana.ts` — the real distilled protocol, only the wire responses
|
|
140
|
+
faked). Each proves: a GET never carries a body (`fake.bodies[0]` is `undefined` — the exact
|
|
141
|
+
"distilled JSON-encodes unknown keys as a body" trap this family's shared memory names); a
|
|
142
|
+
provisioned object refuses update and destroy with no write sent; a transient failure (403/401)
|
|
143
|
+
propagates rather than folding to absent; an unchanged declaration is a noop despite `id`/
|
|
144
|
+
`version`/`iteration` differing; a 412 propagates uncaught; and the folder `parentUid` reparent
|
|
145
|
+
refusal sends no write.
|
package/docs/grafana.md
CHANGED
|
@@ -6,15 +6,14 @@ any status check. Built 2026-09-24 against the live target `teslamate-grafana.se
|
|
|
6
6
|
measured version **13.1.3** (`teslamate/grafana:4.2.0`), listening on `127.0.0.1:3100`, served
|
|
7
7
|
publicly at `tesla.homeflare.dev/dash/`.
|
|
8
8
|
|
|
9
|
-
## The SDK gap —
|
|
9
|
+
## The SDK gap — history, and what shipped on top of the fix
|
|
10
10
|
|
|
11
11
|
✅ **SDK-level gap fixed 2026-09-24** (`@distilled.cloud/grafana`, aliased onto
|
|
12
|
-
`@homeflare/distilled-grafana` — 0.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
17
|
-
the now-available SDK operations, not part of this fix.
|
|
12
|
+
`@homeflare/distilled-grafana` — 0.2.0; see [docs/distilled-interim.md](./distilled-interim.md)).
|
|
13
|
+
The gap below is left as evidence of what was measured and why. `Grafana.Folder` and
|
|
14
|
+
`Grafana.Dashboard` now ship on top of the unblocked operations —
|
|
15
|
+
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md) covers both in full. `Grafana.AlertRule`,
|
|
16
|
+
`ContactPoint`, `NotificationPolicy` and `MuteTiming` remain follow-up work, not part of this PR.
|
|
18
17
|
|
|
19
18
|
Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
|
|
20
19
|
`lib/services/grafana.d.ts`, 4,938 lines): the package had **no create/read/update/delete
|
|
@@ -125,8 +124,10 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
|
|
|
125
124
|
|
|
126
125
|
## Not covered
|
|
127
126
|
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
- **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
|
|
128
|
+
[grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
|
|
129
|
+
- **Alert rules, contact points, notification policies, mute timings.** The SDK-level blocker is
|
|
130
|
+
fixed (above); no house `Resource` calls these operations yet — follow-up work, not blocked on
|
|
131
|
+
anything.
|
|
131
132
|
- **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
|
|
132
133
|
still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
|
package/docs/opnsense.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# OPNsense — `@homeflare/alchemy/opnsense`
|
|
2
|
+
|
|
3
|
+
Declares `Opnsense.Firewall.Alias`, `.Category` and `.Group` against
|
|
4
|
+
`@distilled.cloud/opnsense` (aliased onto `@homeflare/distilled-opnsense@0.2.0` —
|
|
5
|
+
[`distilled-interim.md`](./distilled-interim.md)). Built 2026-09-24. **READ-ONLY BY
|
|
6
|
+
DESIGN** — see "The policy" below before reading anything else here. This is the
|
|
7
|
+
estate's **edge firewall**: nothing in this family's code, or in building it,
|
|
8
|
+
contacted the live box, and nothing about it may reconfigure one. (The vendor
|
|
9
|
+
version pin below WAS confirmed against the live box once, read-only, during
|
|
10
|
+
review — see "Vendor version" below; that probe used neither this family's
|
|
11
|
+
code nor this SDK's generated client.)
|
|
12
|
+
|
|
13
|
+
## Vendor version — measured 2026-09-24
|
|
14
|
+
|
|
15
|
+
`@homeflare/distilled-opnsense`'s own README carries its generation pin:
|
|
16
|
+
`opnsense/core`/`opnsense/plugins` tag `26.7.2`. That pin used to be an
|
|
17
|
+
**inference** from a captured package list, never measured live (SSH to the
|
|
18
|
+
edge is forbidden by house policy — a failed probe gets the caller's IP
|
|
19
|
+
CrowdSec-banned). This PR's review measured it instead (Probe 14, 2026-09-24),
|
|
20
|
+
live and read-only, over the HTTPS API only — no SSH:
|
|
21
|
+
|
|
22
|
+
- `firmware-info` returned `200` with `product_version=26.7.2_2`.
|
|
23
|
+
- `alias-search` returned `200` (keys: `current`, `rowCount`, `rows`, `total`).
|
|
24
|
+
|
|
25
|
+
So the pin is now **measured**, not inferred: the live edge runs `26.7.2_2`,
|
|
26
|
+
one patch ahead of the `26.7.2` tag the model/controller shape was generated
|
|
27
|
+
from — the same series the "patch releases rarely change shape" reasoning
|
|
28
|
+
already relied on, now confirmed rather than assumed. `alias-search`'s 200
|
|
29
|
+
also confirms the live box's Firewall Alias API answers in the shape this SDK
|
|
30
|
+
expects, even though this family reads through `get()`, not `search*` (below).
|
|
31
|
+
|
|
32
|
+
## Resource set, and why these three
|
|
33
|
+
|
|
34
|
+
The SDK covers 8 controllers / 83 operations (Firewall Alias/Category/Group/Filter,
|
|
35
|
+
Routing Gateways, and the `os-frr` plugin's Quagga/BGP). This first import pass
|
|
36
|
+
picks the three whose SDK module exports BOTH a `get()` (the whole model, keyed by
|
|
37
|
+
uuid) and a `search<Item>` operation (a paginated list, OPNsense's own POST-shaped
|
|
38
|
+
read) — genuine "list and get" configuration objects, not runtime state:
|
|
39
|
+
|
|
40
|
+
- **Built**: `firewall_alias`, `firewall_category`, `firewall_group` (interface
|
|
41
|
+
groups, not permission groups).
|
|
42
|
+
- **Skipped, and why**:
|
|
43
|
+
- `firewall_filter` (rules) and `routing_settings` (gateways) export `get()` but
|
|
44
|
+
no `search*` — no distinct list operation, so they miss this pass's own bar.
|
|
45
|
+
Rules are also the most consequential, highest-churn object in the model;
|
|
46
|
+
deferred to its own pass rather than folded in here.
|
|
47
|
+
- `quagga_general` is a global settings singleton, not a collection of items —
|
|
48
|
+
no uuid, nothing to adopt "by id".
|
|
49
|
+
- `quagga_bgp` mixes several item types (aspath, communitylist, neighbor,
|
|
50
|
+
peergroup, prefixlist, redistribution, routemap) with no `search*` for any of
|
|
51
|
+
them — deferred as a unit rather than picking one sub-type arbitrarily.
|
|
52
|
+
- `quagga_service` is pure runtime control (start/stop/restart/status) — the
|
|
53
|
+
task's own "not runtime state" exclusion, textbook case.
|
|
54
|
+
|
|
55
|
+
## Why `get()`, never `getItem`/`searchItem`, for the three that shipped
|
|
56
|
+
|
|
57
|
+
`get()` returns the WHOLE model tree in one GET —
|
|
58
|
+
`{ alias: { aliases: { alias: { <uuid>: AliasItem, ... } } } }` for Alias, and the
|
|
59
|
+
equivalent nesting for Category/Group (see each resource file's header for the
|
|
60
|
+
exact path). Finding one item by uuid is then an object-key lookup, never an
|
|
61
|
+
ambiguous request.
|
|
62
|
+
|
|
63
|
+
That sidesteps two real gaps MEASURED in this early SDK, not a stylistic choice:
|
|
64
|
+
|
|
65
|
+
1. **`firewall_alias` has no per-item get at all** — only `get` (whole model) and
|
|
66
|
+
`searchAlias` (paginated) exist.
|
|
67
|
+
2. **`firewall_category`/`firewall_group` DO export `getCategory`/`getGroup`**,
|
|
68
|
+
but their generated request schema carries no `uuid` path parameter at all
|
|
69
|
+
(`GetCategoryRequest`/`GetGroupRequest` are both `S.Struct({})` against a `uri`
|
|
70
|
+
with no `{uuid}` placeholder) — a generation gap in this SDK, not a design
|
|
71
|
+
choice. Filed as an SDK gap below rather than worked around with a raw path.
|
|
72
|
+
|
|
73
|
+
`searchAlias`/`searchCategory`/`searchGroup` were also passed over for `list()`
|
|
74
|
+
building (they stay unused) because their generated request schema exposes no
|
|
75
|
+
typed limit/offset/searchPhrase at all — a large object set could silently
|
|
76
|
+
page-truncate through a naive use of them, where `get()` has no pagination to
|
|
77
|
+
fall short of.
|
|
78
|
+
|
|
79
|
+
## The policy: reconcile and delete always refuse
|
|
80
|
+
|
|
81
|
+
Tim, 2026-09-24: UniFi and OPNsense are read-only for now. `policy.ts`'s
|
|
82
|
+
`OpnsenseWriteRefused` is the ONLY error `reconcile`/`delete` can raise on any of
|
|
83
|
+
the three resources — neither handler imports an `add`/`set`/`del`/`toggle`
|
|
84
|
+
operation from the SDK. `reconcile` still observes with one `get()` first, so the
|
|
85
|
+
refusal can say whether it would have created or updated; `delete` observes
|
|
86
|
+
nothing and refuses unconditionally. `write-refusal.test.ts` proves it with a fake
|
|
87
|
+
HTTP client that fails the test itself on any non-GET request, exercised through
|
|
88
|
+
the exact `handlers` object each resource's `Provider` wraps — not a lower-level
|
|
89
|
+
seam a real provider bypasses.
|
|
90
|
+
|
|
91
|
+
Lifting this is a kit change (edit `policy.ts`, write the create/update/delete
|
|
92
|
+
bodies these resources do not have), never a prop. There is no `allowWrite` flag.
|
|
93
|
+
|
|
94
|
+
## Adopt is `adopt(true)`, not silent (S7, S8, H1)
|
|
95
|
+
|
|
96
|
+
None of Alias, Category or Group carries an ownership marker OPNsense itself
|
|
97
|
+
understands. Following upstream's `Snippet.ts` reference for a marker-less API
|
|
98
|
+
(the same posture `../discord` documents, not NetBox's silent-adopt gap): a cold
|
|
99
|
+
read (no persisted `output`) that finds a live object at the declared uuid
|
|
100
|
+
returns `Unowned(attrs)`, so declaring it fails `OwnedBySomeoneElse` **unless**
|
|
101
|
+
`adopt(true)` is set. Each resource's convenience constructor
|
|
102
|
+
(`firewallAlias`/`firewallCategory`/`firewallGroup`) pipes `adopt(true)` on by
|
|
103
|
+
default (H5).
|
|
104
|
+
|
|
105
|
+
## The declaration renderer
|
|
106
|
+
|
|
107
|
+
`aliasPropsFromLive`/`categoryPropsFromLive`/`groupPropsFromLive` (the barrel's
|
|
108
|
+
names for each resource file's `propsFromLive`, itself the same function as
|
|
109
|
+
`attributesOf` — see each `*-form.ts`'s header) are pure: given the uuid `get()`'s
|
|
110
|
+
map key names and the item decoded at that key, they return exactly the props a
|
|
111
|
+
declaration needs so `matches` reports noop against it. A future import script
|
|
112
|
+
iterates `Object.entries(map)` from one `get()` call and calls the matching
|
|
113
|
+
renderer once per row to generate an `alchemy.run.ts` row — no such script ships
|
|
114
|
+
in this change.
|
|
115
|
+
|
|
116
|
+
## Credentials
|
|
117
|
+
|
|
118
|
+
`OPNSENSE_URL` / `OPNSENSE_API_KEY` / `OPNSENSE_API_SECRET`, read **at call time**
|
|
119
|
+
via `@distilled.cloud/opnsense/Credentials`'s own `CredentialsFromEnv` (Basic
|
|
120
|
+
auth, the vendor's own scheme), re-exported from `credentials.ts` rather than
|
|
121
|
+
re-implemented. No OpenBao mint yet (H8's target is unreleased); a deploy process
|
|
122
|
+
exports the three variables itself today. This package never reads, logs or
|
|
123
|
+
mints them.
|
|
124
|
+
|
|
125
|
+
The only key that exists today is a **ROOT API key** — not scoped to Firewall
|
|
126
|
+
Alias/Category/Group, or to read-only. That is one more reason "The policy"
|
|
127
|
+
above refuses every write unconditionally rather than trusting a props-level
|
|
128
|
+
flag: any write path this key could reach would carry root's full blast
|
|
129
|
+
radius on the estate's edge firewall, not just these three object types.
|
|
130
|
+
|
|
131
|
+
## SDK gaps
|
|
132
|
+
|
|
133
|
+
- 🔴 **`getCategory`/`getGroup`'s missing `uuid` path parameter** (above) — a
|
|
134
|
+
distilled generation gap, not exercised by this family, worth fixing upstream
|
|
135
|
+
in the distilled clone before a later pass tries to use either operation.
|
|
136
|
+
- **No vendor constraint table**, unlike NetBox/Paperless — this pass did not
|
|
137
|
+
walk `vendor-schema` for OPNsense's field limits (e.g. `CategoryItem.color`'s
|
|
138
|
+
`/^[0-9a-fA-F]{6}$/` pattern); a malformed declaration would only be caught if
|
|
139
|
+
writes were ever lifted, not at plan time.
|
|
140
|
+
- **`password`/`username`/`authtype`/`expire`** (the URL-fetch alias credential
|
|
141
|
+
fields) and **`path_expression`** (`urljson`-only) are left off `AliasProps`
|
|
142
|
+
entirely — S25 for the first four, narrow scope for the fifth. See
|
|
143
|
+
`alias-form.ts`'s header.
|
|
144
|
+
- **No live lifecycle test** (H12): tests run against `fake-opnsense.ts`, a
|
|
145
|
+
loopback fake, not the real edge. This task had no credentials and was told
|
|
146
|
+
not to look for any.
|
|
@@ -124,7 +124,7 @@ The files currently on the wrong side of the line are listed in
|
|
|
124
124
|
| `caddy` | Caddy 2.11.4 | source comments only ⚠️ |
|
|
125
125
|
| `forgejo` | Forgejo 16.0.3 | source comments only ⚠️ |
|
|
126
126
|
| `talos` | Talos v1.13 docs (never measured live) | source comments only ⚠️ |
|
|
127
|
-
| `launchd` / `linux` | macOS 27 / systemd 257
|
|
127
|
+
| `launchd` / `linux` | macOS 27 / systemd 257 / Podman 5.4.2 | source comments only ⚠️ |
|
|
128
128
|
|
|
129
129
|
⚠️ These rows record a version only in prose, so no test catches a drift. `schemas/manifest.json`
|
|
130
130
|
also repeats the PVE, PBS and UniFi entries, which means there are two manifests for one
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# `Podman.Container` — `@homeflare/alchemy/linux`
|
|
2
|
+
|
|
3
|
+
Status: active
|
|
4
|
+
Verified: 2026-09-24
|
|
5
|
+
|
|
6
|
+
One Podman [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)
|
|
7
|
+
`.container` file, declared, plus the systemd unit Podman's own generator
|
|
8
|
+
turns it into — the container half of [linux-host.md](./linux-host.md)'s
|
|
9
|
+
`Systemd.Unit` family, over the same `HostRunner` seam.
|
|
10
|
+
|
|
11
|
+
★ **Why not `Docker.Container`.** Upstream Alchemy's resource was measured and
|
|
12
|
+
rejected for three reasons: no host-network prop (every container CT100 runs
|
|
13
|
+
uses `--network host`), `environment` puts `Redacted` secrets in Alchemy state,
|
|
14
|
+
and systemd already supervises the containers this stack declares. Quadlet
|
|
15
|
+
sidesteps all three: `Network=host` is a real value, `Environment=` is refused
|
|
16
|
+
if it looks like a secret (below), and the GENERATED unit is what systemd
|
|
17
|
+
supervises — this resource only ever declares the `.container` file that
|
|
18
|
+
produces it.
|
|
19
|
+
|
|
20
|
+
## Keys checked against the doc, not invented
|
|
21
|
+
|
|
22
|
+
Every `[Container]` key below is checked against `podman-systemd.unit(5)`,
|
|
23
|
+
**Podman 5.4** (`containers/podman` tag `v5.4.0`,
|
|
24
|
+
`docs/source/markdown/podman-systemd.unit.5.md`, read 2026-09-24) — CT100 runs
|
|
25
|
+
Podman 5.4.2 (`podman --version`, measured over `ssh ct100`, read-only,
|
|
26
|
+
2026-09-24). `[Container]` is typed, unlike `Systemd.Unit`'s freeform
|
|
27
|
+
`sections`, because Quadlet's own schema — unlike bare `systemd.unit(5)` — IS
|
|
28
|
+
one vendor's documented, versioned surface. Adding a key later means checking
|
|
29
|
+
it against the same doc and adding a field; there is no raw `[Container]`
|
|
30
|
+
escape hatch, on purpose (see secrets, below).
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
PodmanContainer('grafana-primary', {
|
|
34
|
+
name: 'grafana-primary',
|
|
35
|
+
container: {
|
|
36
|
+
containerName: 'grafana-primary',
|
|
37
|
+
environmentFile: ['/etc/grafana-primary/env'],
|
|
38
|
+
image: 'docker.io/grafana/grafana:12.2.1',
|
|
39
|
+
network: 'host',
|
|
40
|
+
volume: ['grafana-primary-data:/var/lib/grafana'],
|
|
41
|
+
},
|
|
42
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
43
|
+
service: { restart: 'always', restartSec: '5s' },
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Supported `[Container]` keys: `Image`, `ContainerName`, `Network` (supports
|
|
48
|
+
`'host'`), `Volume` (repeatable), `EnvironmentFile` (repeatable), `Environment`
|
|
49
|
+
(repeatable `KEY=VALUE`, never a secret), `PublishPort` (repeatable, refused
|
|
50
|
+
together with `network: 'host'`), `Exec`, `User`, `AutoUpdate`
|
|
51
|
+
(`'registry' | 'local'`), `PodmanArgs` (repeatable). `[Unit]`
|
|
52
|
+
(`Description`/`Documentation` typed, everything else verbatim lines — same
|
|
53
|
+
approach `Systemd.Unit` uses for the whole file, because these three sections
|
|
54
|
+
stay systemd's own directive set even inside a `.container` file), `[Service]`
|
|
55
|
+
(`Restart`/`RestartSec` typed, everything else verbatim), and `[Install]`
|
|
56
|
+
(`WantedBy`/`RequiredBy`/`Alias` — the only three keys Quadlet honours for a
|
|
57
|
+
`.container` file; NOT `Also`, which plain `systemd.unit(5)` allows).
|
|
58
|
+
|
|
59
|
+
## Secrets: `EnvironmentFile=`, never `Environment=`
|
|
60
|
+
|
|
61
|
+
⛔ **A secret belongs in a host path an out-of-band renderer maintains**
|
|
62
|
+
(openbao-agent), named in `container.environmentFile` — never in
|
|
63
|
+
`container.environment`, which is rendered into a 0644 file and stored
|
|
64
|
+
unencrypted in Alchemy state, the same two reasons `Systemd.Unit`'s `content`
|
|
65
|
+
is never secret.
|
|
66
|
+
|
|
67
|
+
⚠️ **The refusal is best-effort, not a scanner** — `container-secrets.ts`. It
|
|
68
|
+
refuses an `Environment=` key whose NAME says what it holds (`SECRET`,
|
|
69
|
+
`TOKEN`, `PASSWORD`, `API_KEY`, …) and a handful of value shapes real vendor
|
|
70
|
+
tokens take (`Bearer `, `ghp_`, `AKIA`, a JWT's `eyJ`, …). It cannot prove a
|
|
71
|
+
value is safe, only refuse the obvious mistake — `container-secrets.test.ts`
|
|
72
|
+
proves the refusal fires before any write, and that the renderer itself has no
|
|
73
|
+
independent filter (validation is the only backstop, which is why every write
|
|
74
|
+
path calls it first).
|
|
75
|
+
|
|
76
|
+
## Lifecycle — where it differs from `Systemd.Unit`
|
|
77
|
+
|
|
78
|
+
Order is **write, `daemon-reload`, VERIFY the generator, then start or restart
|
|
79
|
+
the generated `<name>.service` only for a real change** — see
|
|
80
|
+
`container-lifecycle.ts` and `container-generator.ts` for the full reasoning,
|
|
81
|
+
each doc-cited. In brief:
|
|
82
|
+
|
|
83
|
+
- ⛔ **The generated unit is never `systemctl enable`d.** MEASURED on CT100,
|
|
84
|
+
2026-09-24: `caddy.container` declares `[Install] WantedBy=multi-user.target`
|
|
85
|
+
and nothing ever ran `systemctl enable caddy`; `systemctl is-enabled
|
|
86
|
+
caddy.service` prints `generated`, and
|
|
87
|
+
`/run/systemd/generator/multi-user.target.wants/caddy.service` is a symlink
|
|
88
|
+
the GENERATOR put there — doc-confirmed (`podman-systemd.unit(5)`): a
|
|
89
|
+
generated unit is transient, and `systemctl enable` "does not work" on one;
|
|
90
|
+
Quadlet applies `[Install]` itself, at every `daemon-reload`. So there is no
|
|
91
|
+
`enabled` prop: declaring `install.wantedBy` **is** the enablement.
|
|
92
|
+
- ⛔ **A generator failure is a typed `QuadletGeneratorError`, never "absent".**
|
|
93
|
+
`systemd.generator(7)` (tag `v257`): `daemon-reload` deletes ALL generator
|
|
94
|
+
output and reruns every generator from the files on disk NOW. So a
|
|
95
|
+
`.container` file Quadlet refuses has no generated unit at all — even one
|
|
96
|
+
that generated fine a moment ago. An UPDATE that fails verification
|
|
97
|
+
therefore restores the LAST-KNOWN-GOOD file and reloads again, rather than
|
|
98
|
+
leaving the bad file for the next deploy to retry (`Systemd.Unit`'s own
|
|
99
|
+
choice, unit-lifecycle.ts): here "the next deploy" might be a reboot, and by
|
|
100
|
+
then the container this resource already promised running has no unit at
|
|
101
|
+
all. A CREATE that fails removes the file it wrote, same as `Systemd.Unit`.
|
|
102
|
+
- ⚠️ **The one open question.** Whether an ALREADY-ACTIVE unit's `LoadState`
|
|
103
|
+
stays `loaded` through a failed regeneration (systemd need not
|
|
104
|
+
garbage-collect a referenced unit) was not measured — doing so needs writing
|
|
105
|
+
a broken file to a live host, which read-only access forbids. If it does,
|
|
106
|
+
`LoadState`/`SourcePath` alone would not catch a failed update to a
|
|
107
|
+
container that was already running; the restore-last-known-good step does
|
|
108
|
+
not depend on catching it, since it always leaves the host at a file known
|
|
109
|
+
to generate.
|
|
110
|
+
- `SourcePath` (added to `systemctl.ts`'s `SHOW_PROPERTIES`, additive for
|
|
111
|
+
`Systemd.Unit`) ties the generated unit back to the `.container` file —
|
|
112
|
+
MEASURED on CT100: `SourcePath=/etc/containers/systemd/caddy.container`. A
|
|
113
|
+
mismatch means a same-named file earlier in Quadlet's search path is
|
|
114
|
+
generating the unit instead of ours — a takeover, not a success.
|
|
115
|
+
- Directory allowlist is Quadlet's OWN rootful search path (doc, "Podman Unit
|
|
116
|
+
Search Path"): `/run/containers/systemd`, `/etc/containers/systemd`,
|
|
117
|
+
`/usr/share/containers/systemd` — CT100's `caddy.container` lives in the
|
|
118
|
+
middle one.
|
|
119
|
+
- Adopt, restart-on-change, delete (stop, remove the file, reload — no
|
|
120
|
+
`disable`, since it was never enabled through systemctl) and the rename
|
|
121
|
+
(delete-first replace) contract are otherwise identical to `Systemd.Unit`'s
|
|
122
|
+
— see [linux-host.md](./linux-host.md).
|
|
123
|
+
|
|
124
|
+
## The CT100 fixture
|
|
125
|
+
|
|
126
|
+
`container-fixture.test.ts` renders the same directives as CT100's live
|
|
127
|
+
`caddy.container` (read read-only over `ssh ct100`, 2026-09-24) and checks
|
|
128
|
+
every line matches. Not byte-for-byte: CT100's file interleaves long prose
|
|
129
|
+
comments between directives, and a typed `[Container]` has nowhere to attach
|
|
130
|
+
"this comment goes between these two keys" — the same limit `Systemd.Unit`'s
|
|
131
|
+
`renderUnit` has for the identical reason. The test proves directive parity;
|
|
132
|
+
the file explains why byte parity is not the right bar for a typed resource.
|