@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.
Files changed (184) hide show
  1. package/README.md +39 -3
  2. package/dist/grafana/dashboard-model.d.ts +77 -0
  3. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  4. package/dist/grafana/dashboard.d.ts +98 -0
  5. package/dist/grafana/dashboard.d.ts.map +1 -0
  6. package/dist/grafana/folder.d.ts +100 -0
  7. package/dist/grafana/folder.d.ts.map +1 -0
  8. package/dist/grafana/index.d.ts +9 -8
  9. package/dist/grafana/index.d.ts.map +1 -1
  10. package/dist/grafana/index.js +158 -6
  11. package/dist/grafana/index.js.map +8 -4
  12. package/dist/grafana/providers.d.ts +1 -1
  13. package/dist/grafana/providers.d.ts.map +1 -1
  14. package/dist/grafana/provisioned.d.ts +11 -0
  15. package/dist/grafana/provisioned.d.ts.map +1 -0
  16. package/dist/linux/container-form.d.ts +129 -0
  17. package/dist/linux/container-form.d.ts.map +1 -0
  18. package/dist/linux/container-generator.d.ts +74 -0
  19. package/dist/linux/container-generator.d.ts.map +1 -0
  20. package/dist/linux/container-handlers.d.ts +15 -0
  21. package/dist/linux/container-handlers.d.ts.map +1 -0
  22. package/dist/linux/container-lifecycle.d.ts +37 -0
  23. package/dist/linux/container-lifecycle.d.ts.map +1 -0
  24. package/dist/linux/container-preflight.d.ts +34 -0
  25. package/dist/linux/container-preflight.d.ts.map +1 -0
  26. package/dist/linux/container-secrets.d.ts +37 -0
  27. package/dist/linux/container-secrets.d.ts.map +1 -0
  28. package/dist/linux/container-settle.d.ts +34 -0
  29. package/dist/linux/container-settle.d.ts.map +1 -0
  30. package/dist/linux/container.d.ts +30 -0
  31. package/dist/linux/container.d.ts.map +1 -0
  32. package/dist/linux/fake-quadlet.d.ts +25 -0
  33. package/dist/linux/fake-quadlet.d.ts.map +1 -0
  34. package/dist/linux/index.d.ts +6 -3
  35. package/dist/linux/index.d.ts.map +1 -1
  36. package/dist/linux/index.js +641 -268
  37. package/dist/linux/index.js.map +15 -7
  38. package/dist/linux/providers.d.ts +1 -1
  39. package/dist/linux/providers.d.ts.map +1 -1
  40. package/dist/linux/systemctl.d.ts +7 -1
  41. package/dist/linux/systemctl.d.ts.map +1 -1
  42. package/dist/opnsense/alias-form.d.ts +29 -0
  43. package/dist/opnsense/alias-form.d.ts.map +1 -0
  44. package/dist/opnsense/alias.d.ts +81 -0
  45. package/dist/opnsense/alias.d.ts.map +1 -0
  46. package/dist/opnsense/category-form.d.ts +10 -0
  47. package/dist/opnsense/category-form.d.ts.map +1 -0
  48. package/dist/opnsense/category.d.ts +58 -0
  49. package/dist/opnsense/category.d.ts.map +1 -0
  50. package/dist/opnsense/credentials.d.ts +36 -0
  51. package/dist/opnsense/credentials.d.ts.map +1 -0
  52. package/dist/opnsense/fake-opnsense.d.ts +51 -0
  53. package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
  54. package/dist/opnsense/group-form.d.ts +11 -0
  55. package/dist/opnsense/group-form.d.ts.map +1 -0
  56. package/dist/opnsense/group.d.ts +62 -0
  57. package/dist/opnsense/group.d.ts.map +1 -0
  58. package/dist/opnsense/index.d.ts +16 -0
  59. package/dist/opnsense/index.d.ts.map +1 -0
  60. package/dist/opnsense/index.js +211 -0
  61. package/dist/opnsense/index.js.map +20 -0
  62. package/dist/opnsense/policy.d.ts +20 -0
  63. package/dist/opnsense/policy.d.ts.map +1 -0
  64. package/dist/opnsense/providers.d.ts +13 -0
  65. package/dist/opnsense/providers.d.ts.map +1 -0
  66. package/dist/opnsense/resource.d.ts +74 -0
  67. package/dist/opnsense/resource.d.ts.map +1 -0
  68. package/dist/opnsense/wire.d.ts +39 -0
  69. package/dist/opnsense/wire.d.ts.map +1 -0
  70. package/dist/proxmox/index.js +170 -80
  71. package/dist/proxmox/index.js.map +9 -7
  72. package/dist/proxmox/storage-form.d.ts +54 -0
  73. package/dist/proxmox/storage-form.d.ts.map +1 -0
  74. package/dist/proxmox/storage-wire.d.ts +56 -0
  75. package/dist/proxmox/storage-wire.d.ts.map +1 -0
  76. package/dist/proxmox/storage.d.ts +55 -60
  77. package/dist/proxmox/storage.d.ts.map +1 -1
  78. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  79. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  80. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  81. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  82. package/dist/proxmox/zfs-pool.d.ts +27 -52
  83. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  84. package/dist/unifi/fake-unifi.d.ts +48 -0
  85. package/dist/unifi/fake-unifi.d.ts.map +1 -0
  86. package/dist/unifi/firewall-zone-form.d.ts +40 -0
  87. package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
  88. package/dist/unifi/firewall-zone.d.ts +50 -0
  89. package/dist/unifi/firewall-zone.d.ts.map +1 -0
  90. package/dist/unifi/index.d.ts +13 -0
  91. package/dist/unifi/index.d.ts.map +1 -0
  92. package/dist/unifi/index.js +190 -0
  93. package/dist/unifi/index.js.map +16 -0
  94. package/dist/unifi/network-form.d.ts +67 -0
  95. package/dist/unifi/network-form.d.ts.map +1 -0
  96. package/dist/unifi/network.d.ts +67 -0
  97. package/dist/unifi/network.d.ts.map +1 -0
  98. package/dist/unifi/policy.d.ts +20 -0
  99. package/dist/unifi/policy.d.ts.map +1 -0
  100. package/dist/unifi/providers.d.ts +13 -0
  101. package/dist/unifi/providers.d.ts.map +1 -0
  102. package/dist/unifi/resource.d.ts +64 -0
  103. package/dist/unifi/resource.d.ts.map +1 -0
  104. package/docs/grafana-folder-dashboard.md +145 -0
  105. package/docs/grafana.md +11 -10
  106. package/docs/opnsense.md +146 -0
  107. package/docs/provider-standard.md +1 -1
  108. package/docs/quadlet-container.md +132 -0
  109. package/docs/unifi.md +93 -0
  110. package/docs/upstream-conformance.md +9 -4
  111. package/package.json +12 -2
  112. package/src/grafana/dashboard-model.test.ts +111 -0
  113. package/src/grafana/dashboard-model.ts +111 -0
  114. package/src/grafana/dashboard.test.ts +232 -0
  115. package/src/grafana/dashboard.ts +161 -0
  116. package/src/grafana/folder.test.ts +212 -0
  117. package/src/grafana/folder.ts +161 -0
  118. package/src/grafana/index.ts +21 -8
  119. package/src/grafana/providers.ts +7 -1
  120. package/src/grafana/provisioned.ts +49 -0
  121. package/src/linux/container-fixture.test.ts +92 -0
  122. package/src/linux/container-form.ts +243 -0
  123. package/src/linux/container-generator.test.ts +195 -0
  124. package/src/linux/container-generator.ts +113 -0
  125. package/src/linux/container-handlers.ts +73 -0
  126. package/src/linux/container-lifecycle.test.ts +209 -0
  127. package/src/linux/container-lifecycle.ts +217 -0
  128. package/src/linux/container-preflight.ts +97 -0
  129. package/src/linux/container-secrets.test.ts +171 -0
  130. package/src/linux/container-secrets.ts +112 -0
  131. package/src/linux/container-settle.ts +97 -0
  132. package/src/linux/container.ts +60 -0
  133. package/src/linux/fake-quadlet.ts +127 -0
  134. package/src/linux/index.ts +11 -3
  135. package/src/linux/providers.ts +2 -0
  136. package/src/linux/systemctl.ts +9 -0
  137. package/src/opnsense/alias-form.test.ts +89 -0
  138. package/src/opnsense/alias-form.ts +53 -0
  139. package/src/opnsense/alias.test.ts +203 -0
  140. package/src/opnsense/alias.ts +117 -0
  141. package/src/opnsense/category-form.test.ts +54 -0
  142. package/src/opnsense/category-form.ts +20 -0
  143. package/src/opnsense/category.test.ts +138 -0
  144. package/src/opnsense/category.ts +82 -0
  145. package/src/opnsense/credentials.ts +41 -0
  146. package/src/opnsense/fake-opnsense.ts +79 -0
  147. package/src/opnsense/group-form.test.ts +75 -0
  148. package/src/opnsense/group-form.ts +25 -0
  149. package/src/opnsense/group.test.ts +141 -0
  150. package/src/opnsense/group.ts +78 -0
  151. package/src/opnsense/index.ts +45 -0
  152. package/src/opnsense/policy.ts +49 -0
  153. package/src/opnsense/providers.ts +21 -0
  154. package/src/opnsense/resource.ts +146 -0
  155. package/src/opnsense/wire.ts +56 -0
  156. package/src/opnsense/write-refusal.test.ts +148 -0
  157. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  158. package/src/proxmox/storage-form.ts +144 -0
  159. package/src/proxmox/storage-read-failure.test.ts +214 -0
  160. package/src/proxmox/storage-wire.ts +124 -0
  161. package/src/proxmox/storage.ts +165 -175
  162. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  163. package/src/proxmox/zfs-pool-form.ts +236 -0
  164. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  165. package/src/proxmox/zfs-pool-wire.ts +132 -0
  166. package/src/proxmox/zfs-pool.ts +106 -76
  167. package/src/unifi/fake-unifi.ts +60 -0
  168. package/src/unifi/firewall-zone-form.ts +65 -0
  169. package/src/unifi/firewall-zone.test.ts +88 -0
  170. package/src/unifi/firewall-zone.ts +66 -0
  171. package/src/unifi/index.ts +26 -0
  172. package/src/unifi/network-form.ts +207 -0
  173. package/src/unifi/network.test.ts +209 -0
  174. package/src/unifi/network.ts +83 -0
  175. package/src/unifi/policy.ts +37 -0
  176. package/src/unifi/providers.ts +15 -0
  177. package/src/unifi/resource.test.ts +133 -0
  178. package/src/unifi/resource.ts +131 -0
  179. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  180. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  181. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  182. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  183. package/src/proxmox/zfs-pool-spec.ts +0 -76
  184. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `Proxmox.ZfsPool`'s own cries-wolf fix, PLUS the request-shape and transient-failure fixes
