@homeflare/alchemy 0.49.0 → 0.51.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/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
- package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
- package/dist/kubernetes/index.d.ts +12 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +585 -0
- package/dist/kubernetes/index.js.map +19 -0
- package/dist/kubernetes/ready-checks.d.ts +45 -0
- package/dist/kubernetes/ready-checks.d.ts.map +1 -0
- package/dist/kubernetes/ready-classify.d.ts +23 -0
- package/dist/kubernetes/ready-classify.d.ts.map +1 -0
- package/dist/kubernetes/ready-connect.d.ts +29 -0
- package/dist/kubernetes/ready-connect.d.ts.map +1 -0
- package/dist/kubernetes/ready-declare.d.ts +5 -0
- package/dist/kubernetes/ready-declare.d.ts.map +1 -0
- package/dist/kubernetes/ready-errors.d.ts +80 -0
- package/dist/kubernetes/ready-errors.d.ts.map +1 -0
- package/dist/kubernetes/ready-poll.d.ts +61 -0
- package/dist/kubernetes/ready-poll.d.ts.map +1 -0
- package/dist/kubernetes/ready.d.ts +74 -0
- package/dist/kubernetes/ready.d.ts.map +1 -0
- package/dist/talos/fake-apiserver.d.ts +11 -2
- package/dist/talos/fake-apiserver.d.ts.map +1 -1
- package/dist/unifi/acl-rule-ordering.d.ts +6 -1
- package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
- package/dist/unifi/acl-rule.d.ts +6 -1
- package/dist/unifi/acl-rule.d.ts.map +1 -1
- package/dist/unifi/dns-policy.d.ts +6 -1
- package/dist/unifi/dns-policy.d.ts.map +1 -1
- package/dist/unifi/fake-unifi.d.ts +4 -0
- package/dist/unifi/fake-unifi.d.ts.map +1 -1
- package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
- package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
- package/dist/unifi/firewall-policy.d.ts +6 -1
- package/dist/unifi/firewall-policy.d.ts.map +1 -1
- package/dist/unifi/firewall-zone.d.ts +6 -1
- package/dist/unifi/firewall-zone.d.ts.map +1 -1
- package/dist/unifi/index.d.ts +5 -3
- package/dist/unifi/index.d.ts.map +1 -1
- package/dist/unifi/index.js +339 -86
- package/dist/unifi/index.js.map +10 -6
- package/dist/unifi/network-scope.d.ts +31 -0
- package/dist/unifi/network-scope.d.ts.map +1 -0
- package/dist/unifi/network-update.d.ts +44 -0
- package/dist/unifi/network-update.d.ts.map +1 -0
- package/dist/unifi/network.d.ts +32 -12
- package/dist/unifi/network.d.ts.map +1 -1
- package/dist/unifi/policy.d.ts +89 -1
- package/dist/unifi/policy.d.ts.map +1 -1
- package/dist/unifi/resource.d.ts +17 -43
- package/dist/unifi/resource.d.ts.map +1 -1
- package/dist/unifi/traffic-matching-list.d.ts +6 -1
- package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
- package/dist/unifi/update-reconcile.d.ts +31 -0
- package/dist/unifi/update-reconcile.d.ts.map +1 -0
- package/dist/unifi/wifi-broadcast.d.ts +6 -1
- package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
- package/dist/unifi/wire-guard.d.ts +33 -0
- package/dist/unifi/wire-guard.d.ts.map +1 -0
- package/docs/kubernetes-ready.md +146 -0
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/docs/unifi-network-write.md +90 -0
- package/docs/unifi.md +36 -36
- package/package.json +7 -1
- package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
- package/src/kubernetes/helm-chart-bounded.ts +110 -0
- package/src/kubernetes/index.ts +28 -0
- package/src/kubernetes/ready-backoff.test.ts +124 -0
- package/src/kubernetes/ready-checks.test.ts +106 -0
- package/src/kubernetes/ready-checks.ts +165 -0
- package/src/kubernetes/ready-classify.ts +76 -0
- package/src/kubernetes/ready-connect.ts +52 -0
- package/src/kubernetes/ready-declare.test.ts +85 -0
- package/src/kubernetes/ready-declare.ts +50 -0
- package/src/kubernetes/ready-errors.ts +102 -0
- package/src/kubernetes/ready-first-install.test.ts +126 -0
- package/src/kubernetes/ready-poll-states.test.ts +92 -0
- package/src/kubernetes/ready-poll.test.ts +194 -0
- package/src/kubernetes/ready-poll.ts +215 -0
- package/src/kubernetes/ready-rollout.test.ts +58 -0
- package/src/kubernetes/ready-secrecy.test.ts +131 -0
- package/src/kubernetes/ready.fixtures.ts +103 -0
- package/src/kubernetes/ready.test.ts +205 -0
- package/src/kubernetes/ready.ts +188 -0
- package/src/talos/fake-apiserver.ts +21 -3
- package/src/unifi/fake-unifi.ts +15 -2
- package/src/unifi/handler-allow-news.test.ts +80 -0
- package/src/unifi/index.ts +21 -5
- package/src/unifi/network-scope.test.ts +203 -0
- package/src/unifi/network-scope.ts +75 -0
- package/src/unifi/network-update.test.ts +250 -0
- package/src/unifi/network-update.ts +95 -0
- package/src/unifi/network.test.ts +27 -1
- package/src/unifi/network.ts +56 -12
- package/src/unifi/policy.ts +128 -10
- package/src/unifi/resource.test.ts +15 -3
- package/src/unifi/resource.ts +64 -105
- package/src/unifi/update-reconcile.ts +126 -0
- package/src/unifi/wire-guard-anchor.test.ts +198 -0
- package/src/unifi/wire-guard-round2.test.ts +120 -0
- package/src/unifi/wire-guard.test.ts +218 -0
- package/src/unifi/wire-guard.ts +192 -0
- package/src/unifi/write-op-reference.test.ts +38 -2
- package/src/unifi/get-only-guard.test.ts +0 -165
package/src/unifi/network.ts
CHANGED
|
@@ -11,12 +11,11 @@
|
|
|
11
11
|
* `FirewallZone`; `FirewallPolicy`'s several discriminated filter variants and its
|
|
12
12
|
* list-replacing ordering endpoint are a bigger, separate PR).
|
|
13
13
|
*
|
|
14
|
-
* ⛔ THE WHOLE-OBJECT-PUT TRAP (
|
|
15
|
-
* `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration
|
|
16
|
-
* outright when the caller omits them
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* never a props-only body.
|
|
14
|
+
* ⛔ THE WHOLE-OBJECT-PUT TRAP (packages/distilled-unifi-network/README.md) FIRES HERE, THROUGH `network-update.ts`;
|
|
15
|
+
* see its header. `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/
|
|
16
|
+
* `ipv6Configuration` outright when the caller omits them, so the body is the raw live object
|
|
17
|
+
* plus only the fields the declaration changed since the last deploy — never props alone.
|
|
18
|
+
* This is the ONE family with a write path (Tim 2026-10-10, `policy.ts`); create/delete refuse.
|
|
20
19
|
*/
|
|
21
20
|
import { Resource } from 'alchemy';
|
|
22
21
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
@@ -24,9 +23,15 @@ import * as Provider from 'alchemy/Provider';
|
|
|
24
23
|
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
25
24
|
import * as Effect from 'effect/Effect';
|
|
26
25
|
import { type NetworkAttributes, type NetworkProps, attributesOf } from './network-form.ts';
|
|
27
|
-
import { matches } from './network-drift.ts';
|
|
26
|
+
import { driftOf, matches } from './network-drift.ts';
|
|
27
|
+
import { type ScopeError, checkNetworkWriteScope } from './network-scope.ts';
|
|
28
|
+
import { writeNetwork } from './network-update.ts';
|
|
29
|
+
import type { UnifiUpdateWouldBeNoop } from './policy.ts';
|
|
28
30
|
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
29
31
|
import { unifiHandlers } from './resource.ts';
|
|
32
|
+
import type { AllowedWrite } from './wire-guard.ts';
|
|
33
|
+
|
|
34
|
+
type UpdateError = networks.UpdateNetworkError | UnifiUpdateWouldBeNoop | ScopeError;
|
|
30
35
|
|
|
31
36
|
export type { NetworkAttributes, NetworkProps } from './network-form.ts';
|
|
32
37
|
export { declareNetwork } from './network-form.ts';
|
|
@@ -48,10 +53,11 @@ export const UnifiNetwork = Resource<UnifiNetwork>('Unifi.Network', {
|
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
|
-
* `network('lan', props)` — `adopt(true)` piped on by default (H5).
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* remember `--adopt`, mirroring
|
|
56
|
+
* `network('lan', props)` — `adopt(true)` piped on by default (H5). A cold first deploy against an
|
|
57
|
+
* existing network can still only bind to it or refuse `OwnedBySomeoneElse` — the update path
|
|
58
|
+
* needs prior Alchemy state (`update-reconcile.ts`), so it cannot fire on adoption. Piping
|
|
59
|
+
* `adopt(true)` here picks the bind without a stack having to remember `--adopt`, mirroring
|
|
60
|
+
* `discord/application-command.ts`'s `applicationCommand`.
|
|
55
61
|
*/
|
|
56
62
|
export const network = (id: string, props: NetworkProps) =>
|
|
57
63
|
UnifiNetwork(id, props).pipe(adopt(true));
|
|
@@ -61,11 +67,42 @@ export const network = (id: string, props: NetworkProps) =>
|
|
|
61
67
|
* seam `netbox/prefix.ts`'s `spec` and `discord/application-command.ts`'s `spec` use — `handlers`
|
|
62
68
|
* bakes in `CredentialsFromEnv`, which resolves environment variables a test cannot repoint.
|
|
63
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* Every declarable key: `NetworkProps` minus the two identity ids. A test pins this against a
|
|
72
|
+
* fully-populated `NetworkProps` literal so a new prop cannot be silently unpatchable.
|
|
73
|
+
*/
|
|
74
|
+
export const NETWORK_PATCH_KEYS: ReadonlyArray<keyof NetworkProps> = [
|
|
75
|
+
'name',
|
|
76
|
+
'enabled',
|
|
77
|
+
'management',
|
|
78
|
+
'vlanId',
|
|
79
|
+
'dhcpGuarding',
|
|
80
|
+
'cellularBackupEnabled',
|
|
81
|
+
'internetAccessEnabled',
|
|
82
|
+
'ipv4Configuration',
|
|
83
|
+
'ipv6Configuration',
|
|
84
|
+
'isolationEnabled',
|
|
85
|
+
'mdnsForwardingEnabled',
|
|
86
|
+
'zoneId',
|
|
87
|
+
'deviceId',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The only PUT that may pass for this row: its own `/v1/sites/<siteId>/networks/<networkId>` under
|
|
92
|
+
* the configured base URL. ⛔ Built from the row's NEW props (the ids the write is about to use) and
|
|
93
|
+
* compared by exact string against the request path, so no id can act as a pattern.
|
|
94
|
+
*/
|
|
95
|
+
export const networkAllowedWrite = (props: NetworkProps): AllowedWrite => ({
|
|
96
|
+
method: 'PUT',
|
|
97
|
+
tail: `/v1/sites/${props.siteId}/networks/${props.networkId}`,
|
|
98
|
+
});
|
|
99
|
+
|
|
64
100
|
export const spec: UnifiSpec<
|
|
65
101
|
NetworkProps,
|
|
66
102
|
networks.NetworkDetails,
|
|
67
103
|
NetworkAttributes,
|
|
68
|
-
networks.GetNetworkDetailsError
|
|
104
|
+
networks.GetNetworkDetailsError,
|
|
105
|
+
UpdateError
|
|
69
106
|
> = {
|
|
70
107
|
type: 'Unifi.Network',
|
|
71
108
|
describe: (props) => `sites/${props.siteId}/networks/${props.networkId}`,
|
|
@@ -75,6 +112,13 @@ export const spec: UnifiSpec<
|
|
|
75
112
|
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
76
113
|
attributes: attributesOf,
|
|
77
114
|
matches,
|
|
115
|
+
update: {
|
|
116
|
+
allowedWrite: networkAllowedWrite,
|
|
117
|
+
patchKeys: NETWORK_PATCH_KEYS,
|
|
118
|
+
driftOf,
|
|
119
|
+
checkScope: checkNetworkWriteScope,
|
|
120
|
+
write: writeNetwork,
|
|
121
|
+
},
|
|
78
122
|
};
|
|
79
123
|
|
|
80
124
|
export const handlers = unifiHandlers(spec);
|
package/src/unifi/policy.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one refusal every `Unifi.*` write
|
|
2
|
+
* The one refusal every refused `Unifi.*` write returns. The family makes exactly one SDK write
|
|
3
|
+
* call — `networks.updateNetwork`, through `network-update.ts` — and that is the only write that
|
|
4
|
+
* never sees this error.
|
|
3
5
|
*
|
|
4
|
-
* ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY FOR
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* policy is a kit change (a new PR
|
|
9
|
-
* can pass.
|
|
6
|
+
* ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY. LIFTED FOR EXACTLY ONE CASE on
|
|
7
|
+
* 2026-10-10 (Tim): `Unifi.Network` update, through `update-reconcile.ts` + `network-update.ts`.
|
|
8
|
+
* Every other write — create, delete, any other family's update — still fails with this typed
|
|
9
|
+
* error, and `resource.test.ts`/`wire-guard.test.ts` prove it with a fake `HttpClient` that
|
|
10
|
+
* records every request. Widening the policy again is a kit change (a new PR, reviewed as one),
|
|
11
|
+
* not a flag a stack can pass.
|
|
10
12
|
*
|
|
11
13
|
* ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
|
|
12
14
|
* caller (a future import script, a test) branch on the refusal without parsing a message.
|
|
@@ -14,8 +16,14 @@
|
|
|
14
16
|
import * as Data from 'effect/Data';
|
|
15
17
|
import * as Effect from 'effect/Effect';
|
|
16
18
|
|
|
17
|
-
export const
|
|
18
|
-
|
|
19
|
+
export const UNIFI_WRITE_POLICY =
|
|
20
|
+
'UniFi is adopt-only except Unifi.Network update: one whole-object PUT built from the live ' +
|
|
21
|
+
'object plus the declared field changes, only on a row with prior Alchemy state, only through ' +
|
|
22
|
+
'deploy-approve; create/delete and every other family stay refused -- widening is a kit change ' +
|
|
23
|
+
'(Tim 2026-10-10 lifts the 2026-09-24 rule for this one case)';
|
|
24
|
+
|
|
25
|
+
/** @deprecated renamed `UNIFI_WRITE_POLICY`; kept so existing consumer imports do not break. */
|
|
26
|
+
export const UNIFI_READ_ONLY_POLICY = UNIFI_WRITE_POLICY;
|
|
19
27
|
|
|
20
28
|
/** What kind of write was asked for and refused — for the message only, never acted on. */
|
|
21
29
|
export type UnifiWriteAction = 'create' | 'update' | 'delete';
|
|
@@ -28,10 +36,120 @@ export class UnifiWriteRefused extends Data.TaggedError('UnifiWriteRefused')<{
|
|
|
28
36
|
readonly action: UnifiWriteAction;
|
|
29
37
|
}> {
|
|
30
38
|
override get message(): string {
|
|
31
|
-
return `${this.type} ${this.identity}: refusing to ${this.action} -- ${
|
|
39
|
+
return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_WRITE_POLICY}.`;
|
|
32
40
|
}
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
|
|
36
44
|
export const refuseWrite = (type: string, identity: string, action: UnifiWriteAction) =>
|
|
37
45
|
Effect.fail(new UnifiWriteRefused({ type, identity, action }));
|
|
46
|
+
|
|
47
|
+
// ⛔ THE THREE ERRORS BELOW CARRY FIELD NAMES ONLY, NEVER VALUES: a network row holds addressing
|
|
48
|
+
// detail, and an error message reaches logs, CI bodies and pasted reports.
|
|
49
|
+
const fieldList = (fields: ReadonlyArray<string>) => `[${fields.join(', ')}]`;
|
|
50
|
+
|
|
51
|
+
/** Live no longer equals what the last deploy wrote: a hand edit is never overwritten. */
|
|
52
|
+
export class UnifiLiveDriftedSinceDeploy extends Data.TaggedError('UnifiLiveDriftedSinceDeploy')<{
|
|
53
|
+
readonly type: string;
|
|
54
|
+
readonly identity: string;
|
|
55
|
+
readonly fields: ReadonlyArray<string>;
|
|
56
|
+
}> {
|
|
57
|
+
override get message(): string {
|
|
58
|
+
return (
|
|
59
|
+
`${this.type} ${this.identity}: live differs from the last deployed state on ` +
|
|
60
|
+
`${fieldList(this.fields)}; refusing to PUT over it. Two causes look identical from here: a ` +
|
|
61
|
+
'hand edit on the console, or an earlier PUT that landed but did not converge ' +
|
|
62
|
+
'(UnifiUpdateDidNotConverge). Re-import the live object first.'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The merged body equals live: a PUT would change nothing (keeps a re-PUT loop impossible). */
|
|
68
|
+
export class UnifiUpdateWouldBeNoop extends Data.TaggedError('UnifiUpdateWouldBeNoop')<{
|
|
69
|
+
readonly type: string;
|
|
70
|
+
readonly identity: string;
|
|
71
|
+
}> {
|
|
72
|
+
override get message(): string {
|
|
73
|
+
return `${this.type} ${this.identity}: the merged update body equals live; refusing a no-op PUT.`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The PUT answered, yet the response still differs from the declaration. */
|
|
78
|
+
export class UnifiUpdateDidNotConverge extends Data.TaggedError('UnifiUpdateDidNotConverge')<{
|
|
79
|
+
readonly type: string;
|
|
80
|
+
readonly identity: string;
|
|
81
|
+
readonly fields: ReadonlyArray<string>;
|
|
82
|
+
}> {
|
|
83
|
+
override get message(): string {
|
|
84
|
+
return (
|
|
85
|
+
`${this.type} ${this.identity}: the PUT LANDED, but live now differs from the declaration on ` +
|
|
86
|
+
`${fieldList(this.fields)}. The change is on the console and is not rolled back; the next ` +
|
|
87
|
+
'deploy will see this as drift. Recovery is a re-import of the live object.'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ⛔ THE FIVE ERRORS BELOW BOUND THE WRITE SCOPE (`network-scope.ts`, `update-reconcile.ts`): the
|
|
93
|
+
// one intended consumer is a single VLAN's `ipv6Configuration`. Field names only, as above.
|
|
94
|
+
|
|
95
|
+
/** A patch would remove a key outside the removable allowlist (an omitted block is turned off). */
|
|
96
|
+
export class UnifiFieldNotRemovable extends Data.TaggedError('UnifiFieldNotRemovable')<{
|
|
97
|
+
readonly type: string;
|
|
98
|
+
readonly identity: string;
|
|
99
|
+
readonly fields: ReadonlyArray<string>;
|
|
100
|
+
}> {
|
|
101
|
+
override get message(): string {
|
|
102
|
+
return (
|
|
103
|
+
`${this.type} ${this.identity}: refusing to remove ${fieldList(this.fields)}; a whole-object ` +
|
|
104
|
+
'PUT turns an omitted block off, and only the removable allowlist may be dropped.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A patch would SET a key outside the settable allowlist (only `ipv6Configuration` may be written). */
|
|
110
|
+
export class UnifiFieldNotSettable extends Data.TaggedError('UnifiFieldNotSettable')<{
|
|
111
|
+
readonly type: string;
|
|
112
|
+
readonly identity: string;
|
|
113
|
+
readonly fields: ReadonlyArray<string>;
|
|
114
|
+
}> {
|
|
115
|
+
override get message(): string {
|
|
116
|
+
return (
|
|
117
|
+
`${this.type} ${this.identity}: refusing to set ${fieldList(this.fields)}; this write path ` +
|
|
118
|
+
'only sets the settable allowlist (ipv6Configuration).'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The live object is the site's default (management) network: never written. */
|
|
124
|
+
export class UnifiManagementNetworkRefused extends Data.TaggedError(
|
|
125
|
+
'UnifiManagementNetworkRefused',
|
|
126
|
+
)<{
|
|
127
|
+
readonly type: string;
|
|
128
|
+
readonly identity: string;
|
|
129
|
+
}> {
|
|
130
|
+
override get message(): string {
|
|
131
|
+
return `${this.type} ${this.identity}: live is the default (management) network; refusing any write to it.`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A patch changes a field that decides where the network lives or what serves it. */
|
|
136
|
+
export class UnifiImmutableFieldChanged extends Data.TaggedError('UnifiImmutableFieldChanged')<{
|
|
137
|
+
readonly type: string;
|
|
138
|
+
readonly identity: string;
|
|
139
|
+
readonly fields: ReadonlyArray<string>;
|
|
140
|
+
}> {
|
|
141
|
+
override get message(): string {
|
|
142
|
+
return `${this.type} ${this.identity}: refusing to change ${fieldList(this.fields)}; those fields are never written.`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `olds` and `news` name different objects: the prior state is no baseline for this write. */
|
|
147
|
+
export class UnifiIdentityChanged extends Data.TaggedError('UnifiIdentityChanged')<{
|
|
148
|
+
readonly type: string;
|
|
149
|
+
readonly identity: string;
|
|
150
|
+
readonly previous: string;
|
|
151
|
+
}> {
|
|
152
|
+
override get message(): string {
|
|
153
|
+
return `${this.type} ${this.identity}: the last deploy named ${this.previous}; refusing to PUT against another object.`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -13,7 +13,12 @@ import { Unowned } from 'alchemy/AdoptPolicy';
|
|
|
13
13
|
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
14
14
|
import * as Effect from 'effect/Effect';
|
|
15
15
|
import { fakeUnifiLayer } from './fake-unifi.ts';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type UnifiIdentityChanged,
|
|
18
|
+
type UnifiLiveDriftedSinceDeploy,
|
|
19
|
+
type UnifiUpdateDidNotConverge,
|
|
20
|
+
UnifiWriteRefused,
|
|
21
|
+
} from './policy.ts';
|
|
17
22
|
import { type UnifiSpec, unifiOperations } from './resource.ts';
|
|
18
23
|
|
|
19
24
|
interface WidgetProps {
|
|
@@ -52,11 +57,18 @@ const run = <A, E>(effect: Effect.Effect<A, E, UnifiNetworkOpContext>) =>
|
|
|
52
57
|
Effect.runPromise(effect.pipe(Effect.provide(fakeUnifiLayer(unreachableFetch))));
|
|
53
58
|
|
|
54
59
|
const refusal = async (
|
|
55
|
-
effect: Effect.Effect<
|
|
60
|
+
effect: Effect.Effect<
|
|
61
|
+
unknown,
|
|
62
|
+
| UnifiWriteRefused
|
|
63
|
+
| UnifiLiveDriftedSinceDeploy
|
|
64
|
+
| UnifiUpdateDidNotConverge
|
|
65
|
+
| UnifiIdentityChanged,
|
|
66
|
+
UnifiNetworkOpContext
|
|
67
|
+
>,
|
|
56
68
|
) => {
|
|
57
69
|
const failure = await run(Effect.flip(effect));
|
|
58
70
|
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
59
|
-
return failure;
|
|
71
|
+
return failure as UnifiWriteRefused;
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
describe('unifiOperations.readHandler', () => {
|
package/src/unifi/resource.ts
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* One shape for every UniFi Network
|
|
3
|
-
*
|
|
2
|
+
* One shape for every UniFi Network object — `Unifi.Network`, `Unifi.FirewallZone`, and whatever
|
|
3
|
+
* import adds next. ADOPT-ONLY, except the one `update` a spec may opt into (`Unifi.Network`).
|
|
4
4
|
*
|
|
5
5
|
* ★ MIRRORS `../discord/resource.ts` (S7-S10, H1, H5), NOT `../netbox/resource.ts`. Both are
|
|
6
6
|
* "marker-less API" families (S8): UniFi Network objects carry no tag or metadata field this
|
|
7
7
|
* stack could stamp as ownership, so a cold read of a match is `Unowned(attrs)` exactly like
|
|
8
|
-
* `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.
|
|
8
|
+
* `Cloudflare/Snippets/Snippet.ts@v2.0.0-beta.81#read`, the reference implementation S8 and H1
|
|
9
9
|
* both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns
|
|
10
10
|
* that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.
|
|
11
11
|
*
|
|
12
|
-
* ⛔ THE
|
|
13
|
-
*
|
|
14
|
-
* `reconcile`
|
|
15
|
-
*
|
|
12
|
+
* ⛔ THE POSTURE (`policy.ts`, Tim 2026-09-24, lifted for ONE case 2026-10-10): there is no
|
|
13
|
+
* `create` and no `destroy` in this spec at all, and `update` exists only when a spec carries an
|
|
14
|
+
* `update` block (`update-reconcile.ts`). `reconcile` is `updateReconcile`: refuse a create,
|
|
15
|
+
* report an exact match with zero writes, and otherwise either refuse or run the three-way PUT.
|
|
16
|
+
* A spec without `update` (every family but Network) can only ever refuse or report a match.
|
|
16
17
|
*
|
|
17
18
|
* ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
|
|
18
|
-
* Measured against `Plan.ts@v2.0.0-beta.
|
|
19
|
+
* Measured against `Plan.ts@v2.0.0-beta.81` (`forceUpdateAfterAdoption`, H6): beta.81 forces
|
|
19
20
|
* ONE reconcile call after every cold adoption, whether or not the diff said noop — so a
|
|
20
21
|
* resource whose `reconcile` always failed would break the ordinary "adopt an unchanged
|
|
21
22
|
* object" deploy, not just a real write. The fix, exactly like `discordOperations.reconcile`:
|
|
22
|
-
* read the live object again
|
|
23
|
-
*
|
|
24
|
-
* calls nothing — S10's "zero write calls when there is no drift" holds even under the forced
|
|
25
|
-
* post-adoption call.
|
|
23
|
+
* read the live object again and return it on a match. S10's "zero write calls when there is
|
|
24
|
+
* no drift" holds even under the forced post-adoption call.
|
|
26
25
|
*
|
|
27
26
|
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE —
|
|
28
27
|
* same split netbox's `resource.ts` uses and for the same reason: `Effect.catchTag`'s
|
|
@@ -30,15 +29,21 @@
|
|
|
30
29
|
* spec's `E` is only concrete at each resource file's own call site. Every OTHER failure (a
|
|
31
30
|
* real 5xx, a network error, `Forbidden`) is NOT folded anywhere — it fails the plan loudly,
|
|
32
31
|
* per the task's own rule: only a genuine not-found may mean absent.
|
|
32
|
+
*
|
|
33
|
+
* ★ The wire guard (`wire-guard.ts`) and the three-way reconcile (`update-reconcile.ts`) live in
|
|
34
|
+
* their own files; `read`/`diff`/`delete` run under `GetOnlyHttpClient`, only `reconcile` gets
|
|
35
|
+
* the per-row allow entry of a spec that declares `update`.
|
|
33
36
|
*/
|
|
34
37
|
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
35
38
|
import { isResolved } from 'alchemy/Diff';
|
|
36
39
|
import type { Input } from 'alchemy/Input';
|
|
37
|
-
import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
|
|
40
|
+
import { Credentials, CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
|
|
38
41
|
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
39
42
|
import * as Effect from 'effect/Effect';
|
|
40
|
-
import * as HttpClient from 'effect/http/HttpClient';
|
|
41
|
-
import {
|
|
43
|
+
import type * as HttpClient from 'effect/http/HttpClient';
|
|
44
|
+
import { refuseWrite } from './policy.ts';
|
|
45
|
+
import { type ReconcileArgs, type UnifiUpdate, updateReconcile } from './update-reconcile.ts';
|
|
46
|
+
import { type AllowedWrite, guardedHttpClient } from './wire-guard.ts';
|
|
42
47
|
|
|
43
48
|
/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
|
|
44
49
|
export type UnifiRequirements = HttpClient.HttpClient;
|
|
@@ -47,9 +52,9 @@ export type UnifiRequirements = HttpClient.HttpClient;
|
|
|
47
52
|
* One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
|
|
48
53
|
* `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
|
|
49
54
|
* union, with `NotFound` already folded away) so the precise per-operation error union flows
|
|
50
|
-
* through instead of being widened by hand here.
|
|
55
|
+
* through instead of being widened by hand here. `E2` is the error union of the optional write.
|
|
51
56
|
*/
|
|
52
|
-
export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
|
|
57
|
+
export type UnifiSpec<Props extends object, Live, Attributes extends object, E, E2 = never> = {
|
|
53
58
|
/** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
|
|
54
59
|
readonly type: string;
|
|
55
60
|
/** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
|
|
@@ -58,10 +63,12 @@ export type UnifiSpec<Props extends object, Live, Attributes extends object, E>
|
|
|
58
63
|
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
59
64
|
/** For the refusal message and read-back failures. */
|
|
60
65
|
readonly describe: (props: Props) => string;
|
|
66
|
+
/** Present only on a family allowed to write (`Unifi.Network`); see `update-reconcile.ts`. */
|
|
67
|
+
readonly update?: UnifiUpdate<Props, Live, E2>;
|
|
61
68
|
};
|
|
62
69
|
|
|
63
|
-
export const unifiOperations = <Props extends object, Live, Attributes extends object, E>(
|
|
64
|
-
spec: UnifiSpec<Props, Live, Attributes, E>,
|
|
70
|
+
export const unifiOperations = <Props extends object, Live, Attributes extends object, E, E2>(
|
|
71
|
+
spec: UnifiSpec<Props, Live, Attributes, E, E2>,
|
|
65
72
|
) => {
|
|
66
73
|
const read = (props: Props) =>
|
|
67
74
|
spec
|
|
@@ -91,16 +98,13 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
|
|
|
91
98
|
: ({ action: 'update' } as const);
|
|
92
99
|
}),
|
|
93
100
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Exact match: the object already is what is declared. Zero write calls (S10), correct
|
|
102
|
-
// whether this is a routine deploy or beta.79's forced post-adoption reconcile (H6).
|
|
103
|
-
return live;
|
|
101
|
+
/** Pre-state callers (tests, adopt-only probes) pass just `news`: `olds`/`output` unset. */
|
|
102
|
+
reconcile: (news: Props, rest: Partial<Omit<ReconcileArgs<Props>, 'news'>> = {}) =>
|
|
103
|
+
updateReconcile(spec, {
|
|
104
|
+
news,
|
|
105
|
+
olds: rest.olds,
|
|
106
|
+
output: rest.output,
|
|
107
|
+
note: rest.note ?? (() => Effect.void),
|
|
104
108
|
}),
|
|
105
109
|
|
|
106
110
|
destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
|
|
@@ -108,94 +112,49 @@ export const unifiOperations = <Props extends object, Live, Attributes extends o
|
|
|
108
112
|
};
|
|
109
113
|
|
|
110
114
|
/**
|
|
111
|
-
*
|
|
112
|
-
* `reconcile`/`destroy` — every path this engine exposes already returns that refusal instead of
|
|
113
|
-
* calling an SDK write op. This guard stops the same thing one layer lower, AT THE WIRE: it wraps
|
|
114
|
-
* whatever `HttpClient` the caller already provides (the stack's `FetchHttpClient.layer` in
|
|
115
|
-
* production, `fakeUnifiLayer` in tests — `openbao/bao-http.ts`'s header names this same
|
|
116
|
-
* "wrap, don't replace" seam) so a future resource file that, by mistake, called an SDK write
|
|
117
|
-
* operation directly — bypassing `unifiOperations` entirely — would still never reach the vendor
|
|
118
|
-
* API. `write-op-reference.test.ts` proves no such call exists today; this is what stops one from
|
|
119
|
-
* ever taking effect if one is ever added.
|
|
120
|
-
*
|
|
121
|
-
* ⚠️ `Effect.die`, NOT A TYPED FAILURE — classified per distilled-doctrine's "classify every
|
|
122
|
-
* die/orDie". Every generated SDK operation (`networks.getNetworkDetails`, …) declares its OWN
|
|
123
|
-
* closed error union from the pinned OpenAPI spec, and generated files are never hand-edited to
|
|
124
|
-
* add a member to it. A typed `Effect.fail` here would be a failure mode absent from every op's
|
|
125
|
-
* declared type the moment it occurred — a type-system lie. A defect is the sound way to add a
|
|
126
|
-
* NEW "this must be structurally impossible" failure underneath types this package does not
|
|
127
|
-
* own, exactly like `openbao`'s `refuse()` helpers' `Effect.die(new Error(...))` for a
|
|
128
|
-
* configuration move that should never be reachable.
|
|
129
|
-
*/
|
|
130
|
-
export class UnifiNonGetRequest extends Error {
|
|
131
|
-
constructor(
|
|
132
|
-
readonly method: string,
|
|
133
|
-
/** PATH ONLY, never the full URL — see `requestPath` below. */
|
|
134
|
-
readonly path: string,
|
|
135
|
-
) {
|
|
136
|
-
super(
|
|
137
|
-
`UniFi HttpClient guard: refused ${method} ${path} -- ${UNIFI_READ_ONLY_POLICY}. This is a ` +
|
|
138
|
-
'defect, not a recoverable condition: something under src/unifi tried to send a non-GET ' +
|
|
139
|
-
"request, which policy.ts's per-operation refusal should already have made impossible. " +
|
|
140
|
-
'Fix the kit code that produced this request.',
|
|
141
|
-
);
|
|
142
|
-
this.name = 'UnifiNonGetRequest';
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* ⛔ PATH ONLY, NEVER THE HOST. A cloud connector's base URL embeds the account's Console ID
|
|
148
|
-
* (`docs/unifi.md`'s Credentials section, T3) — a defect message is still a message that can
|
|
149
|
-
* reach a log, a CI failure body or a pasted error report, so it gets the same treatment as
|
|
150
|
-
* `redactUrls` gives the import layer's own error text. Falls back to a fixed placeholder rather
|
|
151
|
-
* than the raw string on a parse failure, so a malformed URL can never leak through unredacted.
|
|
152
|
-
*/
|
|
153
|
-
const requestPath = (url: string): string => {
|
|
154
|
-
try {
|
|
155
|
-
return new URL(url).pathname;
|
|
156
|
-
} catch {
|
|
157
|
-
return '<unparseable request url>';
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
const guardGetOnly = (client: HttpClient.HttpClient): HttpClient.HttpClient =>
|
|
162
|
-
client.pipe(
|
|
163
|
-
HttpClient.mapRequestEffect((request) =>
|
|
164
|
-
request.method === 'GET'
|
|
165
|
-
? Effect.succeed(request)
|
|
166
|
-
: Effect.die(new UnifiNonGetRequest(request.method, requestPath(request.url))),
|
|
167
|
-
),
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* A `Layer` that reads whatever `HttpClient` is already in the calling context and replaces it,
|
|
172
|
-
* for everything downstream, with `guardGetOnly`'s wrapped version (`HttpClient.layerMergedContext`
|
|
173
|
-
* — see the guard above). Exported so `get-only-guard.test.ts` can prove the mechanism directly,
|
|
174
|
-
* against a bare `HttpClient` service, without needing `CredentialsFromEnv` or any env var at all.
|
|
175
|
-
*/
|
|
176
|
-
export const GetOnlyHttpClient = HttpClient.layerMergedContext(
|
|
177
|
-
Effect.map(HttpClient.HttpClient, guardGetOnly),
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* The four provider handlers for a spec'd read-only UniFi object, wired once.
|
|
115
|
+
* The four provider handlers for a spec'd UniFi object, wired once.
|
|
182
116
|
*
|
|
183
117
|
* ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
|
|
184
118
|
* adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
|
|
185
119
|
*/
|
|
186
|
-
export const unifiHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
187
|
-
spec: UnifiSpec<Props, Live, Attributes, E>,
|
|
120
|
+
export const unifiHandlers = <Props extends object, Live, Attributes extends object, E, E2>(
|
|
121
|
+
spec: UnifiSpec<Props, Live, Attributes, E, E2>,
|
|
188
122
|
) => {
|
|
189
123
|
const ops = unifiOperations(spec);
|
|
190
|
-
const withCredentials = <A, Err>(
|
|
191
|
-
effect.
|
|
124
|
+
const withCredentials = <A, Err>(
|
|
125
|
+
effect: Effect.Effect<A, Err, UnifiNetworkOpContext>,
|
|
126
|
+
allow: ReadonlyArray<AllowedWrite> = [],
|
|
127
|
+
) =>
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
// ⛔ The guard anchors the allowed PUT to the CONFIGURED base URL (`wire-guard.ts`), so it is
|
|
130
|
+
// read from the same credentials the SDK uses.
|
|
131
|
+
const { apiBaseUrl } = yield* yield* Credentials;
|
|
132
|
+
return yield* effect.pipe(Effect.provide(guardedHttpClient(allow, apiBaseUrl)));
|
|
133
|
+
}).pipe(Effect.provide(CredentialsFromEnv)) as Effect.Effect<A, Err, HttpClient.HttpClient>;
|
|
134
|
+
|
|
192
135
|
return {
|
|
193
136
|
list: () => Effect.succeed([]),
|
|
194
137
|
read: (args: { olds: Props; output: Attributes | undefined }) =>
|
|
195
138
|
withCredentials(ops.readHandler(args)),
|
|
196
139
|
diff: (args: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
197
140
|
withCredentials(ops.diff(args.news, args.output)),
|
|
198
|
-
reconcile
|
|
141
|
+
// ⛔ ONLY `reconcile` gets the per-row allow entry, and only when the spec declares `update`.
|
|
142
|
+
reconcile: (args: {
|
|
143
|
+
news: Props;
|
|
144
|
+
olds: Props | undefined;
|
|
145
|
+
output: Attributes | undefined;
|
|
146
|
+
session: { note: (message: string) => Effect.Effect<void, never, never> };
|
|
147
|
+
}) =>
|
|
148
|
+
withCredentials(
|
|
149
|
+
ops.reconcile(args.news, {
|
|
150
|
+
olds: args.olds,
|
|
151
|
+
output: args.output,
|
|
152
|
+
note: (message) => args.session.note(message),
|
|
153
|
+
}),
|
|
154
|
+
// ⛔ From `news` (the ids the write will use), never `olds`; reconcile also refuses when
|
|
155
|
+
// `olds` names another object (`update-reconcile.ts`).
|
|
156
|
+
spec.update ? [spec.update.allowedWrite(args.news)] : [],
|
|
157
|
+
),
|
|
199
158
|
delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
|
|
200
159
|
};
|
|
201
160
|
};
|