@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,146 @@
1
+ /**
2
+ * One shape for every OPNsense object this family declares — `read`/`diff` mirroring
3
+ * `../discord/resource.ts`'s marker-less-API posture exactly (S7–S9, H1), and `reconcile`/
4
+ * `delete` replaced end to end with the read-only refusal `policy.ts` states.
5
+ *
6
+ * ★ WHY ONE ENGINE FOR THREE RESOURCES. `alias.ts`, `category.ts` and `group.ts` differ only in
7
+ * which `firewall_*` module they call and how a live item maps to attributes — the read/diff/
8
+ * reconcile/delete flow is identical, so it lives here once, the way `netbox/resource.ts` and
9
+ * `discord/resource.ts` already do for their own families.
10
+ *
11
+ * ★ `Unowned(attrs)` ON A COLD READ, PLAIN ATTRIBUTES ON A WARM ONE (S7, S8). None of Alias,
12
+ * Category or Group carries an ownership marker OPNsense itself understands — no tag, no
13
+ * metadata field a stack could stamp — so this is the same "marker-less API" S8 describes, and
14
+ * the same posture `../discord/resource.ts` documents against `Snippet.ts`'s reference read.
15
+ * `adopt(true)` (H5), piped on by each resource file's convenience constructor, is what turns
16
+ * an `Unowned` first read into a silent one-time takeover instead of an `OwnedBySomeoneElse`
17
+ * refusal — exactly what an import script generating `alchemy.run.ts` rows from live reads
18
+ * needs: declare what is live, adopt it, change nothing.
19
+ *
20
+ * ⛔ `reconcile` AND `delete` NEVER CALL AN SDK OPERATION. `reconcile` still OBSERVES first (one
21
+ * GET, via `read`) so the refusal it raises can say whether it would have created or updated —
22
+ * S9's "observe before you write" doctrine, kept for the error message even though there is no
23
+ * write to guard. `delete` observes nothing: it refuses unconditionally, because under this
24
+ * policy a delete never succeeds regardless of whether the object is still live. See policy.ts.
25
+ */
26
+ import { Unowned } from 'alchemy/AdoptPolicy';
27
+ import { isResolved } from 'alchemy/Diff';
28
+ import type { Input } from 'alchemy/Input';
29
+ import type { OpnsenseOpContext } from '@distilled.cloud/opnsense/Protocol';
30
+ import * as Effect from 'effect/Effect';
31
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
32
+ import { CredentialsFromEnv } from './credentials.ts';
33
+ import { refuseWrite } from './policy.ts';
34
+
35
+ /** What every stack must still provide after `opnsenseHandlers` bakes in `CredentialsFromEnv`. */
36
+ export type OpnsenseRequirements = HttpClient.HttpClient;
37
+
38
+ /** Every resource in this family identifies its live object by OPNsense's own assigned uuid. */
39
+ export interface OpnsenseIdentity {
40
+ readonly uuid: string;
41
+ }
42
+
43
+ /**
44
+ * One OPNsense object's read path. `Live` is whatever the generated `get()` response decodes to
45
+ * for one item (an `AliasItem`, `CategoryItem`, `GroupItem`); `E` is left to each resource file
46
+ * to declare — in practice always `OpnsenseOpError`, since every generated operation shares one
47
+ * error channel (protocol.ts).
48
+ */
49
+ export type OpnsenseSpec<Props extends OpnsenseIdentity, Live, Attributes extends object, E> = {
50
+ readonly resourceType: string;
51
+ /** Absent when `props.uuid` is not a key in the live model's map — see each resource file. */
52
+ readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, OpnsenseOpContext>;
53
+ /** Also the declaration renderer (S.T. propsFromLive re-export) — see each `*-form.ts`. */
54
+ readonly attributes: (uuid: string, live: Live) => Attributes;
55
+ readonly matches: (attributes: Attributes, props: Props) => boolean;
56
+ };
57
+
58
+ export const opnsenseOperations = <
59
+ Props extends OpnsenseIdentity,
60
+ Live,
61
+ Attributes extends object,
62
+ E,
63
+ >(
64
+ spec: OpnsenseSpec<Props, Live, Attributes, E>,
65
+ ) => {
66
+ const read = (props: Props) =>
67
+ spec
68
+ .fetchLive(props)
69
+ .pipe(
70
+ Effect.map((live) => (live === undefined ? undefined : spec.attributes(props.uuid, live))),
71
+ );
72
+
73
+ return {
74
+ read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
75
+ Effect.gen(function* () {
76
+ const attrs = yield* read(olds);
77
+ if (attrs === undefined) return undefined;
78
+ // Warm read: a prior apply already adopted this exact uuid — ours.
79
+ if (output !== undefined) return attrs;
80
+ // Cold read (fresh store, or state lost): no ownership marker exists on this vendor.
81
+ return Unowned(attrs);
82
+ }),
83
+
84
+ diff: (news: Input<Props>, output: Attributes | undefined) =>
85
+ Effect.gen(function* () {
86
+ if (output === undefined || !isResolved(news)) return undefined;
87
+ const live = yield* read(news);
88
+ if (live === undefined) return { action: 'update' } as const;
89
+ return spec.matches(live, news)
90
+ ? ({ action: 'noop' } as const)
91
+ : ({ action: 'update' } as const);
92
+ }),
93
+
94
+ reconcile: (news: Props) =>
95
+ Effect.gen(function* () {
96
+ const live = yield* read(news);
97
+ return yield* refuseWrite(
98
+ spec.resourceType,
99
+ news.uuid,
100
+ live === undefined ? 'create' : 'update',
101
+ );
102
+ }),
103
+
104
+ destroy: (olds: Props) => refuseWrite(spec.resourceType, olds.uuid, 'delete'),
105
+ };
106
+ };
107
+
108
+ /**
109
+ * The provider handlers for a spec'd OPNsense object, wired once. `list` answers empty — every
110
+ * `get()` in this family returns the WHOLE model tree with no per-account/per-scope narrowing,
111
+ * so a `list` that enumerated it would sweep the estate's entire alias/category/group set into
112
+ * one plan; adoption stays explicit, one uuid at a time, the same reasoning netbox's and
113
+ * discord's own `list` give for a shared or marker-less object space (S12).
114
+ *
115
+ * `nuke: { skip: true }` — `delete` refuses unconditionally (policy.ts), so `alchemy unsafe
116
+ * nuke` could never do anything here but fail loudly on every object it found; skipping keeps
117
+ * that refusal out of a nuke census entirely, matching `postgres/database.ts` and
118
+ * `github/repository-ruleset.ts`'s own `nuke: { skip: true }` for the same reason.
119
+ */
120
+ export const opnsenseHandlers = <
121
+ Props extends OpnsenseIdentity,
122
+ Live,
123
+ Attributes extends object,
124
+ E,
125
+ >(
126
+ spec: OpnsenseSpec<Props, Live, Attributes, E>,
127
+ ) => {
128
+ const ops = opnsenseOperations(spec);
129
+ // ⚠️ GENERIC OVER ITS OWN ERROR AND REQUIREMENT CHANNELS, NOT PINNED TO `E`/`OpnsenseOpContext`.
130
+ // `read`/`diff` fail with `E` and need `OpnsenseOpContext`; `reconcile` fails with
131
+ // `E | OpnsenseWriteRefused` (it observes, then refuses) and still needs `OpnsenseOpContext`
132
+ // for that observe; `delete` fails with `OpnsenseWriteRefused` alone and needs NOTHING (it
133
+ // never observes) — three different signatures sharing one credentials-providing wrapper.
134
+ const withCredentials = <A, Err, R>(effect: Effect.Effect<A, Err, R>) =>
135
+ Effect.provide(effect, CredentialsFromEnv);
136
+ return {
137
+ list: () => Effect.succeed([]),
138
+ nuke: { skip: true },
139
+ read: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
140
+ withCredentials(ops.read({ olds, output })),
141
+ diff: ({ news, output }: { news: Input<Props>; output: Attributes | undefined }) =>
142
+ withCredentials(ops.diff(news, output)),
143
+ reconcile: ({ news }: { news: Props }) => withCredentials(ops.reconcile(news)),
144
+ delete: ({ olds }: { olds: Props }) => withCredentials(ops.destroy(olds)),
145
+ };
146
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * How OPNsense's MVC model spells a value on the wire, and how to get it back to something
3
+ * comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
4
+ *
5
+ * ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS. Every `BooleanField` is the string
6
+ * `"1"` or `"0"` (never a JSON boolean — `AliasItem.enabled: string`, `GroupItem.nogroup?:
7
+ * string`, see `firewall_alias.ts`/`firewall_group.ts`). Every multi-select `OptionField`/
8
+ * `ModelRelationField` is a SINGLE comma-joined string, not a JSON array (`AliasItem.categories`,
9
+ * `GroupItem.members` — both carry the doc comment "wire value is a single comma-joined string,
10
+ * not a JSON array" in the generated SDK). Nothing here is specific to one resource.
11
+ *
12
+ * ⚠️ COMMA-LIST ORDER ROUND-TRIP IS NOT ASSUMED STABLE. OPNsense builds these from a PHP
13
+ * associative array (`ModelRelationField`/`OptionField::getNodeData()`), and nothing in the
14
+ * model XML promises the join order survives a save-then-read unchanged. Sorting and
15
+ * deduplicating both sides before comparing is what makes `matches` (alias-form.ts,
16
+ * group-form.ts) actually settle instead of reporting drift on a re-ordered list that changed
17
+ * nothing — the same reasoning `../proxmox/user-wire.ts`'s `groupSet` documents for PVE's
18
+ * `groups` field.
19
+ */
20
+
21
+ /** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
22
+ * default for a `BooleanField` is form-specific, so there is no house-wide fallback here. */
23
+ export const bool01 = (value: string | undefined, fallback: boolean): boolean =>
24
+ value === undefined ? fallback : value === '1';
25
+
26
+ /** The write direction, for symmetry — unused today (this family never writes), kept so a form
27
+ * helper never has to guess the spelling if lifting the read-only policy adds one later. */
28
+ export const toBool01 = (value: boolean): string => (value ? '1' : '0');
29
+
30
+ /**
31
+ * A comma-joined multi-select column, normalised to a sorted, deduplicated set — see the module
32
+ * header's ⚠️ for why normalising (not just splitting) is what makes a diff settle.
33
+ */
34
+ export const csvSet = (value: string | undefined): readonly string[] => {
35
+ if (value === undefined || value.trim() === '') return [];
36
+ const items = value
37
+ .split(',')
38
+ .map((item) => item.trim())
39
+ .filter((item) => item !== '');
40
+ return [...new Set(items)].sort();
41
+ };
42
+
43
+ /** A declared array, normalised the SAME way `csvSet` normalises the wire, so `matches` compares
44
+ * two sets built by one function rather than trusting the caller's array to already be sorted. */
45
+ export const csvSetOf = (value: readonly string[] | undefined): readonly string[] =>
46
+ csvSet(value === undefined ? undefined : value.join(','));
47
+
48
+ /**
49
+ * OPNsense's numeric-as-string fields (`"60"`, `"0"`), tolerant of an already-numeric input the
50
+ * same way `../proxmox/user-wire.ts`'s `int` is tolerant of PVE's mixed number/string releases.
51
+ */
52
+ export const numField = (value: string | undefined, fallback: number): number => {
53
+ if (value === undefined) return fallback;
54
+ const parsed = Number(value);
55
+ return Number.isFinite(parsed) ? parsed : fallback;
56
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * THE PROOF: no SDK write operation — `addItem`, `setItem`, `delItem`, `toggleItem`, `set`, or
3
+ * OPNsense's own POST-shaped search reads — is reachable from ANY handler of ANY resource in this
4
+ * family, for ANY input. `getOnlyOrFail` (fake-opnsense.ts) fails the test itself the instant a
5
+ * non-GET request is made, so this does not rely on inspecting what each handler happens to call
6
+ * — it fails loudly the moment anything but a `get()` reaches the wire.
7
+ *
8
+ * `alias.test.ts`, `category.test.ts` and `group.test.ts` already run every scenario through this
9
+ * same fake and would catch a write there too; this file exists so ONE place in this family
10
+ * states the guarantee plainly, exercising the public `handlers` object each resource file wires
11
+ * into its `Provider` (not just the lower-level `opnsenseOperations`), across every lifecycle
12
+ * method `Provider.of` calls and against both an absent and a drifted live object.
13
+ *
14
+ * ⚠️ `handlers.*` BAKES IN `CredentialsFromEnv` (resource.ts's `withCredentials`), unlike
15
+ * `opnsenseOperations(spec)` — see fake-opnsense.ts's warning on `fakeOpnsenseLayer`. Providing
16
+ * `fakeOpnsenseLayer`'s own `Credentials` layer from here does nothing: the inner `Effect.provide`
17
+ * already closed over that tag, so it wins. Left unaddressed, `CredentialsFromEnv` reads real
18
+ * `OPNSENSE_*` env vars via `EffectConfig`, finds none in CI, and `Effect.orDie`s before any
19
+ * request is built — `fake.seen` stays empty and `.every(...)` on an empty array is vacuously
20
+ * `true`. `fakeCredentialsConfigProvider` below fixes this WITHOUT touching real `process.env`
21
+ * (which a parallel test file could race on): it overrides the `effect/ConfigProvider` the
22
+ * `EffectConfig.String("OPNSENSE_*")` reads inside `CredentialsFromEnv` resolve against, which is
23
+ * a different tag CredentialsFromEnv never shadows — proved against this exact fake/real pattern
24
+ * before landing here. Every "only GETs" test below also asserts `fake.seen` has exactly the
25
+ * one GET `fetchLive` issues, so a run that silently makes zero requests (or a future
26
+ * double-GET regression) fails loudly instead of passing on an under-checked `.every()`.
27
+ *
28
+ * ⚠️ THIS FIX HAS ONE INVARIANT: `CredentialsFromEnv` (distilled-opnsense's `credentials.ts`) must
29
+ * keep resolving `OPNSENSE_*` through the AMBIENT `ConfigProvider` rather than one it pins
30
+ * itself. If it ever starts providing its own `ConfigProvider` — the way it already closes over
31
+ * the `Credentials` tag via `withCredentials` — this override would be shadowed the same way
32
+ * `fakeOpnsenseLayer`'s `Credentials` layer is today, and the vacuous-pass bug this file exists
33
+ * to prevent would return silently.
34
+ */
35
+ import { describe, expect, test } from 'bun:test';
36
+ import * as Effect from 'effect/Effect';
37
+ import * as Layer from 'effect/Layer';
38
+ import * as ConfigProvider from 'effect/ConfigProvider';
39
+ import { handlers as aliasHandlers, spec as aliasSpec } from './alias.ts';
40
+ import { handlers as categoryHandlers, spec as categorySpec } from './category.ts';
41
+ import {
42
+ FAKE_BASE,
43
+ FAKE_KEY,
44
+ FAKE_SECRET,
45
+ fakeOpnsenseLayer,
46
+ getOnlyOrFail,
47
+ } from './fake-opnsense.ts';
48
+ import { handlers as groupHandlers, spec as groupSpec } from './group.ts';
49
+
50
+ /**
51
+ * Overrides the `ConfigProvider` `CredentialsFromEnv`'s `EffectConfig.String("OPNSENSE_*")`
52
+ * resolves against, so `handlers.*` (which bakes in real env-var credentials resolution) gets
53
+ * the same placeholder values `fakeOpnsenseLayer`'s default `Credentials` layer uses — without
54
+ * ever reading or writing real `process.env`.
55
+ */
56
+ const fakeCredentialsConfigProvider = ConfigProvider.layer(
57
+ ConfigProvider.fromUnknown({
58
+ OPNSENSE_API_KEY: FAKE_KEY,
59
+ OPNSENSE_API_SECRET: FAKE_SECRET,
60
+ OPNSENSE_URL: FAKE_BASE,
61
+ }),
62
+ );
63
+
64
+ /** `fakeOpnsenseLayer` plus the env override `handlers.*` needs — see the file header. */
65
+ const fakeHandlersLayer = (fetchFn: typeof globalThis.fetch) =>
66
+ Layer.mergeAll(fakeOpnsenseLayer(fetchFn), fakeCredentialsConfigProvider);
67
+
68
+ const families = [
69
+ {
70
+ empty: Response.json({ alias: {} }),
71
+ handlers: aliasHandlers,
72
+ live: Response.json({
73
+ alias: { aliases: { alias: { u: { content: 'x', enabled: '1', name: 'n', type: 'host' } } } },
74
+ }),
75
+ props: { content: 'x', name: 'n', type: 'host', uuid: 'u' },
76
+ resourceType: aliasSpec.resourceType,
77
+ },
78
+ {
79
+ empty: Response.json({ category: {} }),
80
+ handlers: categoryHandlers,
81
+ live: Response.json({ category: { categories: { category: { u: { name: 'n' } } } } }),
82
+ props: { name: 'n', uuid: 'u' },
83
+ resourceType: categorySpec.resourceType,
84
+ },
85
+ {
86
+ empty: Response.json({ group: {} }),
87
+ handlers: groupHandlers,
88
+ live: Response.json({
89
+ group: { ifgroupentry: { u: { ifname: 'n', members: '', sequence: '0' } } },
90
+ }),
91
+ props: { ifname: 'n', members: [], uuid: 'u' },
92
+ resourceType: groupSpec.resourceType,
93
+ },
94
+ ];
95
+
96
+ describe.each(families)(
97
+ '$resourceType — write-refusal proof',
98
+ ({ empty, handlers, live, props }) => {
99
+ test('list() is empty and issues no request', async () => {
100
+ const fake = getOnlyOrFail(() => {
101
+ throw new Error('list() must never call the wire at all');
102
+ });
103
+ const result = await Effect.runPromise(
104
+ Effect.provide(handlers.list(), fakeOpnsenseLayer(fake.fetch)),
105
+ );
106
+ expect(result).toEqual([]);
107
+ expect(fake.seen).toHaveLength(0);
108
+ });
109
+
110
+ test('reconcile against a LIVE object still only GETs, then refuses', async () => {
111
+ const fake = getOnlyOrFail(() => live);
112
+ const outcome = await Effect.runPromiseExit(
113
+ Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
114
+ );
115
+ expect(outcome._tag).toBe('Failure');
116
+ // ⚠️ Not sufficient alone — `.every()` on an empty array is vacuously true. See the file
117
+ // header: without `fakeHandlersLayer`'s ConfigProvider override this assertion never ran
118
+ // a single request, because `CredentialsFromEnv` died first. Exactly 1, not just > 0:
119
+ // `fetchLive` (alias.ts/category.ts/group.ts) issues one `get()` — matches
120
+ // `alias.test.ts`'s own `toHaveLength(1)` and also catches a future double-GET regression.
121
+ expect(fake.seen).toHaveLength(1);
122
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
123
+ });
124
+
125
+ test('reconcile against an ABSENT object still only GETs, then refuses', async () => {
126
+ const fake = getOnlyOrFail(() => empty);
127
+ const outcome = await Effect.runPromiseExit(
128
+ Effect.provide(handlers.reconcile({ news: props as never }), fakeHandlersLayer(fake.fetch)),
129
+ );
130
+ expect(outcome._tag).toBe('Failure');
131
+ expect(fake.seen).toHaveLength(1);
132
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
133
+ });
134
+
135
+ test('delete makes ZERO requests and refuses', async () => {
136
+ const fake = getOnlyOrFail(() => live);
137
+ const outcome = await Effect.runPromiseExit(
138
+ Effect.provide(handlers.delete({ olds: props as never }), fakeOpnsenseLayer(fake.fetch)),
139
+ );
140
+ expect(outcome._tag).toBe('Failure');
141
+ expect(fake.seen).toHaveLength(0);
142
+ });
143
+
144
+ test('nuke is skipped, so account-wide teardown never even lists this type', () => {
145
+ expect(handlers.nuke).toEqual({ skip: true });
146
+ });
147
+ },
148
+ );
@@ -13,7 +13,7 @@ import { constraintsFor, formViolations } from './constraint-guard.ts';
13
13
  import type { PbsTarget, PveTarget } from './credentials.ts';
14
14
  import { createBody as ifaceCreateForm } from './node-network-form.ts';
15
15
  import { resolveGroups, targetForm } from './pbs-notification-target-form.ts';
16
- import { createForm as zfsCreateForm } from './zfs-pool-write.ts';
16
+ import { createForm as zfsCreateForm } from './zfs-pool-form.ts';
17
17
 
18
18
  const TARGET: PveTarget = { members: ['pve.test:8006'], mount: 'pve-test', scheme: 'pve' };
19
19
  const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `Proxmox.Storage`'s WRITE side: the locator bag, the create/update forms, and the translation
3
+ * into distilled's own typed request shape. Split out of storage-wire.ts (2026-09-24) to keep
4
+ * both files under the 250-line cap — the api-token.ts/api-token-form.ts seam, same reasoning.
5
+ */
6
+ import type * as storage from '@distilled.cloud/proxmox/storage';
7
+ import type { StorageProps } from './storage.ts';
8
+ import { flag } from './values.ts';
9
+
10
+ export const STORAGE_CREATE = 'pve:POST /storage';
11
+ export const STORAGE_UPDATE = 'pve:PUT /storage/{storage}';
12
+
13
+ /**
14
+ * The create-time, plugin-specific half of a storage: `dir` wants `path`, `nfs` `server`+`export`,
15
+ * `cifs` `server`+`share`, `pbs` `server`+`datastore`, `rbd` `pool`+`monhost`, `lvmthin`
16
+ * `vgname`+`thinpool`, `zfspool` `pool`. A bag rather than two dozen named fields, because PVE
17
+ * ships about that many plugins and each brings its own locator. ⚠️ Never diffed — see `matches`
18
+ * (storage-wire.ts).
19
+ *
20
+ * ⛔ `password`, `keyring` AND `encryption-key` ARE `never` ON PURPOSE — see the ⛔ in storage.ts's
21
+ * header. POSITIVE-CONTROLLED rather than assumed: all three are a compile error, `TS2322: Type
22
+ * 'string' is not assignable to type 'undefined'`.
23
+ * ⚠️ EVERY KEY IN THIS BAG GOES THROUGH `underscored` (below) BEFORE IT REACHES DISTILLED — see
24
+ * that function's own header. A caller writes PVE's own field names here (`fs-name`, matching
25
+ * `pvesm`/pve-docs), never distilled's renamed ones.
26
+ */
27
+ export type StorageLocator = Record<string, string> & {
28
+ password?: never;
29
+ keyring?: never;
30
+ 'encryption-key'?: never;
31
+ };
32
+
33
+ const field = (name: string, value: string | undefined): Record<string, string> =>
34
+ value === undefined ? {} : { [name]: value };
35
+
36
+ /**
37
+ * ⚠️ THE CAST DROPS THE THREE `never` GUARDS AND NOTHING ELSE, and it is load-bearing rather than
38
+ * lazy: an intersection carrying OPTIONAL properties is not assignable to `Record<string,string>`
39
+ * at all, so the guards and the form body cannot both exist without one conversion. At runtime a
40
+ * locator is a plain string map; the guards only ever existed to fail a declaration.
41
+ */
42
+ const locatorForm = (locator: StorageLocator | undefined) =>
43
+ ({ ...locator }) as Record<string, string>;
44
+
45
+ /**
46
+ * ★ ROUTES EVERY OUTGOING FIELD THROUGH DISTILLED'S OWN TYPED PROPERTY, RATHER THAN THROUGH ITS
47
+ * "UNKNOWN KEY" FALLBACK — NOT BECAUSE THE WIRE NEEDS THE UNDERSCORED SPELLING. MEASURED both
48
+ * ways (`storage-read-failure.test.ts`'s create test, run once with this function and once
49
+ * without it): PVE's own hyphenated name (`content-dirs=…`) reaches the wire either way —
50
+ * `@distilled.cloud/proxmox`'s generated `T.Body("content-dirs")` annotation re-hyphenates its
51
+ * underscored `content_dirs` property on the way out, AND an untranslated hyphenated key also
52
+ * passes through unchanged as an "unknown key", form-encoded alongside the recognized ones (the
53
+ * GET-request version of that same passthrough is what caused kit 0.31.1's regression — see
54
+ * user.ts/group.ts — but a POST/PUT is already body-bearing, so the failure mode there doesn't
55
+ * apply). So EITHER path is wire-correct for a plain rename. What `underscored` buys instead:
56
+ * distilled's own schema only runs its declared per-field logic (a `T.Body()` rename, but also
57
+ * whatever type coercion or list-joining a FUTURE plugin field might carry) for a property it
58
+ * recognizes — an unrecognized key gets none of that, silently. `StorageLocator` is free-form
59
+ * on purpose (the header's own reasoning), so this file cannot enumerate every field a plugin
60
+ * might need; routing through the recognized property is the one way to get distilled's own
61
+ * per-field handling for a field this package's author never tried, rather than a bare rename.
62
+ * Renamed against every hyphenated field on the generated `CreateStorageRequest`/
63
+ * `PutStorageRequest` today (`prune-backups` -> `prune_backups`, `fs-name` -> `fs_name`,
64
+ * `max-protected-backups` -> `max_protected_backups`, `zfs-base-path` -> `zfs_base_path`,
65
+ * `content-dirs` -> `content_dirs`, `saferemove-stepsize` -> `saferemove_stepsize`, and so on) —
66
+ * a generic transform, not a lookup table, for the same free-form reason. No other migrated
67
+ * family (acl/group/role/user/api-token) hit any of this: none of their fields are hyphenated.
68
+ * ⛔ NEVER APPLIED TO THE FORM `guardWrite` CHECKS, REGARDLESS. The generated vendor-constraint
69
+ * tables (`generated/constraints/pve-storage.ts`) are keyed by PVE's OWN hyphenated names —
70
+ * running this first would make every constraint on `prune-backups`, `fs-name`, etc. silently
71
+ * never match, the vendor check passing over a value it never actually inspected. `createForm`/
72
+ * `updateForm` stay hyphenated for exactly that reason; only `toDistilledCreate`/
73
+ * `toDistilledUpdate` (the actual SDK call, storage.ts's `reconcile`) apply this.
74
+ */
75
+ const underscored = (form: Record<string, string>): Record<string, string> =>
76
+ Object.fromEntries(Object.entries(form).map(([key, value]) => [key.replaceAll('-', '_'), value]));
77
+
78
+ /**
79
+ * The mutable half, for create and update alike.
80
+ *
81
+ * ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED: undeclared means UNMANAGED here, unlike
82
+ * `Proxmox.Pool` where an absent comment means `''`. A storage has no single default to fall back
83
+ * on — `content` defaults per plugin, and clearing a field needs an explicit `delete=` parameter
84
+ * rather than an empty value — so a guessed default would rewrite a storage somebody tuned by
85
+ * hand, and comparing one would report drift nobody declared.
86
+ *
87
+ * ⚠️ RECONCILE PUTs UNCONDITIONALLY whenever the object exists (group.ts's own reconcile has the
88
+ * same note), so this form must be safe to re-apply to a storage that already matches. It is:
89
+ * the declared set and nothing else, and PVE leaves absent parameters alone.
90
+ *
91
+ * ★ KEPT HYPHENATED (`'prune-backups'`, PVE's own wire name), NOT DISTILLED'S UNDERSCORED
92
+ * `prune_backups` — this form is what `guardWrite` checks (storage.ts), and the generated
93
+ * vendor-constraint table is keyed by PVE's own names (`underscored`'s own header has the
94
+ * measured evidence). `toDistilledCreate`/`toDistilledUpdate` translate a COPY for the actual
95
+ * SDK call; this function and its callers never see the underscored form.
96
+ */
97
+ const mutable = (props: StorageProps) => ({
98
+ ...field('comment', props.comment),
99
+ ...field('content', props.content),
100
+ ...field('disable', flag(props.disable)),
101
+ ...field('nodes', props.nodes),
102
+ ...field('preallocation', props.preallocation),
103
+ ...field('prune-backups', props['prune-backups']),
104
+ ...field('shared', flag(props.shared)),
105
+ });
106
+
107
+ /**
108
+ * ⚠️ THE BAG IS SPREAD FIRST SO NOTHING IN IT CAN SHADOW A FIELD THIS RESOURCE MANAGES. A stray
109
+ * `storage` or `type` in a locator would otherwise rename the object being created, and PVE
110
+ * would build the wrong thing under a name Alchemy then records as the declared one.
111
+ * ★ HYPHENATED, THE WIRE SHAPE — `guardWrite` (storage.ts) checks this form directly; see
112
+ * `underscored`'s header for why translating it here would blind that check.
113
+ */
114
+ export const createForm = (props: StorageProps): Record<string, string> => ({
115
+ ...locatorForm(props.locator),
116
+ ...mutable(props),
117
+ storage: props.storage,
118
+ type: props.type,
119
+ });
120
+
121
+ /**
122
+ * ⚠️ `type` AND THE LOCATOR ARE NEVER SENT ON UPDATE — PVE's PUT refuses the create-only fields
123
+ * (`PutStorageRequest` has no `type`, and the plugin locator fields it does carry are for a
124
+ * different purpose: moving an existing volume, not re-pointing the definition), and comparing
125
+ * either could only plan an update that no write can apply: a plan that reports work forever.
126
+ */
127
+ export const updateForm = (props: StorageProps): Record<string, string> => ({
128
+ ...mutable(props),
129
+ storage: props.storage,
130
+ });
131
+
132
+ /**
133
+ * The actual `createStorage` call body — `createForm`, translated once. See `underscored`.
134
+ * ⚠️ `as unknown as` — same load-bearing cast as `locatorForm`'s own ⚠️: a plain
135
+ * `Record<string,string>` does not structurally overlap a named-field interface enough for
136
+ * TypeScript to accept a direct cast, even though every field it carries fits one of the
137
+ * interface's own (all-optional-but-two) properties.
138
+ */
139
+ export const toDistilledCreate = (props: StorageProps): storage.CreateStorageRequest =>
140
+ underscored(createForm(props)) as unknown as storage.CreateStorageRequest;
141
+
142
+ /** The actual `putStorage` call body — `updateForm`, translated once. See `toDistilledCreate`. */
143
+ export const toDistilledUpdate = (props: StorageProps): storage.PutStorageRequest =>
144
+ underscored(updateForm(props)) as unknown as storage.PutStorageRequest;