3
+ * kit 0.31.2/0.31.3 (PR 239/243) found for User/Group/Storage — all apply here too, this time on
4
+ * a `read`-role read (this family never needed `provision` for GETs). Real-shaped fixtures (live
5
+ * TB4 `n2` response bodies, measured 2026-09-24, nothing secret): `GET /nodes/n2/disks/zfs/rpool`
6
+ * -> `{"data":{"name":"rpool","state":"ONLINE","errors":"No known data errors","children":[...]}}`,
7
+ * and a missing pool -> a generic shelled-out `zpool status` failure at HTTP 500, the same class
8
+ * of measured 500 as every other family this package has migrated.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
13
+ import { engineOver } from '../verify/fake-engine.ts';
14
+ import { FAKE_TARGET, withoutBao } from './fake-pve.ts';
15
+ import { ProxmoxZfsPool, ProxmoxZfsPoolProvider } from './zfs-pool.ts';
16
+
17
+ /**
18
+ * One pool, an OpenBao mint the test can deny for `read` mid-run (the cries-wolf case), and a
19
+ * cluster answer the test can break into a transient 500 mid-run (the request-shape regression's
20
+ * own case) — never both at once, so one fixture serves every test below.
21
+ */
22
+ const clusterOverPool = (node: string, name: string, live: Record<string, unknown>) => {
23
+ let denyRead = false;
24
+ let transient = false;
25
+ const stub = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
26
+ const request =
27
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
28
+ const url = new URL(request.url);
29
+ if (url.pathname.includes('/creds/')) {
30
+ if (denyRead && url.pathname.endsWith('/creds/read')) {
31
+ return Response.json({ errors: ['permission denied'] }, { status: 403 });
32
+ }
33
+ const data = { secret: 'fake-secret-not-real', token_id: 'hf-test@pve!fake' };
34
+ return Response.json({ data, lease_duration: 0 });
35
+ }
36
+ if (request.method === 'GET') {
37
+ // ⛔ THE REQUEST-SHAPE REGRESSION'S OWN SIGNATURE (user.ts/group.ts/storage.ts): a real
38
+ // fetch client refuses a GET carrying a body outright.
39
+ expect(await request.text()).toBe('');
40
+ }
41
+ const wire = decodeURIComponent(url.pathname.replace(/^\/api2\/json\//, ''));
42
+ if (wire !== `nodes/${node}/disks/zfs/${name}`) return Response.json({ data: null });
43
+ if (transient) {
44
+ // MEASURED 2026-09-24, live TB4: a missing pool answers exactly this shape.
45
+ return Response.json(
46
+ { data: null, message: `command '/sbin/zpool status -P ${name}' failed: exit code 1\n` },
47
+ { status: 500 },
48
+ );
49
+ }
50
+ return Response.json({ data: live });
51
+ };
52
+ const fetchStub = Object.assign(stub, { preconnect: globalThis.fetch.preconnect });
53
+ return {
54
+ breakReads: () => {
55
+ transient = true;
56
+ },
57
+ denyRead: () => {
58
+ denyRead = true;
59
+ },
60
+ layer: FetchHttpClient.layer.pipe(
61
+ Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, fetchStub as typeof fetch)),
62
+ ),
63
+ };
64
+ };
65
+
66
+ // MEASURED 2026-09-24, live TB4 `GET /nodes/n2/disks/zfs/rpool`.
67
+ const live = {
68
+ children: [
69
+ { children: [{ name: 'nvme-part3', state: 'ONLINE' }], name: 'rpool', state: 'ONLINE' },
70
+ ],
71
+ errors: 'No known data errors',
72
+ name: 'rpool',
73
+ state: 'ONLINE',
74
+ };
75
+ // ⚠️ ADOPT-ONLY (no `devices`/`raidlevel`, decision 9) — the pool is not one this test creates.
76
+ const declare = () =>
77
+ ProxmoxZfsPool('n2-rpool', { name: 'rpool', node: 'n2', target: FAKE_TARGET });
78
+
79
+ describe("Proxmox.ZfsPool's own cries-wolf fix: a refused read mint reports noop, never update", () => {
80
+ test('an already-adopted pool plans noop once the credential is denied, and nothing is written', async () => {
81
+ const fake = clusterOverPool('n2', 'rpool', live);
82
+ await withoutBao(async () => {
83
+ const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
84
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
85
+ fake.denyRead();
86
+ const report = await engine.verify(declare(), { all: true });
87
+ // ⛔ THE BUG THIS PROVES FIXED — same class as storage.ts's, measured for that family
88
+ // 2026-09-24: before the dual-path fix this row read `update` with nothing compared.
89
+ expect(report.rows[0]).toMatchObject({ diff: 'noop' });
90
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'noop' });
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('the same request-shape and transient-failure fixes, once for Proxmox.ZfsPool', () => {
96
+ test('diff() propagates a transient read failure once a state row exists', async () => {
97
+ const fake = clusterOverPool('n2', 'rpool', live);
98
+ await withoutBao(async () => {
99
+ const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
100
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
101
+ fake.breakReads();
102
+ // ⛔ THE FIX: this must REJECT, never resolve with a false 'update'.
103
+ await expect(engine.verify(declare(), { all: true })).rejects.toBeDefined();
104
+ });
105
+ });
106
+
107
+ test('alchemy drift never reports a transient failure as missing', async () => {
108
+ const fake = clusterOverPool('n2', 'rpool', live);
109
+ await withoutBao(async () => {
110
+ const engine = engineOver(ProxmoxZfsPoolProvider().pipe(Layer.provideMerge(fake.layer)));
111
+ expect(await engine.deploy(declare())).toEqual({ 'n2-rpool': 'adopted' });
112
+ fake.breakReads();
113
+ // ⛔ THE FIX: this must REJECT. Without `read`'s `output` branch it resolved with
114
+ // `{ 'n2-rpool': { action: 'missing' } }` and no error anywhere.
115
+ await expect(engine.drift(declare())).rejects.toBeDefined();
116
+ });
117
+ });
118
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `Proxmox.ZfsPool`'s READ side: the live-to-attributes shape, the read itself, and `matches`.
3
+ * Split out of zfs-pool.ts (2026-09-24, the distilled migration) — the storage.ts/storage-wire.ts
4
+ * seam; the write side is zfs-pool-form.ts.
5
+ */
6
+ import * as nodes from '@distilled.cloud/proxmox/nodes';
7
+ import * as Effect from 'effect/Effect';
8
+ import { runPve } from './distilled-pve.ts';
9
+ import type { ZfsPoolProps } from './zfs-pool.ts';
10
+ import { UNREADABLE, type Unreadable, readOrUnreadable } from './unreadable-read.ts';
11
+ import { text } from './values.ts';
12
+
13
+ /**
14
+ * ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches` below. These
15
+ * exist so a plan and the state store can say what the pool actually is, on a family where the
16
+ * declaration and the live object share no comparable field at all.
17
+ */
18
+ export interface ZfsPoolAttributes {
19
+ /** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
20
+ name: string;
21
+ node: string;
22
+ /** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
23
+ state: string;
24
+ /** ZFS's own error summary, e.g. `No known data errors`. */
25
+ errors: string;
26
+ /**
27
+ * The leaf devices ZFS reports, in vdev order, comma-joined.
28
+ *
29
+ * ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
30
+ * a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
31
+ * for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports.
32
+ * Never compare it with `props.devices`.
33
+ */
34
+ devices: string;
35
+ }
36
+
37
+ /**
38
+ * The device paths at the bottom of PVE's vdev tree, in the order ZFS reports them.
39
+ *
40
+ * ⚠️ "NO CHILDREN" IS THE LEAF TEST, AND IT IS NOT A GUESS — MEASURED in the node's `preparetree`,
41
+ * which sets `leaf` to 0 when there are children and 1 otherwise. Reading the flag would work
42
+ * equally well; recursing on `children` needs no second field to be present and is what makes
43
+ * nested sections (mirrors inside a raid10, `spares`, `cache`) flatten correctly.
44
+ */
45
+ export const leafDevices = (children: unknown): string[] =>
46
+ Array.isArray(children)
47
+ ? children.flatMap((entry: unknown) => {
48
+ const vdev = entry as { children?: unknown; name?: unknown };
49
+ const nested = leafDevices(vdev.children);
50
+ return nested.length > 0 ? nested : [text(vdev.name)].filter((name) => name !== '');
51
+ })
52
+ : [];
53
+
54
+ /**
55
+ * ⚠️ `scan`, `status` AND `action` ARE DELIBERATELY NOT ATTRIBUTES. All three are advisory strings
56
+ * ZFS rewrites on its own — `scan` on every scrub, the other two as feature flags and faults come
57
+ * and go — so keeping them would rewrite this resource's state row for reasons no declaration
58
+ * caused. Same reasoning as `digest` in storage.ts.
59
+ */
60
+ const attributesOf = (
61
+ live: nodes.GetNodeDiskZfsResponse,
62
+ props: ZfsPoolProps,
63
+ ): ZfsPoolAttributes => ({
64
+ devices: leafDevices(live.children).join(','),
65
+ errors: text(live.errors, 'unknown'),
66
+ name: props.name,
67
+ node: props.node,
68
+ state: text(live.state, 'unknown'),
69
+ });
70
+
71
+ /**
72
+ * ★ Default `read` role — `Sys.Audit` on `/` is all this GET checks (zfs-pool.ts's header).
73
+ * ⛔ `{ node: props.node, name: props.name }`, NEVER THE WHOLE `props` — storage.ts's/user.ts's own
74
+ * ⛔, the kit 0.31.1 regression: `GetNodeDiskZfsRequest`'s schema declares only `node` and `name`
75
+ * (both path labels); any OTHER key on the object passed to `nodes.getNodeDiskZfs` is treated by
76
+ * distilled's `buildRequest` as an "unknown key" and JSON-encoded onto this bodyless GET, which a
77
+ * stricter fetch client refuses outright.
78
+ * ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
79
+ * read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers
80
+ * `{"message":"command '/sbin/zpool status -P hf-measure-nonexistent-pool' failed: exit code
81
+ * 1\n","data":null}` at HTTP 500 — PVE shells out to `zpool status` and reports its exit code
82
+ * as a generic command failure, not a typed "not found". The same shape as user.ts's/group.ts's/
83
+ * storage.ts's own measured 500s. `readPool` below (used by `createPool`'s settle-poll and by
84
+ * `reconcile`/`read`) still folds it, exactly as `readUser`/`readGroup`/`readStorage` do — the
85
+ * whole reason `createPool` can tell "not built yet" from "forbidden" only by giving up after
86
+ * `SETTLE_ATTEMPTS` (zfs-pool-form.ts's own ⚠️ on that); `readPoolOrFail` (used by `diff`) does
87
+ * not fold it, so a genuine transient failure there propagates and fails the plan loudly instead.
88
+ */
89
+ export const readPoolOrFail = (props: ZfsPoolProps) =>
90
+ readOrUnreadable(
91
+ runPve(
92
+ props.target,
93
+ 'read',
94
+ false,
95
+ nodes.getNodeDiskZfs({ name: props.name, node: props.node }),
96
+ ),
97
+ ).pipe(
98
+ Effect.map((live) => {
99
+ if (live === UNREADABLE) return UNREADABLE;
100
+ // ⚠️ A detail answer with no `name` is not a pool — same shape check zfs-pool-spec.ts made,
101
+ // kept as a defensive fallback now that the 500 above is the measured absence signal.
102
+ return text(live.name) === '' ? undefined : attributesOf(live, props);
103
+ }),
104
+ );
105
+
106
+ /**
107
+ * ⛔ FOLDS A GENUINE READ FAILURE TO `undefined` TOO, AND ONLY `createPool`'s settle-poll AND
108
+ * `read`/`reconcile` MAY USE IT — user.ts's `readUser` has the full reasoning. Safe here because
109
+ * a wrongful fold during create costs at most a redundant `createNodeDiskZfs` POST, which PVE
110
+ * refuses loudly ("pool already exists") rather than silently corrupting anything — zfs-pool.ts's
111
+ * own header, measured.
112
+ * ⛔ `zfs-pool.ts`'s `diff` does **NOT** use this — the same bug class as the credential denial fix:
113
+ * folding a TRANSIENT failure into "absent" at PLAN TIME would force a false `update` with
114
+ * nothing compared. `diff` calls `readPoolOrFail` directly, so a genuine failure there propagates
115
+ * and fails the whole plan loudly instead.
116
+ */
117
+ export const readPool = (props: ZfsPoolProps) =>
118
+ readPoolOrFail(props).pipe(Effect.orElseSucceed(() => undefined));
119
+
120
+ /** `read`/`reconcile`/`createPool` return `Attributes | undefined`; only `diff` sees `UNREADABLE`. */
121
+ export const dropUnreadable = (live: ZfsPoolAttributes | Unreadable | undefined) =>
122
+ live === UNREADABLE ? undefined : live;
123
+
124
+ /**
125
+ * ⛔ THERE IS NO `matches` FUNCTION HERE — UNLIKE EVERY OTHER MIGRATED FAMILY, AND DELIBERATELY.
126
+ * Every create parameter on this family is write-only (this file's own `attributesOf`), so
127
+ * there is nothing a declaration and a live pool can ever be compared on — a `matches` that
128
+ * invented one would plan a replace (`zpool destroy`, since this family has no PUT) over a
129
+ * difference it could never verify. `zfs-pool.ts`'s `diff` returns `noop` unconditionally once a
130
+ * pool is found, rather than calling a function that would only ever return `true` — found by
131
+ * adversarial review that an earlier draft exported one here anyway, unused by anything.
132
+ */
@@ -26,14 +26,14 @@
26
26
  * ⛔ THERE IS NO PUT ON THIS FAMILY AT ALL. MEASURED from the cluster's own published schema
27
27
  * (`/usr/share/pve-docs/api-viewer/apidoc.js`, read on node-b 2026-09-13): `/nodes/{node}/disks/zfs`
28
28
  * has GET and POST, `/nodes/{node}/disks/zfs/{name}` has GET and DELETE, and that is the whole
29
- * surface. So `updateForm` is omitted — which, per `resource.ts`, makes any `matches` = false a
30
- * REPLACE. A replace is a delete followed by a create, and on this family the delete is the
31
- * thing above. Read the ⛔ on `matches` before adding anything to it.
29
+ * surface — CONFIRMED again in `@distilled.cloud/proxmox`'s generated `nodes.ts`, which declares
30
+ * no `putNodeDiskZfs` at all. So `matches` (zfs-pool-wire.ts) always answers `true`: there is
31
+ * nothing to plan a replace over, and the family's one write is the create below.
32
32
  *
33
33
  * ⛔ NOT ONE CREATE PARAMETER COMES BACK ON READ, SO `matches` COMPARES NOTHING. MEASURED against
34
- * the live cluster: `GET /nodes/node-b/disks/zfs/rpool` answers exactly
35
- * `{action, children, errors, leaf, name, scan, state, status}` — no `ashift`, no `compression`,
36
- * no `raidlevel`, no `devices`, no `add_storage`. Every create parameter is write-only. There is
34
+ * the live cluster: `GET /nodes/n2/disks/zfs/rpool` answers exactly
35
+ * `{action?, children, errors, name, scan?, state, status?}` — no `ashift`, no `compression`, no
36
+ * `raidlevel`, no `devices`, no `add_storage`. Every create parameter is write-only. There is
37
37
  * therefore no field a declaration and a live pool can both be asked about, and a `matches` that
38
38
  * invented one would plan a replace — i.e. a `zpool destroy` — over a difference it could never
39
39
  * verify in the first place.
@@ -46,11 +46,20 @@
46
46
  * is compared, and the three churning strings are kept out of the state store entirely rather
47
47
  * than rewriting this resource's row every time somebody writes a file.
48
48
  *
49
- * ⚠️ TWO HANDLERS ARE OVERRIDDEN AND THE OTHER THREE COME FROM THE FACTORY — see the ★ above
50
- * `handlers`. `delete` is the ⛔ above. `reconcile` is because the create is a FORKED WORKER, so
51
- * the factory's immediate read-back would report a successful create as a failure; the measured
52
- * detail, and the adopt-only refusal decision 9 adds, are both in `createPool` in
53
- * zfs-pool-write.ts.
49
+ * ⛔ A MISSING POOL IS A 500, NOT A 404 — MEASURED against the live cluster (TB4 n2, 2026-09-24,
50
+ * read-role, read-only probe): `GET /nodes/n2/disks/zfs/hf-measure-nonexistent-pool` answers a
51
+ * generic shelled-out command failure at HTTP 500, the same shape as every other family this
52
+ * package has migrated. `zfs-pool-wire.ts` carries the same dual-path read PR 239/243
53
+ * established: `readPoolOrFail` (non-folding, used by `diff`) and `readPool` (folding, used by
54
+ * `createPool`'s settle-poll and by `read`/`reconcile`).
55
+ *
56
+ * ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveOperations` ONTO `@distilled.cloud/proxmox`'s
57
+ * typed `nodes.getNodeDiskZfs`/`createNodeDiskZfs`/`deleteNodeDiskZfs` (2026-09-24, decision
58
+ * 43's proxmox walk-down, nodes/storage sub-area). `distilled-pve.ts`'s `runPve` replaces
59
+ * `pve()`; the cries-wolf fix is wired the same way as every other migrated family. `reconcile`
60
+ * and `delete` are still hand-written (never the factory) for the SAME two reasons as before the
61
+ * migration — the forked-worker settle-poll, and a DELETE that destroys data — now calling
62
+ * `zfs-pool-form.ts`'s `createPool`/`destroyPool` directly instead of through `pveHandlers`.
54
63
  *
55
64
  * ⚠️ PRIVILEGES, FROM THE SCHEMA. Read and diff need `Sys.Audit` on `/` — both GETs check it.
56
65
  * Reconcile needs `Sys.Modify` on `/` for the POST. `Datastore.Allocate` on `/storage` is NOT
@@ -60,31 +69,23 @@
60
69
  * family checks it, not only the POST. MEASURED the same day as the rest of this paragraph:
61
70
  * `ceph-osd.ts`'s own privilege comparison names `disks/zfs` as one of the sibling families
62
71
  * whose write verbs DO carry a `Sys.Modify` check, unlike `ceph/osd`'s create and delete, which
63
- * carry no permissions block at all and 403 for any identity but `root@pam`. This line
64
- * previously said delete "needs nothing at all, since it calls nothing", which repeated the same
65
- * mistake the header above corrects: `delete` calls the DELETE endpoint, so it needs whatever
66
- * that endpoint checks.
72
+ * carry no permissions block at all and 403 for any identity but `root@pam`.
67
73
  */
68
74
  import { Resource } from 'alchemy';
75
+ import { isResolved } from 'alchemy/Diff';
69
76
  import * as Provider from 'alchemy/Provider';
70
77
  import * as Effect from 'effect/Effect';
71
- import type { NodesNodeDisksZfsPostParams } from './generated/pve.ts';
72
- import { type PveRequirements, type WithTarget, pveHandlers, pveOperations } from './resource.ts';
73
- import { spec } from './zfs-pool-spec.ts';
74
- import { createPool, destroyPool } from './zfs-pool-write.ts';
78
+ import { asForm } from './distilled-guard.ts';
79
+ import { guardForm } from './constraint-guard.ts';
80
+ import type { PveRequirements, WithTarget } from './resource-spec.ts';
81
+ import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
82
+ import { dropUnreadable, readPool, readPoolOrFail } from './zfs-pool-wire.ts';
83
+ import type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
84
+ import { createForm, createPool, destroyPool, zfsPoolCreateEndpoint } from './zfs-pool-form.ts';
85
+ import type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
75
86
 
76
- /**
77
- * PVE's layouts, generated (2026-09-23) from `NodesNodeDisksZfsPostParams['raidlevel']`
78
- * (`generated/pve.ts`, manifest `pve-apidoc` pve-manager 9.2.11 sha256 9def8f13…) rather than
79
- * hand-typed, per decision 9. ⚠️ Each has a minimum disk count PVE enforces, and `raid10` needs an
80
- * even one. ⛔ THERE IS NO `stripe` IN THIS ENUM. n1's `speed` pool is one (decision 14 accepts it
81
- * as-is): `ZfsPoolProps` below is why a stripe pool is declarable at all — as an ADOPT-ONLY
82
- * declaration with no `raidlevel`, never as a full one.
83
- */
84
- export type ZfsRaidLevel = NonNullable<NodesNodeDisksZfsPostParams['raidlevel']>;
85
-
86
- /** Generated the same way, from `NodesNodeDisksZfsPostParams['compression']`. */
87
- export type ZfsCompression = NonNullable<NodesNodeDisksZfsPostParams['compression']>;
87
+ export type { ZfsPoolAttributes } from './zfs-pool-wire.ts';
88
+ export type { ZfsCompression, ZfsRaidLevel } from './zfs-pool-form.ts';
88
89
 
89
90
  /**
90
91
  * ⛔ THERE IS NO `add_storage`, `cleanup-config` OR `cleanup-disks` PROP, AND THAT IS NOT AN
@@ -122,7 +123,7 @@ export interface ZfsPoolProps extends WithTarget {
122
123
  * both this and `raidlevel` undeclared declares an ADOPT-ONLY pool — one this resource may
123
124
  * read and report but never build. That is the only shape available for a pool PVE's own POST
124
125
  * schema cannot fully describe, such as n1's `speed`, whose layout is a stripe and whose
125
- * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-write.ts) omits
126
+ * `raidlevel` enum (above) has no stripe to declare. `createForm` (zfs-pool-form.ts) omits
126
127
  * both keys when they are absent, and `createPool` refuses before any POST if the pool it
127
128
  * would need to build is not already there — an adopt-only declaration never creates. Declare
128
129
  * both fields together for a pool this resource should build from scratch; declaring only one
@@ -152,30 +153,6 @@ export interface ZfsPoolProps extends WithTarget {
152
153
  'draid-config'?: string;
153
154
  }
154
155
 
155
- /**
156
- * ⚠️ EVERYTHING HERE IS REPORTED, NOTHING HERE IS COMPARED — see the ⛔ on `matches`. These exist
157
- * so a plan and the state store can say what the pool actually is, on a family where the
158
- * declaration and the live object share no comparable field at all.
159
- */
160
- export interface ZfsPoolAttributes {
161
- /** ★ The value a `Proxmox.Storage` should read to order itself after this pool. */
162
- name: string;
163
- node: string;
164
- /** `ONLINE` | `DEGRADED` | `FAULTED` | … Health, not configuration. */
165
- state: string;
166
- /** ZFS's own error summary, e.g. `No known data errors`. */
167
- errors: string;
168
- /**
169
- * The leaf devices ZFS reports, in vdev order, comma-joined.
170
- *
171
- * ⚠️ THIS IS WHAT ZFS RESOLVED, NOT WHAT WAS DECLARED, and the two normally differ: PVE rewrites
172
- * a `/dev/sdb` into a by-id link before creating, and ZFS reports partition paths (`…-part3`)
173
- * for a pool built on partitions — MEASURED, that is exactly what node-b's `rpool` reports. Never
174
- * compare it with `props.devices`.
175
- */
176
- devices: string;
177
- }
178
-
179
156
  export interface ProxmoxZfsPool extends Resource<
180
157
  'Proxmox.ZfsPool',
181
158
  ZfsPoolProps,
@@ -189,24 +166,77 @@ export const ProxmoxZfsPool = Resource<ProxmoxZfsPool>('Proxmox.ZfsPool', {
189
166
  defaultRemovalPolicy: 'retain',
190
167
  });
191
168
 
192
- const ops = pveOperations(spec);
193
-
194
- /**
195
- * ★ THREE HANDLERS COME FROM THE FACTORY AND TWO DO NOT, SO THE TWO ARE THE ONLY ONES WRITTEN OUT.
196
- * `list`, `read` and `diff` are the factory's, unchanged — the spread is what proves it, rather
197
- * than three re-typed delegations nobody rereads. `reconcile` and `delete` are the two the header
198
- * explains, and they are the only places this family departs from every other resource here.
199
- * This is the acl.ts precedent, for a different reason: there the factory's delete hits an
200
- * endpoint PVE does not implement, here it hits one that works and destroys the data.
201
- */
202
- const handlers = {
203
- ...pveHandlers(spec),
204
- delete: Effect.fn(function* ({ olds }: { olds: ZfsPoolProps }) {
205
- yield* destroyPool(olds, spec.path(olds));
206
- }),
207
- reconcile: ({ news }: { news: ZfsPoolProps }) =>
208
- createPool(news, spec.collection(news), spec.path(news), ops.read),
209
- };
210
-
211
169
  export const ProxmoxZfsPoolProvider = () =>
212
- Provider.effect(ProxmoxZfsPool, Effect.succeed(ProxmoxZfsPool.Provider.of(handlers)));
170
+ Provider.effect(
171
+ ProxmoxZfsPool,
172
+ Effect.succeed(
173
+ ProxmoxZfsPool.Provider.of({
174
+ /** ⛔ EMPTY — `GET /nodes/{node}/disks/zfs` returns telemetry for every pool on the node,
175
+ * `rpool` included. Adoption stays explicit. */
176
+ list: () => Effect.succeed([]),
177
+ // ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — storage.ts's own ⚠️, applied here from the
178
+ // start: `output === undefined` (Plan.ts's adoption probe, Apply.ts's delete recovery)
179
+ // keeps `readPool`'s fold; `output !== undefined` (Drift.ts's already-confirmed row)
180
+ // uses `readPoolOrFail` instead, so `alchemy drift` never reports a transient failure as
181
+ // a silent `{action: 'missing'}`.
182
+ read: Effect.fn(function* ({ olds, output }) {
183
+ return dropUnreadable(
184
+ yield* output === undefined ? readPool(olds) : readPoolOrFail(olds),
185
+ );
186
+ }),
187
+ diff: Effect.fn(function* ({ news, output }) {
188
+ if (!isResolved(news)) return undefined;
189
+ // ⚠️ `guardForm`, NOT `guardWrite` — the only migrated family whose create-guard is
190
+ // CONDITIONAL. `guardWrite`'s own signature requires a real `EndpointKey`, but decision
191
+ // 9's adopt-only mode needs `undefined` some of the time (`zfsPoolCreateEndpoint`'s own
192
+ // header) — `guardForm` (constraint-guard.ts) is the one `distilled-guard.ts`'s
193
+ // `guardWrite` itself calls, and it already accepts `EndpointKey | undefined`.
194
+ yield* guardForm(
195
+ zfsPoolCreateEndpoint(news),
196
+ asForm(createForm(news)),
197
+ output === undefined,
198
+ );
199
+ if (output === undefined) return undefined;
200
+ // ⚠️ `readPoolOrFail`, NOT `readPool` — a genuine TRANSIENT failure here propagates and
201
+ // fails the whole plan loudly instead of folding to "absent" and forcing a false
202
+ // update, the cries-wolf class of bug.
203
+ const live = yield* readPoolOrFail(news);
204
+ if (live === UNREADABLE) {
205
+ yield* unreadableWarning('Proxmox.ZfsPool', `${news.node}/${news.name}`);
206
+ return { action: 'noop' } as const;
207
+ }
208
+ // ⛔ FOUND WRITING THIS FILE, 2026-09-24 — an earlier edit that removed the dead
209
+ // `matches()` call (zfs-pool-wire.ts's own ⛔) took this whole branch out with it,
210
+ // leaving a genuinely-absent pool planning `noop` unconditionally instead of `update`
211
+ // — a silent no-op on a state row Alchemy still holds, caught by
212
+ // zfs-pool-adopt.test.ts's own "an adopted pool that vanishes" case rejecting the plan
213
+ // loudly rather than resolving. Restored before this PR opened.
214
+ if (live === undefined) {
215
+ yield* guardForm(zfsPoolCreateEndpoint(news), asForm(createForm(news)), true);
216
+ return { action: 'update' } as const;
217
+ }
218
+ // A live pool means `noop`, unconditionally — `matches` (zfs-pool-wire.ts's own ⛔)
219
+ // always answers `true`, since every create parameter is write-only: there is nothing a
220
+ // declaration and a live pool can be compared on, so nothing here can ever be a replace.
221
+ return { action: 'noop' } as const;
222
+ }),
223
+ /**
224
+ * ⛔ THE ONLY WRITE THIS FAMILY EVER MAKES IS `createPool`'s create — see zfs-pool-form.ts.
225
+ * An existing pool is returned exactly as read, unwritten.
226
+ * ⚠️ `readPool` PIPED THROUGH `dropUnreadable` — `createPool`'s settle-poll treats a
227
+ * refused mid-poll credential exactly like "not found yet" (its own header), so its own
228
+ * `read` parameter is typed `Attributes | undefined`, never `Unreadable`; folding here is
229
+ * what keeps that true rather than leaking the sentinel into `reconcile`'s own return
230
+ * value, which the engine expects to be real attributes or nothing.
231
+ */
232
+ reconcile: Effect.fn(function* ({ news }) {
233
+ return yield* createPool(news, (props) =>
234
+ readPool(props).pipe(Effect.map(dropUnreadable)),
235
+ );
236
+ }),
237
+ delete: Effect.fn(function* ({ olds }) {
238
+ yield* destroyPool(olds);
239
+ }),
240
+ }),
241
+ ),
242
+ );
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A minimal in-memory UniFi Network API for this family's tests — an in-memory `fetch` handed to
3
+ * Effect's real `FetchHttpClient` through its `Fetch` reference, mirroring `../netbox/fake-netbox.ts`
4
+ * (itself mirroring `../forgejo/fake-forgejo.ts`). Tests exercise distilled's REAL path assembly,
5
+ * JSON encode/decode and status→typed-error matching — nothing about that matching is
6
+ * re-implemented here, only the wire responses a scenario needs.
7
+ *
8
+ * ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
9
+ * `unifi.example.com` and the key is a placeholder — never a real console address: a cloud
10
+ * connector's base URL embeds the account's Console ID, which must never appear in this repo
11
+ * (`docs/unifi.md`).
12
+ */
13
+ import { type Credentials, credentials } from '@distilled.cloud/unifi-network/Credentials';
14
+ import * as Layer from 'effect/Layer';
15
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
16
+
17
+ export const FAKE_BASE = 'https://unifi.example.com/proxy/network/integration';
18
+ export const FAKE_KEY = 'placeholder-unifi-key';
19
+
20
+ export type Seen = { readonly method: string; readonly path: string };
21
+
22
+ /** No documented failure envelope (see `protocol.ts`) — plain text is all a failure needs. */
23
+ export const fakeFailure = (status: number, detail: string) => new Response(detail, { status });
24
+
25
+ /**
26
+ * `route` decides every response; this wrapper only records what was sent and adapts to
27
+ * `globalThis.fetch`'s signature.
28
+ *
29
+ * ★ THE WRITE-REFUSAL PROOF USES `seen` DIRECTLY, NOT A THROWING ROUTE. `resource.test.ts`'s
30
+ * "no SDK write operation is reachable" test asserts `seen.every(s => s.method === 'GET')`
31
+ * after driving every handler — a request this family's engine was never supposed to send
32
+ * shows up in `seen` as a fact, rather than depending on an exception surviving Effect's own
33
+ * error handling on its way back out of a fake `fetch`.
34
+ */
35
+ export const fakeUnifi = (route: (method: string, url: URL) => Response) => {
36
+ const seen: Seen[] = [];
37
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
38
+ const request =
39
+ input instanceof Request ? new Request(input, init) : new Request(String(input), init);
40
+ const url = new URL(request.url);
41
+ seen.push({ method: request.method, path: `${url.pathname}${url.search}` });
42
+ return route(request.method, url);
43
+ }) as typeof globalThis.fetch;
44
+ return { fetch, seen };
45
+ };
46
+
47
+ /**
48
+ * distilled Credentials + the real FetchHttpClient over the fake — `UnifiNetworkOpContext`, what
49
+ * every resource file's exported `spec.fetchLive` needs.
50
+ *
51
+ * ⚠️ NOT PAIRED WITH `handlers`. `handlers` (each resource file's `unifiHandlers(spec)` export)
52
+ * bakes in `CredentialsFromEnv`, which resolves `UNIFI_NETWORK_API_KEY`/`_API_BASE_URL` through
53
+ * Effect's `Config` — whose default provider snapshots `process.env` once and never re-reads it
54
+ * (the same seam `netbox/fake-netbox.ts` documents). Tests call `spec.fetchLive`/`spec.attributes`
55
+ * directly instead, with this explicit layer.
56
+ */
57
+ export const fakeUnifiLayer = (
58
+ fetchFn: typeof globalThis.fetch,
59
+ creds: Layer.Layer<Credentials> = credentials({ apiBaseUrl: FAKE_BASE, apiKey: FAKE_KEY }),
60
+ ) => Layer.mergeAll(creds, FetchHttpClient.layer, Layer.succeed(FetchHttpClient.Fetch, fetchFn));
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `Unifi.FirewallZone`'s wire shape — the declarable subset of `FirewallZone`, and how a live
3
+ * read becomes both plan attributes and a rendered declaration.
4
+ *
5
+ * ⚠️ ONLY `name` AND `networkIds` ARE DECLARABLE. `CreateFirewallZoneRequest`
6
+ * (`firewall.ts`) accepts exactly those two beyond `siteId`; `metadata` (origin/source) and
7
+ * `id` are server-derived, so — same split as `network-form.ts`'s `default`/`metadata` —
8
+ * they are attributes-only, reported but never compared or declared.
9
+ *
10
+ * ⛔ `networkIds` ORDER IS NOT PART OF IDENTITY. The vendor document gives no indication the
11
+ * list is ordered (it is a set of attached networks, not a sequence like the ordering
12
+ * endpoints' rule lists), so `matches` compares it as a set — sorted before comparing —
13
+ * the same reasoning `proxmox/user-wire.ts`'s `groupSet` gives for PVE's `groups`.
14
+ */
15
+ import type * as firewall from '@distilled.cloud/unifi-network/firewall';
16
+
17
+ export interface FirewallZoneProps {
18
+ siteId: string;
19
+ firewallZoneId: string;
20
+ /** Name of a firewall zone. */
21
+ name: string;
22
+ /** Network IDs attached to this zone — compared as a set, see the header. */
23
+ networkIds: string[];
24
+ }
25
+
26
+ export interface FirewallZoneAttributes {
27
+ siteId: string;
28
+ firewallZoneId: string;
29
+ name: string;
30
+ networkIds: string[];
31
+ /** `origin`/`source` (e.g. `SDWAN`) — server-derived, never declared. */
32
+ metadataOrigin: string;
33
+ }
34
+
35
+ const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
36
+
37
+ export const attributesOf = (
38
+ live: firewall.FirewallZone,
39
+ props: FirewallZoneProps,
40
+ ): FirewallZoneAttributes => ({
41
+ siteId: props.siteId,
42
+ firewallZoneId: live.id,
43
+ name: live.name,
44
+ networkIds: sortedSet(live.networkIds),
45
+ metadataOrigin: live.metadata.origin,
46
+ });
47
+
48
+ export const matches = (attributes: FirewallZoneAttributes, props: FirewallZoneProps): boolean =>
49
+ attributes.name === props.name &&
50
+ attributes.networkIds.join(',') === sortedSet(props.networkIds).join(',');
51
+
52
+ /**
53
+ * The declaration renderer (task spec: "given one live object, return the props a declaration
54
+ * needs so its plan is noop"). `matches(attributesOf(live, props), declareFirewallZone(live,
55
+ * siteId))` is `true` by construction (`firewall-zone.test.ts` proves it).
56
+ */
57
+ export const declareFirewallZone = (
58
+ live: firewall.FirewallZone,
59
+ siteId: string,
60
+ ): FirewallZoneProps => ({
61
+ siteId,
62
+ firewallZoneId: live.id,
63
+ name: live.name,
64
+ networkIds: sortedSet(live.networkIds),
65
+ });