@homeflare/alchemy 0.31.2 → 0.32.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.
Files changed (140) hide show
  1. package/README.md +39 -3
  2. package/dist/linux/container-form.d.ts +129 -0
  3. package/dist/linux/container-form.d.ts.map +1 -0
  4. package/dist/linux/container-generator.d.ts +74 -0
  5. package/dist/linux/container-generator.d.ts.map +1 -0
  6. package/dist/linux/container-handlers.d.ts +15 -0
  7. package/dist/linux/container-handlers.d.ts.map +1 -0
  8. package/dist/linux/container-lifecycle.d.ts +37 -0
  9. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  10. package/dist/linux/container-preflight.d.ts +34 -0
  11. package/dist/linux/container-preflight.d.ts.map +1 -0
  12. package/dist/linux/container-secrets.d.ts +37 -0
  13. package/dist/linux/container-secrets.d.ts.map +1 -0
  14. package/dist/linux/container-settle.d.ts +34 -0
  15. package/dist/linux/container-settle.d.ts.map +1 -0
  16. package/dist/linux/container.d.ts +30 -0
  17. package/dist/linux/container.d.ts.map +1 -0
  18. package/dist/linux/fake-quadlet.d.ts +25 -0
  19. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  20. package/dist/linux/index.d.ts +6 -3
  21. package/dist/linux/index.d.ts.map +1 -1
  22. package/dist/linux/index.js +641 -268
  23. package/dist/linux/index.js.map +15 -7
  24. package/dist/linux/providers.d.ts +1 -1
  25. package/dist/linux/providers.d.ts.map +1 -1
  26. package/dist/linux/systemctl.d.ts +7 -1
  27. package/dist/linux/systemctl.d.ts.map +1 -1
  28. package/dist/opnsense/alias-form.d.ts +29 -0
  29. package/dist/opnsense/alias-form.d.ts.map +1 -0
  30. package/dist/opnsense/alias.d.ts +81 -0
  31. package/dist/opnsense/alias.d.ts.map +1 -0
  32. package/dist/opnsense/category-form.d.ts +10 -0
  33. package/dist/opnsense/category-form.d.ts.map +1 -0
  34. package/dist/opnsense/category.d.ts +58 -0
  35. package/dist/opnsense/category.d.ts.map +1 -0
  36. package/dist/opnsense/credentials.d.ts +36 -0
  37. package/dist/opnsense/credentials.d.ts.map +1 -0
  38. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  39. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  40. package/dist/opnsense/group-form.d.ts +11 -0
  41. package/dist/opnsense/group-form.d.ts.map +1 -0
  42. package/dist/opnsense/group.d.ts +62 -0
  43. package/dist/opnsense/group.d.ts.map +1 -0
  44. package/dist/opnsense/index.d.ts +16 -0
  45. package/dist/opnsense/index.d.ts.map +1 -0
  46. package/dist/opnsense/index.js +211 -0
  47. package/dist/opnsense/index.js.map +20 -0
  48. package/dist/opnsense/policy.d.ts +20 -0
  49. package/dist/opnsense/policy.d.ts.map +1 -0
  50. package/dist/opnsense/providers.d.ts +13 -0
  51. package/dist/opnsense/providers.d.ts.map +1 -0
  52. package/dist/opnsense/resource.d.ts +74 -0
  53. package/dist/opnsense/resource.d.ts.map +1 -0
  54. package/dist/opnsense/wire.d.ts +39 -0
  55. package/dist/opnsense/wire.d.ts.map +1 -0
  56. package/dist/proxmox/index.js +106 -47
  57. package/dist/proxmox/index.js.map +6 -4
  58. package/dist/proxmox/storage-form.d.ts +54 -0
  59. package/dist/proxmox/storage-form.d.ts.map +1 -0
  60. package/dist/proxmox/storage-wire.d.ts +56 -0
  61. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  62. package/dist/proxmox/storage.d.ts +55 -60
  63. package/dist/proxmox/storage.d.ts.map +1 -1
  64. package/dist/unifi/fake-unifi.d.ts +48 -0
  65. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  66. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  67. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  68. package/dist/unifi/firewall-zone.d.ts +50 -0
  69. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  70. package/dist/unifi/index.d.ts +13 -0
  71. package/dist/unifi/index.d.ts.map +1 -0
  72. package/dist/unifi/index.js +190 -0
  73. package/dist/unifi/index.js.map +16 -0
  74. package/dist/unifi/network-form.d.ts +67 -0
  75. package/dist/unifi/network-form.d.ts.map +1 -0
  76. package/dist/unifi/network.d.ts +67 -0
  77. package/dist/unifi/network.d.ts.map +1 -0
  78. package/dist/unifi/policy.d.ts +20 -0
  79. package/dist/unifi/policy.d.ts.map +1 -0
  80. package/dist/unifi/providers.d.ts +13 -0
  81. package/dist/unifi/providers.d.ts.map +1 -0
  82. package/dist/unifi/resource.d.ts +64 -0
  83. package/dist/unifi/resource.d.ts.map +1 -0
  84. package/docs/opnsense.md +146 -0
  85. package/docs/provider-standard.md +1 -1
  86. package/docs/quadlet-container.md +132 -0
  87. package/docs/unifi.md +93 -0
  88. package/package.json +12 -2
  89. package/src/linux/container-fixture.test.ts +92 -0
  90. package/src/linux/container-form.ts +243 -0
  91. package/src/linux/container-generator.test.ts +195 -0
  92. package/src/linux/container-generator.ts +113 -0
  93. package/src/linux/container-handlers.ts +73 -0
  94. package/src/linux/container-lifecycle.test.ts +209 -0
  95. package/src/linux/container-lifecycle.ts +217 -0
  96. package/src/linux/container-preflight.ts +97 -0
  97. package/src/linux/container-secrets.test.ts +171 -0
  98. package/src/linux/container-secrets.ts +112 -0
  99. package/src/linux/container-settle.ts +97 -0
  100. package/src/linux/container.ts +60 -0
  101. package/src/linux/fake-quadlet.ts +127 -0
  102. package/src/linux/index.ts +11 -3
  103. package/src/linux/providers.ts +2 -0
  104. package/src/linux/systemctl.ts +9 -0
  105. package/src/opnsense/alias-form.test.ts +89 -0
  106. package/src/opnsense/alias-form.ts +53 -0
  107. package/src/opnsense/alias.test.ts +203 -0
  108. package/src/opnsense/alias.ts +117 -0
  109. package/src/opnsense/category-form.test.ts +54 -0
  110. package/src/opnsense/category-form.ts +20 -0
  111. package/src/opnsense/category.test.ts +138 -0
  112. package/src/opnsense/category.ts +82 -0
  113. package/src/opnsense/credentials.ts +41 -0
  114. package/src/opnsense/fake-opnsense.ts +79 -0
  115. package/src/opnsense/group-form.test.ts +75 -0
  116. package/src/opnsense/group-form.ts +25 -0
  117. package/src/opnsense/group.test.ts +141 -0
  118. package/src/opnsense/group.ts +78 -0
  119. package/src/opnsense/index.ts +45 -0
  120. package/src/opnsense/policy.ts +49 -0
  121. package/src/opnsense/providers.ts +21 -0
  122. package/src/opnsense/resource.ts +146 -0
  123. package/src/opnsense/wire.ts +56 -0
  124. package/src/opnsense/write-refusal.test.ts +148 -0
  125. package/src/proxmox/storage-form.ts +144 -0
  126. package/src/proxmox/storage-read-failure.test.ts +214 -0
  127. package/src/proxmox/storage-wire.ts +124 -0
  128. package/src/proxmox/storage.ts +165 -175
  129. package/src/unifi/fake-unifi.ts +60 -0
  130. package/src/unifi/firewall-zone-form.ts +65 -0
  131. package/src/unifi/firewall-zone.test.ts +88 -0
  132. package/src/unifi/firewall-zone.ts +66 -0
  133. package/src/unifi/index.ts +26 -0
  134. package/src/unifi/network-form.ts +207 -0
  135. package/src/unifi/network.test.ts +209 -0
  136. package/src/unifi/network.ts +83 -0
  137. package/src/unifi/policy.ts +37 -0
  138. package/src/unifi/providers.ts +15 -0
  139. package/src/unifi/resource.test.ts +133 -0
  140. package/src/unifi/resource.ts +131 -0
