@homeflare/alchemy 0.31.1 → 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 +44 -7
- package/dist/grafana/index.d.ts +6 -5
- package/dist/grafana/index.d.ts.map +1 -1
- 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/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/group-wire.d.ts +44 -5
- package/dist/proxmox/group-wire.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +326 -264
- package/dist/proxmox/index.js.map +13 -11
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +54 -0
- package/dist/proxmox/storage-form.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +56 -0
- package/dist/proxmox/storage-wire.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +55 -60
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user-wire.d.ts +34 -5
- package/dist/proxmox/user-wire.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +6 -6
- package/dist/proxmox/user.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/dist/verify/fake-engine.d.ts +14 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/grafana.md +47 -17
- package/docs/opnsense.md +146 -0
- package/docs/provider-standard.md +1 -1
- package/docs/quadlet-container.md +132 -0
- package/docs/unifi.md +93 -0
- package/docs/upstream-conformance.md +14 -4
- package/package.json +13 -3
- package/src/grafana/index.ts +6 -5
- 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/acl.ts +10 -4
- package/src/proxmox/api-token.ts +20 -12
- package/src/proxmox/group-wire.ts +62 -8
- package/src/proxmox/group.ts +23 -35
- package/src/proxmox/read-failure-propagation.test.ts +183 -0
- package/src/proxmox/role.ts +9 -2
- package/src/proxmox/storage-form.ts +144 -0
- package/src/proxmox/storage-read-failure.test.ts +214 -0
- package/src/proxmox/storage-wire.ts +124 -0
- package/src/proxmox/storage.ts +165 -175
- package/src/proxmox/user-wire.ts +44 -8
- package/src/proxmox/user.ts +29 -27
- 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/verify/fake-engine.ts +23 -0
package/src/proxmox/user.ts
CHANGED
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
* user, so a role wide enough to edit the account can still be refused for one group.
|
|
34
34
|
* · `Sys.Audit` (or `User.Modify`) for the read, which the `read` role's auditor already has.
|
|
35
35
|
*
|
|
36
|
-
* ⚠️ A MISSING USER IS A 500, NOT A 404 —
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
36
|
+
* ⚠️ A MISSING USER IS A 500, NOT A 404 — MEASURED against the live cluster, "no such user
|
|
37
|
+
* ('x@pve')". distilled turns that into an `InternalServerError`, with no distinct "not found"
|
|
38
|
+
* tag to catch. `user-wire.ts`'s `readUser` (used by `read`/`reconcile`, where the create
|
|
39
|
+
* workflow needs "absent" from exactly this failure) still folds it; `readUserOrFail` (used by
|
|
40
|
+
* `diff` below) does not — see that function's own header for why folding it there was the
|
|
41
|
+
* cries-wolf bug all over again, just for a genuine PVE quirk instead of a permission denial.
|
|
42
42
|
*
|
|
43
43
|
* ⚠️ THE WIRE URL FOR THIS FAMILY CHANGED, THOUGH NOTHING A CALLER SEES DID. MEASURED
|
|
44
44
|
* (user.test.ts): distilled's `{userid}` label substitution percent-encodes it —
|
|
@@ -60,18 +60,15 @@ import * as Effect from 'effect/Effect';
|
|
|
60
60
|
import { guardWrite } from './distilled-guard.ts';
|
|
61
61
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
62
62
|
import { runPve } from './distilled-pve.ts';
|
|
63
|
-
import {
|
|
64
|
-
UNREADABLE,
|
|
65
|
-
type Unreadable,
|
|
66
|
-
readOrUnreadable,
|
|
67
|
-
unreadableWarning,
|
|
68
|
-
} from './unreadable-read.ts';
|
|
63
|
+
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
69
64
|
import {
|
|
70
65
|
USER_CREATE,
|
|
71
66
|
USER_UPDATE,
|
|
72
|
-
attributesOf,
|
|
73
67
|
createForm,
|
|
68
|
+
dropUnreadable,
|
|
74
69
|
matches,
|
|
70
|
+
readUser,
|
|
71
|
+
readUserOrFail,
|
|
75
72
|
updateForm,
|
|
76
73
|
} from './user-wire.ts';
|
|
77
74
|
|
|
@@ -126,17 +123,6 @@ export interface ProxmoxUser extends Resource<
|
|
|
126
123
|
|
|
127
124
|
export const ProxmoxUser = Resource<ProxmoxUser>('Proxmox.User');
|
|
128
125
|
|
|
129
|
-
/** ★ Default `read` role for this family — only the write side needs `provision`. */
|
|
130
|
-
const readUser = (props: UserProps) =>
|
|
131
|
-
readOrUnreadable(runPve(props.target, 'read', false, access.getAccessUser(props))).pipe(
|
|
132
|
-
Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
|
|
133
|
-
Effect.orElseSucceed(() => undefined),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
137
|
-
const dropUnreadable = (live: UserAttributes | Unreadable | undefined) =>
|
|
138
|
-
live === UNREADABLE ? undefined : live;
|
|
139
|
-
|
|
140
126
|
export const ProxmoxUserProvider = () =>
|
|
141
127
|
Provider.effect(
|
|
142
128
|
ProxmoxUser,
|
|
@@ -148,15 +134,31 @@ export const ProxmoxUserProvider = () =>
|
|
|
148
134
|
* identity someone made years ago. Adoption is an explicit act.
|
|
149
135
|
*/
|
|
150
136
|
list: () => Effect.succeed([]),
|
|
151
|
-
|
|
152
|
-
|
|
137
|
+
// ⚠️ FOLDS ONLY WHEN `output` IS `undefined` — MEASURED 2026-09-24, an adversarial review
|
|
138
|
+
// of this very fix caught the gap. The engine calls this ONE hook from FOUR places
|
|
139
|
+
// (alchemy/src/{Plan,Apply,Drift}.ts), and only `output` tells them apart: Plan.ts's
|
|
140
|
+
// cold-start adoption probe and interrupted-create recovery, and Apply.ts's
|
|
141
|
+
// delete-recovery, all pass `output: undefined` — nothing is confirmed to exist yet, so
|
|
142
|
+
// `readUser`'s fold is still needed there or a brand-new account could never be adopted
|
|
143
|
+
// or created. `Drift.ts` (`alchemy drift`/`sync`/`deploy --detect-drift`) is different:
|
|
144
|
+
// it calls this on an ALREADY-CONFIRMED row (`output: old.attr`, defined), exactly
|
|
145
|
+
// `diff`'s own situation below — a transient failure folded to `undefined` there is
|
|
146
|
+
// reported as `{action: 'missing'}` with NO error at all, the cries-wolf bug again, one
|
|
147
|
+
// command over. `readUserOrFail` on that branch propagates it instead.
|
|
148
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
149
|
+
return dropUnreadable(
|
|
150
|
+
yield* output === undefined ? readUser(olds) : readUserOrFail(olds),
|
|
151
|
+
);
|
|
153
152
|
}),
|
|
154
153
|
diff: Effect.fn(function* ({ news, output }) {
|
|
155
154
|
if (!isResolved(news)) return undefined;
|
|
156
155
|
yield* guardWrite(USER_CREATE, createForm(news), output === undefined);
|
|
157
156
|
yield* guardWrite(USER_UPDATE, updateForm(news), false);
|
|
158
157
|
if (output === undefined) return undefined;
|
|
159
|
-
|
|
158
|
+
// ⚠️ `readUserOrFail`, NOT `readUser` — see that function's own header (user-wire.ts).
|
|
159
|
+
// A genuine failure here propagates and fails the whole plan loudly instead of
|
|
160
|
+
// folding to "absent" and forcing a false update, the cries-wolf class of bug.
|
|
161
|
+
const live = yield* readUserOrFail(news);
|
|
160
162
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
161
163
|
// `update` on an account that was plainly there — see unreadable-read.ts.
|
|
162
164
|
if (live === UNREADABLE) {
|
|
@@ -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)));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UniFi Network providers for Alchemy — READ-ONLY, by Tim's rule (2026-09-24; `policy.ts`).
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THIS BARREL IS THE PUBLIC API, SMALLER THAN THE DIRECTORY. `resource.ts`'s generic engine
|
|
5
|
+
* and each `*-form.ts`'s wire helpers are internals a provider needs but a consumer should
|
|
6
|
+
* not depend on — mirrors `../netbox/index.ts`.
|
|
7
|
+
*/
|
|
8
|
+
export { UNIFI_READ_ONLY_POLICY, UnifiWriteRefused, type UnifiWriteAction } from './policy.ts';
|
|
9
|
+
export type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
10
|
+
export {
|
|
11
|
+
UnifiNetwork,
|
|
12
|
+
UnifiNetworkProvider,
|
|
13
|
+
declareNetwork,
|
|
14
|
+
network,
|
|
15
|
+
type NetworkAttributes,
|
|
16
|
+
type NetworkProps,
|
|
17
|
+
} from './network.ts';
|
|
18
|
+
export {
|
|
19
|
+
UnifiFirewallZone,
|
|
20
|
+
UnifiFirewallZoneProvider,
|
|
21
|
+
declareFirewallZone,
|
|
22
|
+
firewallZone,
|
|
23
|
+
type FirewallZoneAttributes,
|
|
24
|
+
type FirewallZoneProps,
|
|
25
|
+
} from './firewall-zone.ts';
|
|
26
|
+
export { providers } from './providers.ts';
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s wire shape: the declarable subset of `NetworkDetails`, and how a live read
|
|
3
|
+
* becomes both plan attributes and a rendered declaration.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ PROPS MIRROR `UpdateNetworkRequest`, NOT `NetworkDetails`. `default` and `metadata` are
|
|
6
|
+
* server-derived — no writable endpoint in `networks.ts` accepts either — so they are
|
|
7
|
+
* attributes-only, reported but never compared or declared. Everything else in `NetworkProps`
|
|
8
|
+
* is exactly what `updateNetwork`/`createNetwork` would accept, even though this family never
|
|
9
|
+
* calls either: the shape is what a future write path would need, and what today's read-only
|
|
10
|
+
* `matches` compares against.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ `ipv4Configuration` STAYS `unknown`, ON THE SDK'S OWN AUTHORITY. `networks.ts`'s own comment
|
|
13
|
+
* ("Shape varies by variant — widened by scripts/convert.ts; see README") says the OpenAPI
|
|
14
|
+
* discriminator this field varies over was flattened at generation time, not decoded per
|
|
15
|
+
* variant. Comparing it as an opaque JSON value (below) is honest about that; guessing a
|
|
16
|
+
* narrower shape here would silently stop matching the day a new variant's key order differs.
|
|
17
|
+
* ⚠️ It may ALSO hide an unordered array the way `dhcpGuarding` and `ipv6Configuration` do
|
|
18
|
+
* below — this family just can't know, because the shape is never decoded. If a future variant
|
|
19
|
+
* plans spurious `update`s, suspect this field first and widen it into a typed, order-normalized
|
|
20
|
+
* shape rather than adding another opaque comparison.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ THREE FIELDS ARE SETS, NOT SEQUENCES: `dhcpGuarding.trustedDhcpServerIpAddresses`,
|
|
23
|
+
* `ipv6Configuration.additionalHostIpSubnets`, and `ipv6Configuration.dnsServerIpAddressesOverride`.
|
|
24
|
+
* The vendor document gives no indication any of the three is ordered — same reasoning
|
|
25
|
+
* `firewall-zone-form.ts` gives for `networkIds`, and `proxmox/user-wire.ts`'s `groupSet` gives
|
|
26
|
+
* for PVE's `groups` — but `matches` compares via alchemy's `deepEqual`, which sorts object KEYS
|
|
27
|
+
* but NOT ARRAY ELEMENTS (`node_modules/alchemy/lib/Diff.js`). Left alone, a console that
|
|
28
|
+
* returns the same set in a different order between calls plans a spurious `update`, and the
|
|
29
|
+
* read-only reconcile then refuses every deploy. `sortedSet` (dedupe + sort) neutralizes that
|
|
30
|
+
* before any of the three is attributed, declared, or compared. Every OTHER array field in this
|
|
31
|
+
* family (there are none today besides these three) would stay declaration-order as written.
|
|
32
|
+
*/
|
|
33
|
+
import { deepEqual } from 'alchemy/Diff';
|
|
34
|
+
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
35
|
+
|
|
36
|
+
const sortedSet = (values: readonly string[]): string[] => [...new Set(values)].sort();
|
|
37
|
+
|
|
38
|
+
// ⚠️ `value == null` (NOT `=== undefined`) ON PURPOSE, IN BOTH NORMALIZERS BELOW. The SDK types
|
|
39
|
+
// `dhcpGuarding`/`ipv6Configuration` `T | undefined`, but `matches`'s own header says UniFi's
|
|
40
|
+
// JSON answers `null` for an unset optional field — a reality `deepEqual`'s `stripNullish`
|
|
41
|
+
// already treats as equivalent to `undefined` (`value == null` in alchemy's `Diff.js`), which
|
|
42
|
+
// this family's props/attributes types don't model at the TS level. Destructuring a `null` with
|
|
43
|
+
// `=== undefined`'s false branch (`{ ...value, field: value.field }`) would throw
|
|
44
|
+
// `TypeError: Cannot read properties of null` the first time a console omits either field —
|
|
45
|
+
// `== null` catches both and returns the value through untouched, matching what the rest of
|
|
46
|
+
// this file already does for every other optional field.
|
|
47
|
+
const normalizeDhcpGuarding = (
|
|
48
|
+
value: networks.NetworkDHCPGuarding | undefined,
|
|
49
|
+
): networks.NetworkDHCPGuarding | undefined =>
|
|
50
|
+
value == null
|
|
51
|
+
? value
|
|
52
|
+
: { ...value, trustedDhcpServerIpAddresses: sortedSet(value.trustedDhcpServerIpAddresses) };
|
|
53
|
+
|
|
54
|
+
// ⚠️ `exactOptionalPropertyTypes` (see the props header) means these two subfields must be
|
|
55
|
+
// OMITTED, not set to an explicit `undefined`, when the live/declared value has no override —
|
|
56
|
+
// `NetworkIPv6Configuration` types them `field?: T`, not `field?: T | undefined`. Conditional
|
|
57
|
+
// spread (rather than `field: value.field === undefined ? undefined : sortedSet(...)`) is what
|
|
58
|
+
// keeps an absent key absent.
|
|
59
|
+
const normalizeIpv6Configuration = (
|
|
60
|
+
value: networks.NetworkIPv6Configuration | undefined,
|
|
61
|
+
): networks.NetworkIPv6Configuration | undefined =>
|
|
62
|
+
value == null
|
|
63
|
+
? value
|
|
64
|
+
: {
|
|
65
|
+
...value,
|
|
66
|
+
...(value.additionalHostIpSubnets !== undefined
|
|
67
|
+
? { additionalHostIpSubnets: sortedSet(value.additionalHostIpSubnets) }
|
|
68
|
+
: {}),
|
|
69
|
+
...(value.dnsServerIpAddressesOverride !== undefined
|
|
70
|
+
? { dnsServerIpAddressesOverride: sortedSet(value.dnsServerIpAddressesOverride) }
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` ON PURPOSE. `tsconfig.json` sets
|
|
76
|
+
* `exactOptionalPropertyTypes`, which treats `field?: T` (absent-or-T) and
|
|
77
|
+
* `field?: T | undefined` (absent-or-explicitly-undefined) as different types. `declareNetwork`
|
|
78
|
+
* below assigns `live.dhcpGuarding` (typed `T | undefined`) straight through — the whole point
|
|
79
|
+
* of a renderer that echoes a live optional field structurally rather than defaulting it — so
|
|
80
|
+
* the target has to admit an explicit `undefined`, not just omission.
|
|
81
|
+
*/
|
|
82
|
+
export interface NetworkProps {
|
|
83
|
+
siteId: string;
|
|
84
|
+
networkId: string;
|
|
85
|
+
name: string;
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
management: string;
|
|
88
|
+
/** VLAN ID. Must be 1 for the default network and >= 2 for additional networks. */
|
|
89
|
+
vlanId: number;
|
|
90
|
+
dhcpGuarding?: networks.NetworkDHCPGuarding | undefined;
|
|
91
|
+
cellularBackupEnabled?: boolean | undefined;
|
|
92
|
+
internetAccessEnabled?: boolean | undefined;
|
|
93
|
+
/** Opaque — see the header. */
|
|
94
|
+
ipv4Configuration?: unknown;
|
|
95
|
+
ipv6Configuration?: networks.NetworkIPv6Configuration | undefined;
|
|
96
|
+
isolationEnabled?: boolean | undefined;
|
|
97
|
+
mdnsForwardingEnabled?: boolean | undefined;
|
|
98
|
+
zoneId?: string | undefined;
|
|
99
|
+
deviceId?: string | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface NetworkAttributes {
|
|
103
|
+
siteId: string;
|
|
104
|
+
networkId: string;
|
|
105
|
+
/** Whether this is the site's undeletable default network — server-derived, never declared. */
|
|
106
|
+
default: boolean;
|
|
107
|
+
name: string;
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
management: string;
|
|
110
|
+
vlanId: number;
|
|
111
|
+
dhcpGuarding: networks.NetworkDHCPGuarding | undefined;
|
|
112
|
+
cellularBackupEnabled: boolean | undefined;
|
|
113
|
+
internetAccessEnabled: boolean | undefined;
|
|
114
|
+
ipv4Configuration: unknown;
|
|
115
|
+
ipv6Configuration: networks.NetworkIPv6Configuration | undefined;
|
|
116
|
+
isolationEnabled: boolean | undefined;
|
|
117
|
+
mdnsForwardingEnabled: boolean | undefined;
|
|
118
|
+
zoneId: string | undefined;
|
|
119
|
+
deviceId: string | undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const attributesOf = (
|
|
123
|
+
live: networks.NetworkDetails,
|
|
124
|
+
props: NetworkProps,
|
|
125
|
+
): NetworkAttributes => ({
|
|
126
|
+
siteId: props.siteId,
|
|
127
|
+
networkId: live.id,
|
|
128
|
+
default: live.default,
|
|
129
|
+
name: live.name,
|
|
130
|
+
enabled: live.enabled,
|
|
131
|
+
management: live.management,
|
|
132
|
+
vlanId: live.vlanId,
|
|
133
|
+
dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),
|
|
134
|
+
cellularBackupEnabled: live.cellularBackupEnabled,
|
|
135
|
+
internetAccessEnabled: live.internetAccessEnabled,
|
|
136
|
+
ipv4Configuration: live.ipv4Configuration,
|
|
137
|
+
ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),
|
|
138
|
+
isolationEnabled: live.isolationEnabled,
|
|
139
|
+
mdnsForwardingEnabled: live.mdnsForwardingEnabled,
|
|
140
|
+
zoneId: live.zoneId,
|
|
141
|
+
deviceId: live.deviceId,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* ⚠️ `stripNullish: true` ON EVERY COMPARISON. UniFi's JSON answers `null` for an unset optional
|
|
146
|
+
* field; a declaration that omits the same prop carries `undefined`. Without this option every
|
|
147
|
+
* plan against an object with one unset optional field would report `update` forever — the
|
|
148
|
+
* exact "read and write are different shapes" trap `netbox/values.ts` and `proxmox/user-wire.ts`
|
|
149
|
+
* both work around, here solved once with the engine's own `deepEqual` instead of a per-field
|
|
150
|
+
* coercion, because nothing here needs PVE's or NetBox's extra wire-format translation.
|
|
151
|
+
*/
|
|
152
|
+
export const matches = (attributes: NetworkAttributes, props: NetworkProps): boolean =>
|
|
153
|
+
attributes.name === props.name &&
|
|
154
|
+
attributes.enabled === props.enabled &&
|
|
155
|
+
attributes.management === props.management &&
|
|
156
|
+
attributes.vlanId === props.vlanId &&
|
|
157
|
+
deepEqual(
|
|
158
|
+
normalizeDhcpGuarding(attributes.dhcpGuarding),
|
|
159
|
+
normalizeDhcpGuarding(props.dhcpGuarding),
|
|
160
|
+
{
|
|
161
|
+
stripNullish: true,
|
|
162
|
+
},
|
|
163
|
+
) &&
|
|
164
|
+
deepEqual(attributes.cellularBackupEnabled, props.cellularBackupEnabled, {
|
|
165
|
+
stripNullish: true,
|
|
166
|
+
}) &&
|
|
167
|
+
deepEqual(attributes.internetAccessEnabled, props.internetAccessEnabled, {
|
|
168
|
+
stripNullish: true,
|
|
169
|
+
}) &&
|
|
170
|
+
deepEqual(attributes.ipv4Configuration, props.ipv4Configuration, { stripNullish: true }) &&
|
|
171
|
+
deepEqual(
|
|
172
|
+
normalizeIpv6Configuration(attributes.ipv6Configuration),
|
|
173
|
+
normalizeIpv6Configuration(props.ipv6Configuration),
|
|
174
|
+
{ stripNullish: true },
|
|
175
|
+
) &&
|
|
176
|
+
deepEqual(attributes.isolationEnabled, props.isolationEnabled, { stripNullish: true }) &&
|
|
177
|
+
deepEqual(attributes.mdnsForwardingEnabled, props.mdnsForwardingEnabled, {
|
|
178
|
+
stripNullish: true,
|
|
179
|
+
}) &&
|
|
180
|
+
deepEqual(attributes.zoneId, props.zoneId, { stripNullish: true }) &&
|
|
181
|
+
deepEqual(attributes.deviceId, props.deviceId, { stripNullish: true });
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The declaration renderer (task spec: "given one live object, return the props a declaration
|
|
185
|
+
* needs so its plan is noop"). A later import script feeds `getNetworkDetails`'s own response
|
|
186
|
+
* straight in and writes the result into `alchemy.run.ts` as `network('<id>', declareNetwork(live,
|
|
187
|
+
* siteId))` — no field is invented or defaulted here beyond what `attributesOf` already reports,
|
|
188
|
+
* so `matches(attributesOf(live, props), declareNetwork(live, siteId))` is `true` by construction
|
|
189
|
+
* (`network.test.ts` proves it) for any live object this SDK can decode.
|
|
190
|
+
*/
|
|
191
|
+
export const declareNetwork = (live: networks.NetworkDetails, siteId: string): NetworkProps => ({
|
|
192
|
+
siteId,
|
|
193
|
+
networkId: live.id,
|
|
194
|
+
name: live.name,
|
|
195
|
+
enabled: live.enabled,
|
|
196
|
+
management: live.management,
|
|
197
|
+
vlanId: live.vlanId,
|
|
198
|
+
dhcpGuarding: normalizeDhcpGuarding(live.dhcpGuarding),
|
|
199
|
+
cellularBackupEnabled: live.cellularBackupEnabled,
|
|
200
|
+
internetAccessEnabled: live.internetAccessEnabled,
|
|
201
|
+
ipv4Configuration: live.ipv4Configuration,
|
|
202
|
+
ipv6Configuration: normalizeIpv6Configuration(live.ipv6Configuration),
|
|
203
|
+
isolationEnabled: live.isolationEnabled,
|
|
204
|
+
mdnsForwardingEnabled: live.mdnsForwardingEnabled,
|
|
205
|
+
zoneId: live.zoneId,
|
|
206
|
+
deviceId: live.deviceId,
|
|
207
|
+
});
|