@homeflare/alchemy 0.57.0 → 0.59.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 +17 -0
- package/dist/caddy/config.d.ts +6 -0
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/index.js +128 -61
- package/dist/caddy/index.js.map +6 -4
- package/dist/scope/duplicate-id.d.ts +32 -0
- package/dist/scope/duplicate-id.d.ts.map +1 -0
- package/dist/scope/guard.d.ts +28 -0
- package/dist/scope/guard.d.ts.map +1 -0
- package/dist/scope/index.d.ts +21 -0
- package/dist/scope/index.d.ts.map +1 -0
- package/dist/scope/index.js +71 -0
- package/dist/scope/index.js.map +11 -0
- package/dist/unifi/firewall-policy-drift.d.ts +3 -3
- package/dist/unifi/firewall-policy-drift.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-form.d.ts +18 -15
- package/dist/unifi/firewall-policy-form.d.ts.map +1 -1
- package/dist/unifi/index.js +64 -8
- package/dist/unifi/index.js.map +5 -5
- package/dist/unifi/network-update.fixtures.d.ts +53 -0
- package/dist/unifi/network-update.fixtures.d.ts.map +1 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -1
- package/docs/duplicate-resource-id.md +91 -0
- package/docs/provider-standard.md +9 -0
- package/package.json +5 -1
- package/src/caddy/config.ts +12 -3
- package/src/scope/duplicate-id.test.ts +95 -0
- package/src/scope/duplicate-id.ts +113 -0
- package/src/scope/guard.test.ts +128 -0
- package/src/scope/guard.ts +49 -0
- package/src/scope/index.ts +20 -0
- package/src/unifi/firewall-policy-drift.test.ts +64 -0
- package/src/unifi/firewall-policy-drift.ts +24 -7
- package/src/unifi/firewall-policy-form.ts +61 -15
- package/src/unifi/network-update-accept.test.ts +59 -0
- package/src/unifi/network-update.fixtures.ts +74 -0
- package/src/unifi/network-update.test.ts +11 -69
- package/src/unifi/update-reconcile.ts +37 -3
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/scope/duplicate-id.ts
|
|
2
|
+
import { toFqn } from "alchemy/FQN";
|
|
3
|
+
import { CurrentNamespace } from "alchemy/Namespace";
|
|
4
|
+
import { Stack } from "alchemy/Stack";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
|
|
8
|
+
class DuplicateResourceIdError extends Data.TaggedError("DuplicateResourceIdError") {
|
|
9
|
+
}
|
|
10
|
+
var scopeLabel = (namespace) => {
|
|
11
|
+
if (!namespace)
|
|
12
|
+
return "(root)";
|
|
13
|
+
const path = [];
|
|
14
|
+
let node = namespace;
|
|
15
|
+
while (node) {
|
|
16
|
+
path.unshift(node.Id);
|
|
17
|
+
node = node.Parent;
|
|
18
|
+
}
|
|
19
|
+
return path.join("/");
|
|
20
|
+
};
|
|
21
|
+
var registries = new WeakMap;
|
|
22
|
+
var registryFor = (stack) => {
|
|
23
|
+
const existing = registries.get(stack);
|
|
24
|
+
if (existing)
|
|
25
|
+
return existing;
|
|
26
|
+
const created = new Map;
|
|
27
|
+
registries.set(stack, created);
|
|
28
|
+
return created;
|
|
29
|
+
};
|
|
30
|
+
var claimResourceId = (type, id) => Effect.gen(function* () {
|
|
31
|
+
const stack = yield* Stack;
|
|
32
|
+
const namespace = yield* CurrentNamespace;
|
|
33
|
+
const fqn = toFqn(namespace, id);
|
|
34
|
+
const registry = registryFor(stack);
|
|
35
|
+
const firstType = registry.get(fqn);
|
|
36
|
+
if (firstType === undefined) {
|
|
37
|
+
registry.set(fqn, type);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (firstType !== type) {
|
|
41
|
+
return yield* Effect.die(new DuplicateResourceIdError({
|
|
42
|
+
message: `REFUSING: duplicate resource id "${fqn}" in scope ${scopeLabel(namespace)}: ` + `first declared by ${firstType}, again by ${type}`,
|
|
43
|
+
fqn,
|
|
44
|
+
scope: scopeLabel(namespace),
|
|
45
|
+
firstType,
|
|
46
|
+
secondType: type
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
// src/scope/guard.ts
|
|
51
|
+
import * as Effect2 from "effect/Effect";
|
|
52
|
+
var guardDuplicateIds = (resourceClass) => new Proxy(resourceClass, {
|
|
53
|
+
apply(target, thisArg, args) {
|
|
54
|
+
const id = args[0];
|
|
55
|
+
if (typeof id !== "string") {
|
|
56
|
+
return Reflect.apply(target, thisArg, args);
|
|
57
|
+
}
|
|
58
|
+
return Effect2.gen(function* () {
|
|
59
|
+
yield* claimResourceId(resourceClass.Type, id);
|
|
60
|
+
return yield* Reflect.apply(target, thisArg, args);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
export {
|
|
65
|
+
DuplicateResourceIdError,
|
|
66
|
+
claimResourceId,
|
|
67
|
+
guardDuplicateIds
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
//# debugId=1AB58BD65E38742164756E2164756E21
|
|
71
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/scope/duplicate-id.ts", "../src/scope/guard.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Refuse a second resource claiming a logical id already taken in the same stack scope.\n *\n * ★ WHY THIS EXISTS (measured, homeflare-mini PR 329, Tim 2026-10-19). `NixJobRetirement('caddy',\n * …)` and `Caddy.Config` (`CADDY_ID = 'caddy'`) both declared logical id `caddy` in one stack.\n * Alchemy's own `Resource()` constructor treats a second `yield*` at an already-claimed FQN as\n * an idempotent re-reference — correct for \"the same resource, read from two call sites\" — and\n * returns the FIRST-registered resource without ever comparing `Type` (`Resource.ts`, the\n * `if (existing) { … return existing; }` branch; a sibling TODO there already flags a related\n * gap — \"check if props are different and die\"). The SECOND resource's props are never written\n * anywhere: `stack.resources[fqn]` never changes, so nothing downstream — not `Plan.make`, not a\n * later inspection of the compiled stack — can tell two different registrations ever happened.\n * Measured directly (dup-experiment.ts, this PR): registering `Test.ResourceB('shared-id', …)`\n * after `Test.ResourceA('shared-id', …)` compiles a stack whose ONLY entry at that fqn reports\n * `Type: 'Test.ResourceA'` — the second declaration leaves no trace to check after the fact.\n * ★ WHY THE CHECK CANNOT BE BOLTED ON AFTER THE FACT. Because the losing registration leaves no\n * trace (above), a guard built from `CompiledStack.resources` — inspected after the stack's body\n * has run, e.g. before `Plan.make` — sees only one entry per fqn and cannot know a second,\n * different-typed claim ever happened. The only point where both the existing claim AND the new\n * claimant's `Type` are both in hand is at the moment of registration, which is why this module\n * is a REGISTRY populated and checked as each resource is `yield*`ed (see `guard.ts`), not a\n * post-hoc scan.\n * ⛔ UPSTREAM IS THE RIGHT HOME FOR THE REAL FIX. The comparison belongs in `Resource.ts` itself,\n * right where its own TODO already sits — every resource (ours and every vendor built-in) goes\n * through that one function, and this kit cannot wrap a type it does not author (e.g.\n * `Cloudflare.Worker`). This module is the house-side stopgap decision 49 allows (\"a house rule\n * only fills a gap upstream is silent on\"): it protects any TWO resources that both go through\n * `guardDuplicateIds` (guard.ts), which is everywhere this kit's own providers adopt it. The\n * upstream issue text proposing the real fix is in this PR's body.\n */\nimport { toFqn } from 'alchemy/FQN';\nimport { CurrentNamespace, type NamespaceNode } from 'alchemy/Namespace';\nimport { Stack } from 'alchemy/Stack';\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\n\n/**\n * Two resources of different types both claimed `fqn` in `scope`. Carries both type names so the\n * message — and any catcher — can tell the attacker from the defender without re-deriving them.\n */\nexport class DuplicateResourceIdError extends Data.TaggedError('DuplicateResourceIdError')<{\n message: string;\n fqn: string;\n scope: string;\n firstType: string;\n secondType: string;\n}> {}\n\nconst scopeLabel = (namespace: NamespaceNode | undefined): string => {\n if (!namespace) return '(root)';\n const path: string[] = [];\n let node: NamespaceNode | undefined = namespace;\n while (node) {\n path.unshift(node.Id);\n node = node.Parent;\n }\n return path.join('/');\n};\n\n/**\n * One registry per compiled stack, keyed by the `Stack` service object `yield* Stack` returns\n * (a fresh `StackSpec` per `Alchemy.Stack(...)`/`make()` call — see `Stack.ts`'s `Layer.effect`).\n * A `WeakMap` so two unrelated stacks (sequential test runs, two stacks in one process) never see\n * each other's claims, and so the registry is collected with the stack instead of growing forever\n * in a long-running process (an `alchemy dev` watch loop, this package's own test suite).\n */\nconst registries = new WeakMap<object, Map<string, string>>();\n\nconst registryFor = (stack: object): Map<string, string> => {\n const existing = registries.get(stack);\n if (existing) return existing;\n const created = new Map<string, string>();\n registries.set(stack, created);\n return created;\n};\n\n/**\n * Claim `id` for `type` in the current namespace scope. Fine the first time, and fine again for\n * the SAME type (the legitimate \"yield the same resource from two call sites\" pattern Alchemy's\n * own idempotent registration supports). Dies with {@link DuplicateResourceIdError} the moment a\n * DIFFERENT type claims an id already taken — before that second resource's provider ever runs.\n *\n * ⚠️ `Effect.die`, NOT a typed failure. This runs at registration, not inside a lifecycle\n * operation (`read`/`diff`/`reconcile`/`delete`) — the provider-standard's S20 ban on\n * `Effect.orDie` in LIFECYCLE operations does not apply here, and `ConflictingProviderModeError`\n * right next door in upstream's own `Resource.ts` dies the same way for the same reason: there is\n * no recovery a caller could usefully attempt mid-registration.\n */\nexport const claimResourceId = (type: string, id: string): Effect.Effect<void, never, Stack> =>\n Effect.gen(function* () {\n const stack = yield* Stack;\n const namespace = yield* CurrentNamespace;\n const fqn = toFqn(namespace, id);\n const registry = registryFor(stack);\n const firstType = registry.get(fqn);\n if (firstType === undefined) {\n registry.set(fqn, type);\n return;\n }\n if (firstType !== type) {\n return yield* Effect.die(\n new DuplicateResourceIdError({\n message:\n `REFUSING: duplicate resource id \"${fqn}\" in scope ${scopeLabel(namespace)}: ` +\n `first declared by ${firstType}, again by ${type}`,\n fqn,\n scope: scopeLabel(namespace),\n firstType,\n secondType: type,\n }),\n );\n }\n });\n",
|
|
6
|
+
"/**\n * `guardDuplicateIds` — wrap a Resource class so every `(\"id\", props)` call it makes first\n * claims that id through `duplicate-id.ts`'s per-stack registry, naming both resource types if\n * a second, different-typed resource already claimed it. See duplicate-id.ts's header for why\n * the check has to live HERE (at the moment of registration) rather than after the stack body\n * has finished running.\n *\n * Usage, in a resource's own file, right where it's exported today:\n *\n * ```ts\n * export const CaddyConfig = guardDuplicateIds(Resource<CaddyConfig>('Caddy.Config', { … }));\n * ```\n *\n * ★ A `Proxy` around the REAL class, not a reimplementation. `Resource()`'s return value is a\n * callable object wearing an `Effectable.Prototype` (`Effect.isEffect(MyResource) === true`,\n * for the bare-tag platform-resource pattern) plus `.ref`, `.Type`, `.Provider`, `.Self`,\n * `.Aliases` — reconstructing that shape risks silently dropping one of them. A `Proxy` whose\n * `get` trap is the default (`Reflect.get`) forwards every property read untouched; only the\n * `apply` trap — the `MyResource(\"id\", props)` call form — is new behaviour.\n * ⚠️ THE \"REGISTER METHODS\" OVERLOAD IS DELIBERATELY LEFT ALONE. `ResourceClass` is also callable\n * with a single object argument to attach methods (`Resource()`'s own `typeof args[0] ===\n * 'object' ? Object.assign(...) : constructor(...)` branch) — that call mutates and returns the\n * SAME class, carries no logical id, and has nothing to claim. Only a string first argument\n * (the `(id, props)` form) goes through `claimResourceId`.\n */\nimport type { ResourceClass, ResourceLike } from 'alchemy/Resource';\nimport * as Effect from 'effect/Effect';\nimport { claimResourceId } from './duplicate-id.ts';\n\nexport const guardDuplicateIds = <R extends ResourceLike>(\n resourceClass: ResourceClass<R>,\n): ResourceClass<R> =>\n new Proxy(resourceClass, {\n apply(target, thisArg, args: unknown[]) {\n const id = args[0];\n if (typeof id !== 'string') {\n // The \"attach methods\" overload — not a registration, nothing to claim.\n return Reflect.apply(target as (...a: unknown[]) => unknown, thisArg, args);\n }\n return Effect.gen(function* () {\n yield* claimResourceId(resourceClass.Type, id);\n return yield* Reflect.apply(\n target as (...a: unknown[]) => unknown,\n thisArg,\n args,\n ) as Effect.Effect<R>;\n });\n },\n }) as ResourceClass<R>;\n"
|
|
7
|
+
],
|
|
8
|
+
"mappings": ";AA8BA;AACA;AACA;AACA;AACA;AAAA;AAMO,MAAM,iCAAsC,iBAAY,0BAA0B,EAMtF;AAAC;AAEJ,IAAM,aAAa,CAAC,cAAiD;AAAA,EACnE,IAAI,CAAC;AAAA,IAAW,OAAO;AAAA,EACvB,MAAM,OAAiB,CAAC;AAAA,EACxB,IAAI,OAAkC;AAAA,EACtC,OAAO,MAAM;AAAA,IACX,KAAK,QAAQ,KAAK,EAAE;AAAA,IACpB,OAAO,KAAK;AAAA,EACd;AAAA,EACA,OAAO,KAAK,KAAK,GAAG;AAAA;AAUtB,IAAM,aAAa,IAAI;AAEvB,IAAM,cAAc,CAAC,UAAuC;AAAA,EAC1D,MAAM,WAAW,WAAW,IAAI,KAAK;AAAA,EACrC,IAAI;AAAA,IAAU,OAAO;AAAA,EACrB,MAAM,UAAU,IAAI;AAAA,EACpB,WAAW,IAAI,OAAO,OAAO;AAAA,EAC7B,OAAO;AAAA;AAeF,IAAM,kBAAkB,CAAC,MAAc,OACrC,WAAI,UAAU,GAAG;AAAA,EACtB,MAAM,QAAQ,OAAO;AAAA,EACrB,MAAM,YAAY,OAAO;AAAA,EACzB,MAAM,MAAM,MAAM,WAAW,EAAE;AAAA,EAC/B,MAAM,WAAW,YAAY,KAAK;AAAA,EAClC,MAAM,YAAY,SAAS,IAAI,GAAG;AAAA,EAClC,IAAI,cAAc,WAAW;AAAA,IAC3B,SAAS,IAAI,KAAK,IAAI;AAAA,IACtB;AAAA,EACF;AAAA,EACA,IAAI,cAAc,MAAM;AAAA,IACtB,OAAO,OAAc,WACnB,IAAI,yBAAyB;AAAA,MAC3B,SACE,oCAAoC,iBAAiB,WAAW,SAAS,QACzE,qBAAqB,uBAAuB;AAAA,MAC9C;AAAA,MACA,OAAO,WAAW,SAAS;AAAA,MAC3B;AAAA,MACA,YAAY;AAAA,IACd,CAAC,CACH;AAAA,EACF;AAAA,CACD;;ACtFH;AAGO,IAAM,oBAAoB,CAC/B,kBAEA,IAAI,MAAM,eAAe;AAAA,EACvB,KAAK,CAAC,QAAQ,SAAS,MAAiB;AAAA,IACtC,MAAM,KAAK,KAAK;AAAA,IAChB,IAAI,OAAO,OAAO,UAAU;AAAA,MAE1B,OAAO,QAAQ,MAAM,QAAwC,SAAS,IAAI;AAAA,IAC5E;AAAA,IACA,OAAc,YAAI,UAAU,GAAG;AAAA,MAC7B,OAAO,gBAAgB,cAAc,MAAM,EAAE;AAAA,MAC7C,OAAO,OAAO,QAAQ,MACpB,QACA,SACA,IACF;AAAA,KACD;AAAA;AAEL,CAAC;",
|
|
9
|
+
"debugId": "1AB58BD65E38742164756E2164756E21",
|
|
10
|
+
"names": []
|
|
11
|
+
}
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* before comparing — never applied to only `live` or only `declared` (the specific gap MEDIUM-4's
|
|
11
11
|
* own mutant found in `network-drift.ts`; see its header, and `acl-rule-drift.ts`'s own repeat of
|
|
12
12
|
* this same rule for `protocolFilter`/`enforcingDeviceFilter`).
|
|
13
|
-
* ★ `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* ★ `destination`/`ipProtocolScope`/`schedule`/`source` run through `normalizeSetArrays` on BOTH
|
|
14
|
+
* sides (set-like nested arrays compare as sets; `firewall-policy-form.ts`'s header), then the
|
|
15
|
+
* default `deepEqual`. `action` compares wholesale.
|
|
16
16
|
*/
|
|
17
17
|
import type * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
18
18
|
import { type FirewallPolicyAttributes, type FirewallPolicyProps } from './firewall-policy-form.ts';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"firewall-policy-drift.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-drift.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAEzE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,
|
|
1
|
+
{"version":3,"file":"firewall-policy-drift.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-drift.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAEzE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAIzB,MAAM,2BAA2B,CAAC;AAoCnC,eAAO,MAAM,OAAO,eACN,wBAAwB,SAC7B,mBAAmB,KACzB,OAAqD,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,OAAO,SAAU,QAAQ,CAAC,cAAc,SAAS,mBAAmB,sCACnC,CAAC"}
|
|
@@ -15,17 +15,18 @@
|
|
|
15
15
|
* `deepEqual` (`stripNullish`) as ONE value, same proportion `acl-rule-form.ts`'s
|
|
16
16
|
* `enforcingDeviceFilter` gets (one level of structure, not exploded into scalars).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
|
|
22
|
-
* `FirewallPolicyApplicationFilter.applicationIds`, `
|
|
23
|
-
* `
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
18
|
+
* ★ SET-LIKE NESTED ARRAYS COMPARE AS SETS (closed 2026-10-19; was T15's accepted gap). The
|
|
19
|
+
* vendor's filter variants inside `source`/`destination`/`ipProtocolScope`/`schedule` carry
|
|
20
|
+
* arrays with no documented order: `FirewallPolicyIPAddressFilter.items`, the port filter's
|
|
21
|
+
* `items`, `FirewallPolicyMACAddressFilter.macAddresses`, `FirewallPolicyNetworkFilter.networkIds`,
|
|
22
|
+
* `FirewallPolicyApplicationFilter.applicationIds`, `FirewallPolicyRegionFilter.regions`,
|
|
23
|
+
* `FirewallPolicyVPNServerFilter.vpnServerIds`, `FirewallPolicyDomainFilter.domains`,
|
|
24
|
+
* `applicationCategoryIds` and `FirewallSchedule.repeatOnDays`. Measured:
|
|
25
|
+
* the controller (Network 11.0.83) stored a declared port list [443, 9100] as [9100, 443], so a
|
|
26
|
+
* correct create failed its read-back (`UnifiCreateDidNotConverge`, homeflare-network PR 79).
|
|
27
|
+
* `normalizeSetArrays` sorts every array under one of those keys (by canonical JSON) on BOTH
|
|
28
|
+
* sides before `deepEqual`. Membership still has to match exactly, and duplicates are kept, so a
|
|
29
|
+
* real change is never hidden.
|
|
29
30
|
*
|
|
30
31
|
* ⛔ `connectionStateFilter` IS THE ONE TOP-LEVEL PROPS ARRAY, AND IS A SET — "Match on firewall
|
|
31
32
|
* connection state. If null, matches all connection states" describes membership, not a sequence
|
|
@@ -48,6 +49,8 @@ import type * as firewall from '@distilled.cloud/unifi-network/firewall';
|
|
|
48
49
|
* see `acl-rule-form.ts`'s own header for why a normalizer applied to only one side is a bug
|
|
49
50
|
* class (MEDIUM-4). */
|
|
50
51
|
export declare const normalizeConnectionStateFilter: (value: readonly string[] | undefined) => string[] | undefined;
|
|
52
|
+
/** A deep copy with every SET_ARRAY_KEYS array sorted by canonical JSON (duplicates kept). */
|
|
53
|
+
export declare const normalizeSetArrays: <T>(value: T) => T;
|
|
51
54
|
/** ⚠️ EVERY OPTIONAL FIELD SPELLS OUT `| undefined` — `exactOptionalPropertyTypes`; see
|
|
52
55
|
* `network-form.ts`'s own header.
|
|
53
56
|
*
|
|
@@ -64,17 +67,17 @@ export interface FirewallPolicyProps {
|
|
|
64
67
|
/** Connection states this policy matches — compared as a SET, see the header. */
|
|
65
68
|
connectionStateFilter?: string[] | undefined;
|
|
66
69
|
description?: string | undefined;
|
|
67
|
-
/** Traffic destination — compound, compared wholesale; see the header
|
|
70
|
+
/** Traffic destination — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
68
71
|
destination: firewall.FirewallPolicyDestination;
|
|
69
72
|
enabled: boolean;
|
|
70
|
-
/** IP version + protocol scope — compound, compared wholesale; see the header
|
|
73
|
+
/** IP version + protocol scope — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
71
74
|
ipProtocolScope: firewall.FirewallPolicyIPProtocolScope;
|
|
72
75
|
ipsecFilter?: string | undefined;
|
|
73
76
|
loggingEnabled: boolean;
|
|
74
77
|
name: string;
|
|
75
|
-
/** Schedule this policy is active on — compound, compared wholesale; see the header
|
|
78
|
+
/** Schedule this policy is active on — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
76
79
|
schedule?: firewall.FirewallSchedule | undefined;
|
|
77
|
-
/** Traffic source — compound, compared wholesale; see the header
|
|
80
|
+
/** Traffic source — compound, compared wholesale with set-like arrays normalized; see the header. */
|
|
78
81
|
source: firewall.FirewallPolicySource;
|
|
79
82
|
}
|
|
80
83
|
export interface FirewallPolicyAttributes {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"firewall-policy-form.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-form.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"firewall-policy-form.d.ts","sourceRoot":"","sources":["../../src/unifi/firewall-policy-form.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,OAAO,KAAK,KAAK,QAAQ,MAAM,yCAAyC,CAAC;AAIzE;;wBAEwB;AACxB,eAAO,MAAM,8BAA8B,UAClC,SAAS,MAAM,EAAE,GAAG,SAAS,KACnC,MAAM,EAAE,GAAG,SAA2D,CAAC;AA6B1E,8FAA8F;AAC9F,eAAO,MAAM,kBAAkB,GAAI,CAAC,SAAS,CAAC,KAAG,CAehD,CAAC;AAEF;;;;;;;;6FAQ6F;AAC7F,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,iFAAiF;IACjF,qBAAqB,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC7C,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,0GAA0G;IAC1G,WAAW,EAAE,QAAQ,CAAC,yBAAyB,CAAC;IAChD,OAAO,EAAE,OAAO,CAAC;IACjB,kHAAkH;IAClH,eAAe,EAAE,QAAQ,CAAC,6BAA6B,CAAC;IACxD,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,wHAAwH;IACxH,QAAQ,CAAC,EAAE,QAAQ,CAAC,gBAAgB,GAAG,SAAS,CAAC;IACjD,qGAAqG;IACrG,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;CACvC;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,qBAAqB,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAC5C,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,QAAQ,CAAC,yBAAyB,CAAC;IAChD,OAAO,EAAE,OAAO,CAAC;IACjB,oGAAoG;IACpG,KAAK,EAAE,MAAM,CAAC;IACd,eAAe,EAAE,QAAQ,CAAC,6BAA6B,CAAC;IACxD,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,cAAc,EAAE,OAAO,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC,gBAAgB,GAAG,SAAS,CAAC;IAChD,MAAM,EAAE,QAAQ,CAAC,oBAAoB,CAAC;IACtC,6FAA6F;IAC7F,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,YAAY,SACjB,QAAQ,CAAC,cAAc,SACtB,mBAAmB,KACzB,wBAgBD,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,SAC1B,QAAQ,CAAC,cAAc,UACrB,MAAM,KACb,mBAcD,CAAC"}
|
package/dist/unifi/index.js
CHANGED
|
@@ -318,6 +318,15 @@ var computePatch = (news, olds, patchKeys) => {
|
|
|
318
318
|
}
|
|
319
319
|
return { set, unset };
|
|
320
320
|
};
|
|
321
|
+
var acceptInto = (olds, news, accepted) => {
|
|
322
|
+
const next = { ...olds };
|
|
323
|
+
for (const field of accepted) {
|
|
324
|
+
const key = String(field);
|
|
325
|
+
if (key in news || key in next)
|
|
326
|
+
next[key] = news[key];
|
|
327
|
+
}
|
|
328
|
+
return next;
|
|
329
|
+
};
|
|
321
330
|
var updateReconcile = (spec, { news, olds, output, note }) => Effect5.gen(function* () {
|
|
322
331
|
const identity = spec.describe(news);
|
|
323
332
|
const live = yield* spec.fetchLive(news);
|
|
@@ -334,15 +343,22 @@ var updateReconcile = (spec, { news, olds, output, note }) => Effect5.gen(functi
|
|
|
334
343
|
if (previous !== identity) {
|
|
335
344
|
return yield* new UnifiIdentityChanged({ type: spec.type, identity, previous });
|
|
336
345
|
}
|
|
346
|
+
const disagrees = new Set(update.driftOf(live, news).map((d) => String(d.field)));
|
|
337
347
|
const drifted = update.driftOf(live, olds).map((d) => d.field);
|
|
338
|
-
|
|
348
|
+
const conflicts = drifted.filter((field) => disagrees.has(String(field)));
|
|
349
|
+
if (conflicts.length > 0) {
|
|
339
350
|
return yield* new UnifiLiveDriftedSinceDeploy({
|
|
340
351
|
type: spec.type,
|
|
341
352
|
identity,
|
|
342
|
-
fields:
|
|
353
|
+
fields: conflicts
|
|
343
354
|
});
|
|
344
355
|
}
|
|
345
|
-
const
|
|
356
|
+
const accepted = drifted.filter((field) => !disagrees.has(String(field)));
|
|
357
|
+
const baseline = acceptInto(olds, news, accepted);
|
|
358
|
+
if (accepted.length > 0) {
|
|
359
|
+
yield* note(`${spec.type} ${identity}: live edits since the last deploy already match the declaration, accepted: [${accepted.map(String).join(", ")}]`);
|
|
360
|
+
}
|
|
361
|
+
const patch = computePatch(news, baseline, update.patchKeys);
|
|
346
362
|
if (Object.keys(patch.set).length === 0 && patch.unset.length === 0) {
|
|
347
363
|
return yield* refuseWrite(spec.type, identity, "update");
|
|
348
364
|
}
|
|
@@ -936,6 +952,30 @@ var writeFirewallPolicyCreate = (props) => firewall2.createFirewallPolicy(firewa
|
|
|
936
952
|
// src/unifi/firewall-policy-form.ts
|
|
937
953
|
var sortedSet4 = (values) => [...new Set(values)].sort();
|
|
938
954
|
var normalizeConnectionStateFilter = (value) => value == null ? undefined : sortedSet4(value);
|
|
955
|
+
var SET_ARRAY_KEYS = new Set([
|
|
956
|
+
"items",
|
|
957
|
+
"macAddresses",
|
|
958
|
+
"networkIds",
|
|
959
|
+
"applicationIds",
|
|
960
|
+
"regions",
|
|
961
|
+
"vpnServerIds",
|
|
962
|
+
"repeatOnDays",
|
|
963
|
+
"domains",
|
|
964
|
+
"applicationCategoryIds"
|
|
965
|
+
]);
|
|
966
|
+
var sortKeys = (value) => Array.isArray(value) ? value.map(sortKeys) : value !== null && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])])) : value;
|
|
967
|
+
var canonical = (value) => JSON.stringify(sortKeys(normalizeSetArrays(value)));
|
|
968
|
+
var normalizeSetArrays = (value) => {
|
|
969
|
+
if (Array.isArray(value))
|
|
970
|
+
return value.map((v) => normalizeSetArrays(v));
|
|
971
|
+
if (value === null || typeof value !== "object")
|
|
972
|
+
return value;
|
|
973
|
+
const out = {};
|
|
974
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
975
|
+
out[key] = SET_ARRAY_KEYS.has(key) && Array.isArray(inner) ? [...inner].map((v) => normalizeSetArrays(v)).sort((x, y) => canonical(x) < canonical(y) ? -1 : canonical(x) > canonical(y) ? 1 : 0) : normalizeSetArrays(inner);
|
|
976
|
+
}
|
|
977
|
+
return out;
|
|
978
|
+
};
|
|
939
979
|
var attributesOf7 = (live, props) => ({
|
|
940
980
|
siteId: props.siteId,
|
|
941
981
|
firewallPolicyId: live.id,
|
|
@@ -978,14 +1018,30 @@ var fieldDrift7 = makeDriftOf([
|
|
|
978
1018
|
declared: (p) => normalizeConnectionStateFilter(p.connectionStateFilter)
|
|
979
1019
|
},
|
|
980
1020
|
{ field: "description", live: (a) => a.description, declared: (p) => p.description },
|
|
981
|
-
{
|
|
1021
|
+
{
|
|
1022
|
+
field: "destination",
|
|
1023
|
+
live: (a) => normalizeSetArrays(a.destination),
|
|
1024
|
+
declared: (p) => normalizeSetArrays(p.destination)
|
|
1025
|
+
},
|
|
982
1026
|
{ field: "enabled", live: (a) => a.enabled, declared: (p) => p.enabled },
|
|
983
|
-
{
|
|
1027
|
+
{
|
|
1028
|
+
field: "ipProtocolScope",
|
|
1029
|
+
live: (a) => normalizeSetArrays(a.ipProtocolScope),
|
|
1030
|
+
declared: (p) => normalizeSetArrays(p.ipProtocolScope)
|
|
1031
|
+
},
|
|
984
1032
|
{ field: "ipsecFilter", live: (a) => a.ipsecFilter, declared: (p) => p.ipsecFilter },
|
|
985
1033
|
{ field: "loggingEnabled", live: (a) => a.loggingEnabled, declared: (p) => p.loggingEnabled },
|
|
986
1034
|
{ field: "name", live: (a) => a.name, declared: (p) => p.name },
|
|
987
|
-
{
|
|
988
|
-
|
|
1035
|
+
{
|
|
1036
|
+
field: "schedule",
|
|
1037
|
+
live: (a) => normalizeSetArrays(a.schedule),
|
|
1038
|
+
declared: (p) => normalizeSetArrays(p.schedule)
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
field: "source",
|
|
1042
|
+
live: (a) => normalizeSetArrays(a.source),
|
|
1043
|
+
declared: (p) => normalizeSetArrays(p.source)
|
|
1044
|
+
}
|
|
989
1045
|
]);
|
|
990
1046
|
var matches7 = (attributes, props) => fieldDrift7(attributes, props).length === 0;
|
|
991
1047
|
var driftOf7 = (live, props) => fieldDrift7(attributesOf7(live, props), props);
|
|
@@ -1195,5 +1251,5 @@ export {
|
|
|
1195
1251
|
driftOf6 as wifiBroadcastDriftOf
|
|
1196
1252
|
};
|
|
1197
1253
|
|
|
1198
|
-
//# debugId=
|
|
1254
|
+
//# debugId=D14D4404F58A5F1264756E2164756E21
|
|
1199
1255
|
//# sourceMappingURL=index.js.map
|