@@ -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 | source comments only ⚠️ |
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.
package/docs/unifi.md ADDED
@@ -0,0 +1,93 @@
1
+ # UniFi Network — `@homeflare/alchemy/unifi`
2
+
3
+ ⛔ **READ-ONLY, BY TIM'S RULE (2026-09-24).** `Unifi.Network` and `Unifi.FirewallZone` read and
4
+ adopt; neither has a create, an update body or a working delete. `reconcile`/`delete` both fail
5
+ with a typed `UnifiWriteRefused` naming the policy rather than calling any SDK write operation.
6
+ Lifting the rule is a kit change — a PR that adds a write path, reviewed as one — not a flag a
7
+ stack can pass. See `packages/alchemy/src/unifi/policy.ts`.
8
+
9
+ ## What's declared, and why these two first
10
+
11
+ `Unifi.Network` (`getNetworkDetails`/`getNetworksOverviewPage`) and `Unifi.FirewallZone`
12
+ (`getFirewallZone`/`getFirewallZones`) — both list+get pairs over genuine, stable configuration,
13
+ not runtime state (a connected client, a device statistic, a hotspot voucher, a switch stack).
14
+ `Site` was the SDK README's other first suggestion; it has no `getSite`, only the list operation,
15
+ so it fails this house's own "both a list and a get" bar for a first import. `FirewallPolicy` is
16
+ also a list+get config object, but its `source`/`destination`/`ipProtocolScope` fields carry
17
+ several of the vendor's converter-flattened discriminator variants each, and its ordering
18
+ endpoint replaces the whole rule list — modelling that correctly is a bigger, separate PR.
19
+
20
+ ## Credentials
21
+
22
+ Two environment variables, read **at call time**, never props — the SDK's own
23
+ `CredentialsFromEnv` (`@distilled.cloud/unifi-network/Credentials`):
24
+
25
+ | variable | holds |
26
+ | ---------------------------- | --------------------------------------------------- |
27
+ | `UNIFI_NETWORK_API_KEY` | an Integrations → API Key from the console's own UI |
28
+ | `UNIFI_NETWORK_API_BASE_URL` | the console's integration API root — see below |
29
+
30
+ ⛔ **Neither has a default**, and neither is ever logged, committed or persisted in state.
31
+
32
+ ⛔ **No console identifier, endpoint host or other live value ever appears in code, tests,
33
+ fixtures, commits or a PR.** The base URL shape depends on how the console is reached: a cloud
34
+ connector's is `https://api.ui.com/v1/connector/consoles/<consoleId>/proxy/network/integration`
35
+ (`<consoleId>` is an account identifier); a local console is reached directly,
36
+ `<endpoint>/proxy/network/integration`, where `<endpoint>` is a hostname behind a TLS certificate
37
+ the caller trusts. Treat any UniFi base URL as sensitive; this package's tests use only the RFC
38
+ 2606 placeholder `https://unifi.example.com`.
39
+
40
+ The estate's own credential lives in OpenBao at `kv/infra/unifi/api` — fields `api_key`,
41
+ `connector_id`, `console`, `endpoint`, `site`, `transport` (`transport=local`: this console is
42
+ reached directly, not through the cloud connector). A separate `kv/infra/unifi/site-manager`
43
+ token is the _cloud_ Site Manager API credential, a different door (below) this family never
44
+ reads.
45
+
46
+ ✅ **Auth was measured live, read-only, on 2026-09-24.** The OpenAPI document declares no
47
+ `securitySchemes` at all — `X-API-KEY` comes from Ubiquiti's own Integration API guide, not the
48
+ machine-readable spec (`@distilled.cloud/unifi-network/src/credentials.ts`'s own header) — but a
49
+ hand-run, read-only probe confirmed it works against the estate's local console: `X-API-KEY`
50
+ returns HTTP 200 on both `GET /v1/info` (key: `applicationVersion`) and `GET /v1/sites` (keys:
51
+ `count`, `data`, `limit`, `offset`, `totalCount`). The _same_ key against the `api.ui.com` cloud
52
+ connector returns 401 — the wrong door for a key minted on a local console, not evidence the
53
+ cloud shape rejects `X-API-KEY` outright; `kv/infra/unifi/site-manager` is the separate cloud
54
+ credential, untouched by this family. Only reads were probed, by hand, outside this package's own
55
+ test suite — nothing in this PR's code exercises the vendor API live, and a write path would still
56
+ need its own verification.
57
+
58
+ ## Adopt is the default posture, and it never writes
59
+
60
+ `adopt(true)` is piped onto every resource by its convenience constructor (`network(...)`,
61
+ `firewallZone(...)`) — H5. A cold read of a live match answers `Unowned(attrs)` (H1, the
62
+ `Cloudflare/Snippets/Snippet.ts` reference every marker-less family here follows: UniFi Network
63
+ objects carry no tag or metadata field this stack could stamp), and `adopt(true)` turns that into
64
+ a silent bind instead of an `OwnedBySomeoneElse` refusal — the deploy never needs `--adopt`.
65
+
66
+ ⚠️ **A forced post-adoption reconcile still runs once (H6), and it still makes no write.**
67
+ Beta.79 forces one `reconcile` call after every cold adoption whether or not `diff` said noop.
68
+ `resource.ts`'s `reconcile` handles this the same way `discord/resource.ts` does: it reads the
69
+ live object again and only refuses when it is missing (would need a create) or drifted (would
70
+ need a write); an exact match returns the live attributes and calls nothing.
71
+
72
+ ## `list` answers `[]`
73
+
74
+ `GET /v1/sites/{siteId}/networks` answers every network on the site. Adoption stays explicit —
75
+ the same reasoning `Proxmox.User`'s and NetBox's own `list` give.
76
+
77
+ ## The declaration renderer
78
+
79
+ `declareNetwork(live, siteId)` and `declareFirewallZone(live, siteId)` are pure functions: given
80
+ one `getNetworkDetails`/`getFirewallZone` response, they return the `Props` a declaration needs
81
+ so its plan is a no-op. A later import script reads every object on a site and writes one
82
+ `network('<id>', declareNetwork(live, siteId))` row per result into `alchemy.run.ts` — nothing
83
+ here performs that write; these two functions are what the script would call.
84
+
85
+ ## Vendor version
86
+
87
+ UniFi Network Integration API **10.4.57** (OpenAPI 3.1.0, 44 paths), via
88
+ `@distilled.cloud/unifi-network` aliased onto `@homeflare/distilled-unifi-network@0.2.0`
89
+ (`docs/distilled-interim.md`). Traps in the underlying API that this read-only family cannot
90
+ trigger but a future write path must respect — whole-object `PUT`, ordering endpoints that
91
+ replace the whole list, `removeDevice` unadopting and factory-resetting hardware — are recorded
92
+ in the distilled package's own `README.md` and in the estate's `docs/unifi-api-notes.md`
93
+ (outside this repo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -100,6 +100,14 @@
100
100
  "types": "./dist/discord/index.d.ts",
101
101
  "default": "./dist/discord/index.js"
102
102
  },
103
+ "./unifi": {
104
+ "types": "./dist/unifi/index.d.ts",
105
+ "default": "./dist/unifi/index.js"
106
+ },
107
+ "./opnsense": {
108
+ "types": "./dist/opnsense/index.d.ts",
109
+ "default": "./dist/opnsense/index.js"
110
+ },
103
111
  "./package.json": "./package.json"
104
112
  },
