@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.
- package/README.md +39 -3
- package/dist/linux/container-form.d.ts +129 -0
- package/dist/linux/container-form.d.ts.map +1 -0
- package/dist/linux/container-generator.d.ts +74 -0
- package/dist/linux/container-generator.d.ts.map +1 -0
- package/dist/linux/container-handlers.d.ts +15 -0
- package/dist/linux/container-handlers.d.ts.map +1 -0
- package/dist/linux/container-lifecycle.d.ts +37 -0
- package/dist/linux/container-lifecycle.d.ts.map +1 -0
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -0
- package/dist/linux/container-secrets.d.ts +37 -0
- package/dist/linux/container-secrets.d.ts.map +1 -0
- package/dist/linux/container-settle.d.ts +34 -0
- package/dist/linux/container-settle.d.ts.map +1 -0
- package/dist/linux/container.d.ts +30 -0
- package/dist/linux/container.d.ts.map +1 -0
- package/dist/linux/fake-quadlet.d.ts +25 -0
- package/dist/linux/fake-quadlet.d.ts.map +1 -0
- package/dist/linux/index.d.ts +6 -3
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +641 -268
- package/dist/linux/index.js.map +15 -7
- package/dist/linux/providers.d.ts +1 -1
- package/dist/linux/providers.d.ts.map +1 -1
- package/dist/linux/systemctl.d.ts +7 -1
- package/dist/linux/systemctl.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +29 -0
- package/dist/opnsense/alias-form.d.ts.map +1 -0
- package/dist/opnsense/alias.d.ts +81 -0
- package/dist/opnsense/alias.d.ts.map +1 -0
- package/dist/opnsense/category-form.d.ts +10 -0
- package/dist/opnsense/category-form.d.ts.map +1 -0
- package/dist/opnsense/category.d.ts +58 -0
- package/dist/opnsense/category.d.ts.map +1 -0
- package/dist/opnsense/credentials.d.ts +36 -0
- package/dist/opnsense/credentials.d.ts.map +1 -0
- package/dist/opnsense/fake-opnsense.d.ts +51 -0
- package/dist/opnsense/fake-opnsense.d.ts.map +1 -0
- package/dist/opnsense/group-form.d.ts +11 -0
- package/dist/opnsense/group-form.d.ts.map +1 -0
- package/dist/opnsense/group.d.ts +62 -0
- package/dist/opnsense/group.d.ts.map +1 -0
- package/dist/opnsense/index.d.ts +16 -0
- package/dist/opnsense/index.d.ts.map +1 -0
- package/dist/opnsense/index.js +211 -0
- package/dist/opnsense/index.js.map +20 -0
- package/dist/opnsense/policy.d.ts +20 -0
- package/dist/opnsense/policy.d.ts.map +1 -0
- package/dist/opnsense/providers.d.ts +13 -0
- package/dist/opnsense/providers.d.ts.map +1 -0
- package/dist/opnsense/resource.d.ts +74 -0
- package/dist/opnsense/resource.d.ts.map +1 -0
- package/dist/opnsense/wire.d.ts +39 -0
- package/dist/opnsense/wire.d.ts.map +1 -0
- package/dist/proxmox/index.js +106 -47
- package/dist/proxmox/index.js.map +6 -4
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +48 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -0
- package/dist/unifi/firewall-zone-form.d.ts +40 -0
- package/dist/unifi/firewall-zone-form.d.ts.map +1 -0
- package/dist/unifi/firewall-zone.d.ts +50 -0
- package/dist/unifi/firewall-zone.d.ts.map +1 -0
- package/dist/unifi/index.d.ts +13 -0
- package/dist/unifi/index.d.ts.map +1 -0
- package/dist/unifi/index.js +190 -0
- package/dist/unifi/index.js.map +16 -0
- package/dist/unifi/network-form.d.ts +67 -0
- package/dist/unifi/network-form.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +67 -0
- package/dist/unifi/network.d.ts.map +1 -0
- package/dist/unifi/policy.d.ts +20 -0
- package/dist/unifi/policy.d.ts.map +1 -0
- package/dist/unifi/providers.d.ts +13 -0
- package/dist/unifi/providers.d.ts.map +1 -0
- package/dist/unifi/resource.d.ts +64 -0
- package/dist/unifi/resource.d.ts.map +1 -0
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/package.json +12 -2
- package/src/linux/container-fixture.test.ts +92 -0
- package/src/linux/container-form.ts +243 -0
- package/src/linux/container-generator.test.ts +195 -0
- package/src/linux/container-generator.ts +113 -0
- package/src/linux/container-handlers.ts +73 -0
- package/src/linux/container-lifecycle.test.ts +209 -0
- package/src/linux/container-lifecycle.ts +217 -0
- package/src/linux/container-preflight.ts +97 -0
- package/src/linux/container-secrets.test.ts +171 -0
- package/src/linux/container-secrets.ts +112 -0
- package/src/linux/container-settle.ts +97 -0
- package/src/linux/container.ts +60 -0
- package/src/linux/fake-quadlet.ts +127 -0
- package/src/linux/index.ts +11 -3
- package/src/linux/providers.ts +2 -0
- package/src/linux/systemctl.ts +9 -0
- package/src/opnsense/alias-form.test.ts +89 -0
- package/src/opnsense/alias-form.ts +53 -0
- package/src/opnsense/alias.test.ts +203 -0
- package/src/opnsense/alias.ts +117 -0
- package/src/opnsense/category-form.test.ts +54 -0
- package/src/opnsense/category-form.ts +20 -0
- package/src/opnsense/category.test.ts +138 -0
- package/src/opnsense/category.ts +82 -0
- package/src/opnsense/credentials.ts +41 -0
- package/src/opnsense/fake-opnsense.ts +79 -0
- package/src/opnsense/group-form.test.ts +75 -0
- package/src/opnsense/group-form.ts +25 -0
- package/src/opnsense/group.test.ts +141 -0
- package/src/opnsense/group.ts +78 -0
- package/src/opnsense/index.ts +45 -0
- package/src/opnsense/policy.ts +49 -0
- package/src/opnsense/providers.ts +21 -0
- package/src/opnsense/resource.ts +146 -0
- package/src/opnsense/wire.ts +56 -0
- package/src/opnsense/write-refusal.test.ts +148 -0
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/unifi/fake-unifi.ts +60 -0
- package/src/unifi/firewall-zone-form.ts +65 -0
- package/src/unifi/firewall-zone.test.ts +88 -0
- package/src/unifi/firewall-zone.ts +66 -0
- package/src/unifi/index.ts +26 -0
- package/src/unifi/network-form.ts +207 -0
- package/src/unifi/network.test.ts +209 -0
- package/src/unifi/network.ts +83 -0
- package/src/unifi/policy.ts +37 -0
- package/src/unifi/providers.ts +15 -0
- package/src/unifi/resource.test.ts +133 -0
- package/src/unifi/resource.ts +131 -0
package/src/proxmox/storage.ts
CHANGED
|
@@ -12,19 +12,48 @@
|
|
|
12
12
|
* ⛔ ONE PRIVILEGE COVERS THIS WHOLE FAMILY AND IT IS `Datastore.Allocate` ON `/storage`. Create,
|
|
13
13
|
* update, delete AND the single-object read all check it — `Datastore.Audit` is not enough for
|
|
14
14
|
* any of them. MEASURED: `GET /storage/local` under the auditor-scoped `read` lease answers 403
|
|
15
|
-
* "Permission check failed (/storage/local, Datastore.Allocate)".
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
15
|
+
* "Permission check failed (/storage/local, Datastore.Allocate)". So this family reads with
|
|
16
|
+
* `provision`, same as acl.ts and unlike group.ts/user.ts — `provision-baseline.ts` carries
|
|
17
|
+
* `Datastore.Allocate`.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ ON A LANE WHOSE CREDENTIAL CANNOT MINT `provision` (the agent's `read`-role lane), THIS
|
|
20
|
+
* FAMILY SHIPPED WITH THE ORIGINAL CRIES-WOLF BUG — measured live, 2026-09-24: `bun run plan`
|
|
21
|
+
* showed all 5 storages (`cephfs-tb4`, `cephtb4`, `local`, `local-zfs`, `pbs`) as `update`, with
|
|
22
|
+
* no warning, because `client.ts`'s `pveOperations.read` folded the REFUSED mint into `undefined`
|
|
23
|
+
* the same way it folds a genuine absence — `unreadable-read.ts`'s own header names this family
|
|
24
|
+
* by name as one of the two the fix was written for. Migrated here the way acl.ts/group.ts/
|
|
25
|
+
* user.ts were (kit 0.31.1/0.31.2): a refused `provision` mint now reports `noop` with a logged
|
|
26
|
+
* warning instead of forcing `update` with nothing compared.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ A MISSING STORAGE IS A 500, NOT A 404 — MEASURED against the live cluster (TB4, 2026-09-24,
|
|
29
|
+
* admin lane, read-only `GET /storage/hf-measure-nonexistent-probe`): `{"message":"storage
|
|
30
|
+
* '...' does not exist\n"}` at HTTP 500. `storage-wire.ts`'s `readStorageOrFail`/`readStorage`
|
|
31
|
+
* carry the same dual-path split as user.ts/group.ts for exactly this reason — see that file.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ `maxfiles` IS GONE — A MEASURED `@distilled.cloud/proxmox` SDK GAP, NOT A SCOPE TRIM. The
|
|
34
|
+
* vendor's generated `CreateStorageRequest`/`PutStorageRequest` (storage-wire.ts's `mutable`)
|
|
35
|
+
* have no `maxfiles` field at all — grepped across the whole package, not just this endpoint —
|
|
36
|
+
* where `prune-backups`, its documented replacement, is present and correctly wired
|
|
37
|
+
* (`T.Body("prune-backups")` on the generated `prune_backups` property). Silently dropping a
|
|
38
|
+
* declared `maxfiles` value would have been the same class of bug this whole migration exists to
|
|
39
|
+
* close: a write that looks like it landed and did not. So `maxfiles?: never` below, the same
|
|
40
|
+
* COMPILE-ERROR treatment `StorageLocator`'s `password`/`keyring`/`encryption-key` already get —
|
|
41
|
+
* a declaration that sets it is refused before a plan ever runs, not silently ignored by one.
|
|
42
|
+
* None of this cluster's 5 live storages set it (measured, live response bodies below). If a
|
|
43
|
+
* consumer genuinely needs it, the fix belongs in `@distilled.cloud/proxmox`'s generator, not a
|
|
44
|
+
* hand-rolled bypass here — see the provider-walk-down doctrine on vendor gaps.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ EVERY HYPHENATED PVE FIELD NAME BECOMES AN UNDERSCORE IN DISTILLED'S GENERATED TYPESCRIPT —
|
|
47
|
+
* MEASURED across the WHOLE generated `CreateStorageRequest`/`PutStorageRequest`, not one field:
|
|
48
|
+
* `prune-backups` -> `prune_backups`, `fs-name` -> `fs_name`, `max-protected-backups` ->
|
|
49
|
+
* `max_protected_backups`, and so on for every plugin field `StorageLocator`'s free-form bag can
|
|
50
|
+
* carry (`cephfs-tb4`'s own live locator uses `fs-name`, measured below). No other family
|
|
51
|
+
* migrated so far has a hyphenated field at all, so this is new here. `storage-wire.ts`'s
|
|
52
|
+
* `underscored` translates a form BUILT with PVE's own names into distilled's shape, but ONLY
|
|
53
|
+
* for the actual SDK call (`toDistilledCreate`/`toDistilledUpdate`) — never for the form
|
|
54
|
+
* `guardWrite` checks (`createForm`/`updateForm`), which the generated vendor-constraint tables
|
|
55
|
+
* key by PVE's own hyphenated names. Running the translation before that check would make it
|
|
56
|
+
* silently stop inspecting the very fields it exists to catch.
|
|
28
57
|
*
|
|
29
58
|
* ⛔ THERE IS NO `password` PROP AND THERE MUST NEVER BE ONE. PBS and CIFS storages take one at
|
|
30
59
|
* create time, and Alchemy persists attributes UNENCRYPTED — a secret in props is one careless
|
|
@@ -33,28 +62,36 @@
|
|
|
33
62
|
* a COMPILE ERROR rather than a leak somebody finds in a backup months later. Set it once out of
|
|
34
63
|
* band (`pvesm set <id> --password`); PVE never returns it on read, so nothing here could diff it
|
|
35
64
|
* anyway.
|
|
65
|
+
*
|
|
66
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers` (`resource.ts`) ONTO
|
|
67
|
+
* `@distilled.cloud/proxmox`'s typed `storage.getStorage`/`createStorage`/`putStorage`/
|
|
68
|
+
* `deleteStorage` (2026-09-24, decision 43's proxmox walk-down, nodes/storage sub-area, first
|
|
69
|
+
* resource). `distilled-pve.ts`'s `runPve` replaces `pve()`; the cries-wolf fix is wired the
|
|
70
|
+
* same way as acl.ts/group.ts/user.ts.
|
|
36
71
|
*/
|
|
37
72
|
import { Resource } from 'alchemy';
|
|
73
|
+
import { isResolved } from 'alchemy/Diff';
|
|
38
74
|
import * as Provider from 'alchemy/Provider';
|
|
75
|
+
import * as storage from '@distilled.cloud/proxmox/storage';
|
|
39
76
|
import * as Effect from 'effect/Effect';
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
77
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
78
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
79
|
+
import { runPve } from './distilled-pve.ts';
|
|
80
|
+
import {
|
|
81
|
+
STORAGE_CREATE,
|
|
82
|
+
STORAGE_UPDATE,
|
|
83
|
+
createForm,
|
|
84
|
+
toDistilledCreate,
|
|
85
|
+
toDistilledUpdate,
|
|
86
|
+
updateForm,
|
|
87
|
+
} from './storage-form.ts';
|
|
88
|
+
import { dropUnreadable, matches, readStorage, readStorageOrFail } from './storage-wire.ts';
|
|
89
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
90
|
+
import type { StorageAttributes } from './storage-wire.ts';
|
|
91
|
+
import type { StorageLocator } from './storage-form.ts';
|
|
42
92
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
* `cifs` `server`+`share`, `pbs` `server`+`datastore`, `rbd` `pool`+`monhost`, `lvmthin`
|
|
46
|
-
* `vgname`+`thinpool`, `zfspool` `pool`. A bag rather than two dozen named fields, because PVE
|
|
47
|
-
* ships about that many plugins and each brings its own locator. ⚠️ Never diffed — see `matches`.
|
|
48
|
-
*
|
|
49
|
-
* ⛔ `password`, `keyring` AND `encryption-key` ARE `never` ON PURPOSE — see the ⛔ in the header.
|
|
50
|
-
* POSITIVE-CONTROLLED rather than assumed: all three are a compile error, `TS2322: Type 'string'
|
|
51
|
-
* is not assignable to type 'undefined'`.
|
|
52
|
-
*/
|
|
53
|
-
export type StorageLocator = Record<string, string> & {
|
|
54
|
-
password?: never;
|
|
55
|
-
keyring?: never;
|
|
56
|
-
'encryption-key'?: never;
|
|
57
|
-
};
|
|
93
|
+
/** Re-exported so a caller of this file still finds them here, unchanged by the split below. */
|
|
94
|
+
export type { StorageAttributes, StorageLocator };
|
|
58
95
|
|
|
59
96
|
export interface StorageProps extends WithTarget {
|
|
60
97
|
/** PVE's primary key, cluster-wide — the string an LXC's `storage` and `ostemplate` name. */
|
|
@@ -85,26 +122,8 @@ export interface StorageProps extends WithTarget {
|
|
|
85
122
|
*/
|
|
86
123
|
'prune-backups'?: string;
|
|
87
124
|
comment?: string;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
* other. `0` means UNLIMITED to PVE rather than "unset", hence the `-1` in the attribute.
|
|
91
|
-
*/
|
|
92
|
-
maxfiles?: number;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface StorageAttributes {
|
|
96
|
-
storage: string;
|
|
97
|
-
/** Reported so a plan can show what it points at; never diffed — see `type` above. */
|
|
98
|
-
type: string;
|
|
99
|
-
content: string;
|
|
100
|
-
nodes: string;
|
|
101
|
-
disable: boolean;
|
|
102
|
-
shared: boolean;
|
|
103
|
-
preallocation: string;
|
|
104
|
-
'prune-backups': string;
|
|
105
|
-
comment: string;
|
|
106
|
-
/** ⚠️ `-1` means "no maxfiles in storage.cfg". `0` is a real setting meaning unlimited. */
|
|
107
|
-
maxfiles: number;
|
|
125
|
+
/** ⛔ REMOVED — a measured `@distilled.cloud/proxmox` gap, not a scope trim. See the header. */
|
|
126
|
+
maxfiles?: never;
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
export interface ProxmoxStorage extends Resource<
|
|
@@ -120,129 +139,100 @@ export const ProxmoxStorage = Resource<ProxmoxStorage>('Proxmox.Storage', {
|
|
|
120
139
|
defaultRemovalPolicy: 'retain',
|
|
121
140
|
});
|
|
122
141
|
|
|
123
|
-
const str = (value: unknown, fallback = '') => (typeof value === 'string' ? value : fallback);
|
|
124
|
-
|
|
125
|
-
const field = (name: string, value: string | undefined): Record<string, string> =>
|
|
126
|
-
value === undefined ? {} : { [name]: value };
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* ⚠️ THE CAST DROPS THE THREE `never` GUARDS AND NOTHING ELSE, and it is load-bearing rather than
|
|
130
|
-
* lazy: an intersection carrying OPTIONAL properties is not assignable to `Record<string,string>`
|
|
131
|
-
* at all, so the guards and the form body cannot both exist without one conversion. At runtime a
|
|
132
|
-
* locator is a plain string map; the guards only ever existed to fail a declaration.
|
|
133
|
-
*/
|
|
134
|
-
const locatorForm = (locator: StorageLocator | undefined) =>
|
|
135
|
-
({ ...locator }) as Record<string, string>;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* ⚠️ PVE RE-SERIALISES ITS LIST FIELDS IN ITS OWN ORDER — `content` and `nodes` are parsed into a
|
|
139
|
-
* set and written back sorted, `prune-backups` is a property string that comes back in schema
|
|
140
|
-
* order. Comparing raw strings would report an update for `iso,backup` against `backup,iso` on
|
|
141
|
-
* every plan, and the PUT would "fix" it into the identical string. Trimming matters too: a
|
|
142
|
-
* declared `iso, backup` would otherwise never equal what PVE stores.
|
|
143
|
-
*/
|
|
144
|
-
const tokens = (value: string) =>
|
|
145
|
-
value
|
|
146
|
-
.split(',')
|
|
147
|
-
.map((part) => part.trim())
|
|
148
|
-
.filter((part) => part !== '')
|
|
149
|
-
.sort()
|
|
150
|
-
.join(',');
|
|
151
|
-
|
|
152
|
-
const sameList = (declared: string | undefined, live: string) =>
|
|
153
|
-
declared === undefined || tokens(declared) === tokens(live);
|
|
154
|
-
|
|
155
|
-
const same = <T>(declared: T | undefined, live: T) => declared === undefined || declared === live;
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The mutable half, for create and update alike.
|
|
159
|
-
*
|
|
160
|
-
* ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED: undeclared means UNMANAGED here, unlike
|
|
161
|
-
* `Proxmox.Pool` where an absent comment means `''`. A storage has no single default to fall back
|
|
162
|
-
* on — `content` defaults per plugin, and clearing a field needs an explicit `delete=` parameter
|
|
163
|
-
* rather than an empty value — so a guessed default would rewrite a storage somebody tuned by
|
|
164
|
-
* hand, and comparing one would report drift nobody declared.
|
|
165
|
-
*
|
|
166
|
-
* ⚠️ RECONCILE PUTs UNCONDITIONALLY whenever the object exists (resource.ts), so this form must be
|
|
167
|
-
* safe to re-apply to a storage that already matches. It is: the declared set and nothing else,
|
|
168
|
-
* and PVE leaves absent parameters alone.
|
|
169
|
-
*/
|
|
170
|
-
const mutable = (props: StorageProps): Record<string, string> => ({
|
|
171
|
-
...field('comment', props.comment),
|
|
172
|
-
...field('content', props.content),
|
|
173
|
-
...field('disable', flag(props.disable)),
|
|
174
|
-
...field('maxfiles', props.maxfiles === undefined ? undefined : String(props.maxfiles)),
|
|
175
|
-
...field('nodes', props.nodes),
|
|
176
|
-
...field('preallocation', props.preallocation),
|
|
177
|
-
...field('prune-backups', props['prune-backups']),
|
|
178
|
-
...field('shared', flag(props.shared)),
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const handlers = pveHandlers<StorageProps, StorageAttributes>({
|
|
182
|
-
// ⛔ PVE gates this family's ITEM read on the allocate privilege, not the audit one — see
|
|
183
|
-
// `readRole` in resource.ts. The auditor-shaped lease reads 403, which `read` turns into
|
|
184
|
-
// "absent", and the plan then says create for an object that is plainly there.
|
|
185
|
-
readRole: 'provision',
|
|
186
|
-
/**
|
|
187
|
-
* ⚠️ `digest` IS DELIBERATELY NOT AN ATTRIBUTE. PVE returns one on every storage read and it
|
|
188
|
-
* covers storage.cfg as a FILE, not this section of it, so keeping it would rewrite this
|
|
189
|
-
* resource's state whenever an unrelated storage was edited — churn that reads like drift.
|
|
190
|
-
*/
|
|
191
|
-
attributes: (live, props) => ({
|
|
192
|
-
comment: str(live['comment']),
|
|
193
|
-
content: str(live['content']),
|
|
194
|
-
disable: bool(live['disable']),
|
|
195
|
-
maxfiles: typeof live['maxfiles'] === 'number' ? live['maxfiles'] : -1,
|
|
196
|
-
nodes: str(live['nodes']),
|
|
197
|
-
preallocation: str(live['preallocation']),
|
|
198
|
-
'prune-backups': str(live['prune-backups']),
|
|
199
|
-
shared: bool(live['shared']),
|
|
200
|
-
storage: props.storage,
|
|
201
|
-
type: str(live['type'], props.type),
|
|
202
|
-
}),
|
|
203
|
-
collection: () => 'storage',
|
|
204
|
-
/**
|
|
205
|
-
* ⚠️ THE BAG IS SPREAD FIRST SO NOTHING IN IT CAN SHADOW A FIELD THIS RESOURCE MANAGES. A stray
|
|
206
|
-
* `storage` or `type` in a locator would otherwise rename the object being created, and PVE
|
|
207
|
-
* would build the wrong thing under a name Alchemy then records as the declared one.
|
|
208
|
-
*/
|
|
209
|
-
createForm: (props) => ({
|
|
210
|
-
...locatorForm(props.locator),
|
|
211
|
-
...mutable(props),
|
|
212
|
-
storage: props.storage,
|
|
213
|
-
type: props.type,
|
|
214
|
-
}),
|
|
215
|
-
/**
|
|
216
|
-
* ⚠️ `type` AND THE LOCATOR ARE NEVER COMPARED, AND AN UNDECLARED FIELD IS NOT COMPARED EITHER.
|
|
217
|
-
* PVE returns the create-only fields on read but refuses them on PUT, so diffing one could only
|
|
218
|
-
* plan an update that no write can apply: a plan that reports work on every run, forever.
|
|
219
|
-
*/
|
|
220
|
-
/** The vendor rules these forms are checked against at plan time — resource-spec.ts. */
|
|
221
|
-
endpoint: { create: 'pve:POST /storage', update: 'pve:PUT /storage/{storage}' },
|
|
222
|
-
matches: (attributes, props) =>
|
|
223
|
-
sameList(props.content, attributes.content) &&
|
|
224
|
-
sameList(props.nodes, attributes.nodes) &&
|
|
225
|
-
sameList(props['prune-backups'], attributes['prune-backups']) &&
|
|
226
|
-
same(props.disable, attributes.disable) &&
|
|
227
|
-
same(props.shared, attributes.shared) &&
|
|
228
|
-
same(props.preallocation, attributes.preallocation) &&
|
|
229
|
-
same(props.comment, attributes.comment) &&
|
|
230
|
-
same(props.maxfiles, attributes.maxfiles),
|
|
231
|
-
path: (props) => `storage/${props.storage}`,
|
|
232
|
-
updateForm: mutable,
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* ⛔ EMPTY, AND HERE IT MATTERS MORE THAN ANYWHERE ELSE IN THIS PACKAGE. `GET /storage`
|
|
237
|
-
* returns every definition on the cluster, `local` and `local-lvm`/`local-zfs` included:
|
|
238
|
-
* the installer's own, that every guest's rootfs sits on. Adopting those would put
|
|
239
|
-
* Alchemy one `delete` away from the cluster's disks. Adoption is explicit, always.
|
|
240
|
-
|
|
241
|
-
*
|
|
242
|
-
* ⚠️ THIS REMOVES THE DEFINITION, NOT THE DATA. PVE drops the section from storage.cfg and
|
|
243
|
-
* leaves the volumes on disk untouched — so the loss is not the bytes, it is that guests
|
|
244
|
-
* whose config still names this storage can no longer resolve their own disks.
|
|
245
|
-
|
|
246
|
-
*/
|
|
247
142
|
export const ProxmoxStorageProvider = () =>
|
|
248
|
-
Provider.effect(
|
|
143
|
+
Provider.effect(
|
|
144
|
+
ProxmoxStorage,
|
|
145
|
+
Effect.succeed(
|
|
146
|
+
ProxmoxStorage.Provider.of({
|
|
147
|
+
/**
|
|
148
|
+
* ⛔ EMPTY, AND HERE IT MATTERS MORE THAN ANYWHERE ELSE IN THIS PACKAGE. `GET /storage`
|
|
149
|
+
* returns every definition on the cluster, `local` and `local-lvm`/`local-zfs` included:
|
|
150
|
+
* the installer's own, that every guest's rootfs sits on. Adopting those would put
|
|
151
|
+
* Alchemy one `delete` away from the cluster's disks. Adoption is explicit, always.
|
|
152
|
+
*/
|
|
153
|
+
list: () => Effect.succeed([]),
|
|
154
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — user.ts's/group.ts's own ⚠️, applied here
|
|
155
|
+
// from the start rather than found by a later review: the engine calls this hook from
|
|
156
|
+
// Plan.ts's adoption probe and interrupted-create recovery and Apply.ts's delete
|
|
157
|
+
// recovery (all `output: undefined`, nothing confirmed exists yet — `readStorage`'s fold
|
|
158
|
+
// is still needed) as well as Drift.ts (`output: old.attr`, an ALREADY-CONFIRMED row,
|
|
159
|
+
// `diff`'s own situation — `readStorageOrFail` there instead, or a transient failure
|
|
160
|
+
// reports a silent `{action: 'missing'}` with no error at all).
|
|
161
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
162
|
+
return dropUnreadable(
|
|
163
|
+
yield* output === undefined ? readStorage(olds) : readStorageOrFail(olds),
|
|
164
|
+
);
|
|
165
|
+
}),
|
|
166
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
167
|
+
if (!isResolved(news)) return undefined;
|
|
168
|
+
yield* guardWrite(STORAGE_CREATE, createForm(news), output === undefined);
|
|
169
|
+
yield* guardWrite(STORAGE_UPDATE, updateForm(news), false);
|
|
170
|
+
if (output === undefined) return undefined;
|
|
171
|
+
// ⚠️ `readStorageOrFail`, NOT `readStorage` — see that function's own ⛔ in
|
|
172
|
+
// storage-wire.ts. A genuine TRANSIENT failure here propagates and fails the whole
|
|
173
|
+
// plan loudly instead of folding to "absent" and forcing a false update.
|
|
174
|
+
const live = yield* readStorageOrFail(news);
|
|
175
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
176
|
+
// `update` on a storage that was plainly there — see unreadable-read.ts.
|
|
177
|
+
if (live === UNREADABLE) {
|
|
178
|
+
yield* unreadableWarning('Proxmox.Storage', news.storage);
|
|
179
|
+
return { action: 'noop' } as const;
|
|
180
|
+
}
|
|
181
|
+
if (live === undefined) {
|
|
182
|
+
yield* guardWrite(STORAGE_CREATE, createForm(news), true);
|
|
183
|
+
return { action: 'update' } as const;
|
|
184
|
+
}
|
|
185
|
+
return matches(live, news)
|
|
186
|
+
? ({ action: 'noop' } as const)
|
|
187
|
+
: ({ action: 'update' } as const);
|
|
188
|
+
}),
|
|
189
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
190
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
191
|
+
// write below, so `UNREADABLE` here is a narrow race, not the routine case `diff`
|
|
192
|
+
// handles — group.ts's/user.ts's reconcile have the same note.
|
|
193
|
+
const before = dropUnreadable(yield* readStorage(news));
|
|
194
|
+
yield* guardWrite(STORAGE_CREATE, createForm(news), before === undefined);
|
|
195
|
+
yield* guardWrite(STORAGE_UPDATE, updateForm(news), false);
|
|
196
|
+
if (before === undefined) {
|
|
197
|
+
yield* runPve(
|
|
198
|
+
news.target,
|
|
199
|
+
'provision',
|
|
200
|
+
true,
|
|
201
|
+
storage.createStorage(toDistilledCreate(news)),
|
|
202
|
+
);
|
|
203
|
+
} else if (!matches(before, news)) {
|
|
204
|
+
yield* runPve(
|
|
205
|
+
news.target,
|
|
206
|
+
'provision',
|
|
207
|
+
true,
|
|
208
|
+
storage.putStorage(toDistilledUpdate(news)),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const after = dropUnreadable(yield* readStorage(news));
|
|
212
|
+
if (after === undefined) {
|
|
213
|
+
return yield* Effect.die(
|
|
214
|
+
new Error(
|
|
215
|
+
`storage/${news.storage}: the write returned no error but the storage is still ` +
|
|
216
|
+
'absent. PVE wraps every answer in {"data":...} and can report success on a ' +
|
|
217
|
+
'call that did nothing -- read back rather than trusting the status code.',
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return after;
|
|
222
|
+
}),
|
|
223
|
+
/**
|
|
224
|
+
* ⚠️ THIS REMOVES THE DEFINITION, NOT THE DATA. PVE drops the section from storage.cfg and
|
|
225
|
+
* leaves the volumes on disk untouched — so the loss is not the bytes, it is that guests
|
|
226
|
+
* whose config still names this storage can no longer resolve their own disks.
|
|
227
|
+
*/
|
|
228
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
229
|
+
yield* runPve(
|
|
230
|
+
olds.target,
|
|
231
|
+
'provision',
|
|
232
|
+
true,
|
|
233
|
+
storage.deleteStorage({ storage: olds.storage }),
|
|
234
|
+
);
|
|
235
|
+
}),
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallZone`'s `spec` against a fake UniFi Network API — mirrors `network.test.ts`.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import type * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
8
|
+
import { attributesOf, matches } from './firewall-zone-form.ts';
|
|
9
|
+
import { type FirewallZoneProps, declareFirewallZone, spec } from './firewall-zone.ts';
|
|
10
|
+
import { unifiOperations } from './resource.ts';
|
|
11
|
+
|
|
12
|
+
const ZONE_PATH = '/proxy/network/integration/v1/sites/site-1/firewall/zones/zone-1';
|
|
13
|
+
|
|
14
|
+
const liveZone = (overrides: Partial<firewall.FirewallZone> = {}): firewall.FirewallZone => ({
|
|
15
|
+
id: 'zone-1',
|
|
16
|
+
metadata: { origin: 'USER' },
|
|
17
|
+
name: 'Cameras',
|
|
18
|
+
networkIds: ['net-2', 'net-1'],
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const PROPS: FirewallZoneProps = {
|
|
23
|
+
siteId: 'site-1',
|
|
24
|
+
firewallZoneId: 'zone-1',
|
|
25
|
+
name: 'Cameras',
|
|
26
|
+
networkIds: ['net-1', 'net-2'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('Unifi.FirewallZone spec.fetchLive', () => {
|
|
30
|
+
test('a real GET response decodes, and networkIds order does not affect matches', async () => {
|
|
31
|
+
const fake = fakeUnifi((method, url) =>
|
|
32
|
+
method === 'GET' && url.pathname === ZONE_PATH
|
|
33
|
+
? Response.json(liveZone())
|
|
34
|
+
: fakeFailure(400, 'unexpected request'),
|
|
35
|
+
);
|
|
36
|
+
const live = await Effect.runPromise(
|
|
37
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
38
|
+
);
|
|
39
|
+
expect(live?.id).toBe('zone-1');
|
|
40
|
+
const attrs = attributesOf(live as firewall.FirewallZone, PROPS);
|
|
41
|
+
expect(matches(attrs, PROPS)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a genuine 404 folds to undefined', async () => {
|
|
45
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
46
|
+
const live = await Effect.runPromise(
|
|
47
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
48
|
+
);
|
|
49
|
+
expect(live).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('declareFirewallZone -- the declaration renderer', () => {
|
|
54
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
55
|
+
const live = liveZone({ networkIds: ['net-9', 'net-3', 'net-3'] });
|
|
56
|
+
const declared = declareFirewallZone(live, 'site-1');
|
|
57
|
+
const attrs = attributesOf(live, declared);
|
|
58
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('Unifi.FirewallZone write paths never reach the vendor API', () => {
|
|
63
|
+
test('reconcile on an exact-match adoption (H6) sends only the one read', async () => {
|
|
64
|
+
const fake = fakeUnifi((method, url) =>
|
|
65
|
+
method === 'GET' && url.pathname === ZONE_PATH
|
|
66
|
+
? Response.json(liveZone())
|
|
67
|
+
: fakeFailure(400, 'unexpected request'),
|
|
68
|
+
);
|
|
69
|
+
const exit = await Effect.runPromiseExit(
|
|
70
|
+
unifiOperations(spec)
|
|
71
|
+
.reconcile(PROPS)
|
|
72
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
73
|
+
);
|
|
74
|
+
expect(exit._tag).toBe('Success');
|
|
75
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
79
|
+
const fake = fakeUnifi(() => fakeFailure(400, 'unexpected request'));
|
|
80
|
+
const exit = await Effect.runPromiseExit(
|
|
81
|
+
unifiOperations(spec)
|
|
82
|
+
.destroy(PROPS)
|
|
83
|
+
.pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
84
|
+
);
|
|
85
|
+
expect(exit._tag).toBe('Failure');
|
|
86
|
+
expect(fake.seen).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.FirewallZone` — one firewall zone on a UniFi Network site, READ-ONLY (`policy.ts`).
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY FIREWALL ZONE IS IN THE FIRST IMPORT SET AND `Unifi.FirewallPolicy` IS NOT.
|
|
5
|
+
* `getFirewallZone`/`getFirewallZones` (`firewall.ts`) is a list+get pair over genuine,
|
|
6
|
+
* simply-shaped configuration: `{id, metadata, name, networkIds}`, four fields, no nested
|
|
7
|
+
* discriminated filter. `FirewallPolicy` (same file) is also list+get and also configuration,
|
|
8
|
+
* but its `source`/`destination`/`ipProtocolScope` fields carry several of `docs/unifi-api-notes.md`'s
|
|
9
|
+
* "converter-flattened discriminator variants" each, and `firewall/policies/ordering` replaces
|
|
10
|
+
* the whole ordered list — modelling those correctly is a bigger, separate PR (`network.ts`'s
|
|
11
|
+
* header note names it explicitly) rather than something to rush alongside this one.
|
|
12
|
+
*/
|
|
13
|
+
import { Resource } from 'alchemy';
|
|
14
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
15
|
+
import * as Provider from 'alchemy/Provider';
|
|
16
|
+
import * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import {
|
|
19
|
+
type FirewallZoneAttributes,
|
|
20
|
+
type FirewallZoneProps,
|
|
21
|
+
attributesOf,
|
|
22
|
+
matches,
|
|
23
|
+
} from './firewall-zone-form.ts';
|
|
24
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
25
|
+
import { unifiHandlers } from './resource.ts';
|
|
26
|
+
|
|
27
|
+
export type { FirewallZoneAttributes, FirewallZoneProps } from './firewall-zone-form.ts';
|
|
28
|
+
export { declareFirewallZone } from './firewall-zone-form.ts';
|
|
29
|
+
|
|
30
|
+
export interface UnifiFirewallZone extends Resource<
|
|
31
|
+
'Unifi.FirewallZone',
|
|
32
|
+
FirewallZoneProps,
|
|
33
|
+
FirewallZoneAttributes,
|
|
34
|
+
never,
|
|
35
|
+
UnifiRequirements
|
|
36
|
+
> {}
|
|
37
|
+
|
|
38
|
+
export const UnifiFirewallZone = Resource<UnifiFirewallZone>('Unifi.FirewallZone', {
|
|
39
|
+
defaultRemovalPolicy: 'retain',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** `firewallZone('lan', props)` — `adopt(true)` piped on by default (H5); see `network.ts`. */
|
|
43
|
+
export const firewallZone = (id: string, props: FirewallZoneProps) =>
|
|
44
|
+
UnifiFirewallZone(id, props).pipe(adopt(true));
|
|
45
|
+
|
|
46
|
+
/** ★ EXPORTED for direct testing against a fake `Credentials` layer — see `network.ts`'s own. */
|
|
47
|
+
export const spec: UnifiSpec<
|
|
48
|
+
FirewallZoneProps,
|
|
49
|
+
firewall.FirewallZone,
|
|
50
|
+
FirewallZoneAttributes,
|
|
51
|
+
firewall.GetFirewallZoneError
|
|
52
|
+
> = {
|
|
53
|
+
type: 'Unifi.FirewallZone',
|
|
54
|
+
describe: (props) => `sites/${props.siteId}/firewall/zones/${props.firewallZoneId}`,
|
|
55
|
+
fetchLive: (props) =>
|
|
56
|
+
firewall
|
|
57
|
+
.getFirewallZone({ siteId: props.siteId, firewallZoneId: props.firewallZoneId })
|
|
58
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
59
|
+
attributes: attributesOf,
|
|
60
|
+
matches,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const handlers = unifiHandlers(spec);
|
|
64
|
+
|
|
65
|
+
export const UnifiFirewallZoneProvider = () =>
|
|
66
|
+
Provider.effect(UnifiFirewallZone, Effect.succeed(UnifiFirewallZone.Provider.of(handlers)));
|