105
113
  "publishConfig": {
@@ -110,9 +118,11 @@
110
118
  "@distilled.cloud/grafana": "npm:@homeflare/distilled-grafana@0.2.0",
111
119
  "@distilled.cloud/litellm": "npm:@homeflare/distilled-litellm@0.2.0",
112
120
  "@distilled.cloud/netbox": "npm:@homeflare/distilled-netbox@0.3.0",
121
+ "@distilled.cloud/opnsense": "npm:@homeflare/distilled-opnsense@0.2.0",
113
122
  "@distilled.cloud/paperless-ngx": "npm:@homeflare/distilled-paperless-ngx@0.3.0",
114
123
  "@distilled.cloud/proxmox": "npm:@homeflare/distilled-proxmox@0.2.0",
115
- "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.2.0"
124
+ "@distilled.cloud/proxmox-backup": "npm:@homeflare/distilled-proxmox-backup@0.2.0",
125
+ "@distilled.cloud/unifi-network": "npm:@homeflare/distilled-unifi-network@0.2.0"
116
126
  },
117
127
  "peerDependencies": {
118
128
  "@distilled.cloud/cloudflare": "1.0.0-rc.12",
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Renders the SAME directives as CT100's live `caddy.container`
3
+ * (`/etc/containers/systemd/caddy.container`, read read-only over `ssh ct100` 2026-09-24) and checks
4
+ * every line against that fixture.
5
+ *
6
+ * ⚠️ NOT BYTE-FOR-BYTE, AND HERE IS EXACTLY WHY NOT. `renderContainerFile` renders a TYPED
7
+ * `ContainerProps` — the whole point of this family (container-form.ts's header) — and CT100's
8
+ * file interleaves long prose comments BETWEEN directives inside `[Unit]` and `[Container]`
9
+ * (why host networking, the OpenBao-unreachable note, the rate-limit warning). There is nowhere
10
+ * in a typed prop to attach "this comment goes between EnvironmentFile= and Environment=": the
11
+ * comments are the file's OWN authored documentation, not data this resource's props carry.
12
+ * `Systemd.Unit` has the identical limit for the same reason — `unit-form.ts`'s `renderUnit`
13
+ * is lines-in-order with no comment slots either. So this fixture proves DIRECTIVE parity
14
+ * (every key, in a stable order, with CT100's exact values) rather than text-diff parity; the
15
+ * `[Unit]`/`[Service]`/`[Install]` VALUES below are copied verbatim from CT100's file, and the
16
+ * `[Container]` ones from CT100's `[X-Container]` section (Quadlet 5.x renders that heading;
17
+ * this family emits the pre-5.x `[Container]` spelling, both documented (podman-systemd.unit(5))).
18
+ */
19
+ import { expect, test } from 'bun:test';
20
+ import { type ContainerProps, renderContainerFile } from './container-form.ts';
21
+
22
+ // ★ Copied verbatim from CT100's /etc/containers/systemd/caddy.container, 2026-09-24.
23
+ const caddy: ContainerProps = {
24
+ container: {
25
+ containerName: 'caddy',
26
+ environment: { CADDY_CONFIG: '/etc/caddy/Caddyfile' },
27
+ environmentFile: ['/etc/caddy/cf-token.env'],
28
+ image: 'localhost/homeflare/caddy:2.11.4-hf1',
29
+ network: 'host',
30
+ volume: [
31
+ '/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
32
+ '/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
33
+ '/etc/caddy/cf-token.env:/run/cf/token.env:ro',
34
+ 'caddy-data:/data',
35
+ 'caddy-config:/config',
36
+ ],
37
+ },
38
+ install: { wantedBy: ['multi-user.target'] },
39
+ name: 'caddy',
40
+ service: { restart: 'always', restartSec: '5s' },
41
+ unit: {
42
+ description: 'HomeFlare TLS front door (Caddy, DNS-01)',
43
+ documentation:
44
+ 'https://gitlab.homeflare.dev/homeflare/homeflare-config/-/tree/main/hosts/ct100/caddy',
45
+ lines: [
46
+ ['After', 'network-online.target'],
47
+ ['Wants', 'network-online.target'],
48
+ ['After', 'caddy-cf-token.service'],
49
+ ['Wants', 'caddy-cf-token.service'],
50
+ ],
51
+ },
52
+ };
53
+
54
+ test('renders every directive CT100’s live caddy.container carries', () => {
55
+ const rendered = renderContainerFile(caddy);
56
+ const expected = [
57
+ '[Unit]',
58
+ 'Description=HomeFlare TLS front door (Caddy, DNS-01)',
59
+ 'Documentation=https://gitlab.homeflare.dev/homeflare/homeflare-config/-/tree/main/hosts/ct100/caddy',
60
+ 'After=network-online.target',
61
+ 'Wants=network-online.target',
62
+ 'After=caddy-cf-token.service',
63
+ 'Wants=caddy-cf-token.service',
64
+ '[Container]',
65
+ 'Image=localhost/homeflare/caddy:2.11.4-hf1',
66
+ 'ContainerName=caddy',
67
+ 'Network=host',
68
+ 'EnvironmentFile=/etc/caddy/cf-token.env',
69
+ 'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
70
+ 'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
71
+ 'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
72
+ 'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',
73
+ 'Volume=caddy-data:/data',
74
+ 'Volume=caddy-config:/config',
75
+ '[Service]',
76
+ 'Restart=always',
77
+ 'RestartSec=5s',
78
+ '[Install]',
79
+ 'WantedBy=multi-user.target',
80
+ ];
81
+ for (const line of expected) expect(rendered).toContain(line);
82
+ // Every non-blank, non-header line in our render is one of the expected lines — nothing invented.
83
+ const ours = rendered.split('\n').filter((line) => line !== '' && !line.startsWith('['));
84
+ expect(ours.sort()).toEqual(expected.filter((line) => !line.startsWith('[')).sort());
85
+ });
86
+
87
+ test('no comment lines — the one documented gap, not an accident', () => {
88
+ // Quadlet accepts `#`/`;` comments in a .container file; renderUnit (unit-form.ts) never emits
89
+ // one, in this family or Systemd.Unit's. CT100's own comments live only in the file this
90
+ // resource would MANAGE, never in state — restating them is a future prop, not a bug here.
91
+ expect(renderContainerFile(caddy)).not.toMatch(/^\s*[#;]/m);
92
+ });