@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network`'s `spec` against a fake UniFi Network API, proving the real distilled wire
|
|
3
|
+
* path (path assembly, JSON decode, `NotFound` folding), the declaration renderer, and — the
|
|
4
|
+
* task's own requirement — that no handler this family exposes ever sends a non-`GET` request.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import type * as networks from '@distilled.cloud/unifi-network/networks';
|
|
8
|
+
import * as Retry from '@distilled.cloud/unifi-network/Retry';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
|
|
11
|
+
import { attributesOf, matches } from './network-form.ts';
|
|
12
|
+
import { type NetworkProps, declareNetwork, spec } from './network.ts';
|
|
13
|
+
import { unifiOperations } from './resource.ts';
|
|
14
|
+
|
|
15
|
+
const NETWORK_PATH = '/proxy/network/integration/v1/sites/site-1/networks/net-1';
|
|
16
|
+
|
|
17
|
+
const liveNetwork = (
|
|
18
|
+
overrides: Partial<networks.NetworkDetails> = {},
|
|
19
|
+
): networks.NetworkDetails => ({
|
|
20
|
+
default: false,
|
|
21
|
+
enabled: true,
|
|
22
|
+
id: 'net-1',
|
|
23
|
+
management: 'ADVANCED',
|
|
24
|
+
metadata: { origin: 'USER' },
|
|
25
|
+
name: 'Cameras',
|
|
26
|
+
vlanId: 40,
|
|
27
|
+
isolationEnabled: true,
|
|
28
|
+
zoneId: 'zone-1',
|
|
29
|
+
...overrides,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const PROPS: NetworkProps = {
|
|
33
|
+
siteId: 'site-1',
|
|
34
|
+
networkId: 'net-1',
|
|
35
|
+
name: 'Cameras',
|
|
36
|
+
enabled: true,
|
|
37
|
+
management: 'ADVANCED',
|
|
38
|
+
vlanId: 40,
|
|
39
|
+
isolationEnabled: true,
|
|
40
|
+
zoneId: 'zone-1',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('Unifi.Network spec.fetchLive', () => {
|
|
44
|
+
test('a real GET response decodes into the typed live object', async () => {
|
|
45
|
+
const fake = fakeUnifi((method, url) =>
|
|
46
|
+
method === 'GET' && url.pathname === NETWORK_PATH
|
|
47
|
+
? Response.json(liveNetwork())
|
|
48
|
+
: fakeFailure(400, 'unexpected request'),
|
|
49
|
+
);
|
|
50
|
+
const live = await Effect.runPromise(
|
|
51
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
52
|
+
);
|
|
53
|
+
expect(live?.id).toBe('net-1');
|
|
54
|
+
expect(fake.seen).toEqual([{ method: 'GET', path: NETWORK_PATH }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a genuine 404 folds to undefined -- never a false "unreadable" on anything else', async () => {
|
|
58
|
+
const fake = fakeUnifi(() => fakeFailure(404, 'not found'));
|
|
59
|
+
const live = await Effect.runPromise(
|
|
60
|
+
spec.fetchLive(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
61
|
+
);
|
|
62
|
+
expect(live).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a 500 fails loudly -- only a genuine not-found may mean absent', async () => {
|
|
66
|
+
// ⚠️ `Retry.none`: the default policy retries a 500 (one of core's DEFAULT_ERROR_STATUSES)
|
|
67
|
+
// indefinitely with backoff -- this test asserts the FAILURE, not the retry schedule.
|
|
68
|
+
const fake = fakeUnifi(() => fakeFailure(500, 'boom'));
|
|
69
|
+
const failure = await Effect.runPromise(
|
|
70
|
+
Effect.flip(
|
|
71
|
+
spec.fetchLive(PROPS).pipe(Retry.none, Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
expect(failure._tag).toBe('InternalServerError');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('declareNetwork -- the declaration renderer', () => {
|
|
79
|
+
test('its output matches attributesOf(live) by construction, for any live object', () => {
|
|
80
|
+
const live = liveNetwork({ name: 'Guest', vlanId: 99, isolationEnabled: false });
|
|
81
|
+
const declared = declareNetwork(live, 'site-1');
|
|
82
|
+
const attrs = attributesOf(live, declared);
|
|
83
|
+
expect(matches(attrs, declared)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('matches -- unordered arrays (dhcpGuarding, ipv6Configuration)', () => {
|
|
88
|
+
// ⛔ `deepEqual` (alchemy/Diff) sorts object keys but not array elements: without normalizing
|
|
89
|
+
// these three fields (see network-form.ts's header), a console returning the same set in a
|
|
90
|
+
// different order would plan a spurious update.
|
|
91
|
+
test('the same trusted DHCP servers in a different order is a noop', () => {
|
|
92
|
+
const live = liveNetwork({
|
|
93
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.2', '10.0.0.1'] },
|
|
94
|
+
});
|
|
95
|
+
const props: NetworkProps = {
|
|
96
|
+
...PROPS,
|
|
97
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
|
|
98
|
+
};
|
|
99
|
+
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a different set of trusted DHCP servers is an update', () => {
|
|
103
|
+
const live = liveNetwork({
|
|
104
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.2'] },
|
|
105
|
+
});
|
|
106
|
+
const props: NetworkProps = {
|
|
107
|
+
...PROPS,
|
|
108
|
+
dhcpGuarding: { trustedDhcpServerIpAddresses: ['10.0.0.1', '10.0.0.3'] },
|
|
109
|
+
};
|
|
110
|
+
expect(matches(attributesOf(live, props), props)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('the same IPv6 host subnets and DNS overrides in a different order is a noop', () => {
|
|
114
|
+
const live = liveNetwork({
|
|
115
|
+
ipv6Configuration: {
|
|
116
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
117
|
+
interfaceType: 'ETHERNET',
|
|
118
|
+
additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
|
|
119
|
+
dnsServerIpAddressesOverride: ['2001:4860:4860::8888', '2001:4860:4860::8844'],
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const props: NetworkProps = {
|
|
123
|
+
...PROPS,
|
|
124
|
+
ipv6Configuration: {
|
|
125
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
126
|
+
interfaceType: 'ETHERNET',
|
|
127
|
+
additionalHostIpSubnets: ['2001:db8:1::/64', '2001:db8::/64'],
|
|
128
|
+
dnsServerIpAddressesOverride: ['2001:4860:4860::8844', '2001:4860:4860::8888'],
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
expect(matches(attributesOf(live, props), props)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a different set of IPv6 host subnets is an update', () => {
|
|
135
|
+
const live = liveNetwork({
|
|
136
|
+
ipv6Configuration: {
|
|
137
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
138
|
+
interfaceType: 'ETHERNET',
|
|
139
|
+
additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:1::/64'],
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const props: NetworkProps = {
|
|
143
|
+
...PROPS,
|
|
144
|
+
ipv6Configuration: {
|
|
145
|
+
clientAddressAssignment: { slaacEnabled: true },
|
|
146
|
+
interfaceType: 'ETHERNET',
|
|
147
|
+
additionalHostIpSubnets: ['2001:db8::/64', '2001:db8:2::/64'],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
expect(matches(attributesOf(live, props), props)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a live `null` (not `undefined`) for either field does not throw', () => {
|
|
154
|
+
// UniFi's JSON answers `null` for an unset optional field (see network-form.ts's header) —
|
|
155
|
+
// a reality the SDK's `T | undefined` types don't model. The double cast lands `null` at
|
|
156
|
+
// runtime the same way a real JSON decode would, past what the type system alone allows.
|
|
157
|
+
const live = {
|
|
158
|
+
...liveNetwork(),
|
|
159
|
+
dhcpGuarding: null,
|
|
160
|
+
ipv6Configuration: null,
|
|
161
|
+
} as unknown as networks.NetworkDetails;
|
|
162
|
+
const declared = declareNetwork(live, 'site-1');
|
|
163
|
+
expect(() => attributesOf(live, declared)).not.toThrow();
|
|
164
|
+
expect(matches(attributesOf(live, declared), declared)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('Unifi.Network write paths never reach the vendor API', () => {
|
|
169
|
+
const ops = unifiOperations(spec);
|
|
170
|
+
|
|
171
|
+
test('reconcile on an exact-match adoption (H6) makes no request but the one read', async () => {
|
|
172
|
+
const fake = fakeUnifi((method, url) =>
|
|
173
|
+
method === 'GET' && url.pathname === NETWORK_PATH
|
|
174
|
+
? Response.json(liveNetwork())
|
|
175
|
+
: fakeFailure(400, 'unexpected request'),
|
|
176
|
+
);
|
|
177
|
+
const exit = await Effect.runPromiseExit(
|
|
178
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
179
|
+
);
|
|
180
|
+
expect(exit._tag).toBe('Success');
|
|
181
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('reconcile on drift refuses instead of PUTting a merged body', async () => {
|
|
185
|
+
const fake = fakeUnifi((method, url) =>
|
|
186
|
+
method === 'GET' && url.pathname === NETWORK_PATH
|
|
187
|
+
? Response.json(liveNetwork({ vlanId: 999 }))
|
|
188
|
+
: fakeFailure(400, 'unexpected request'),
|
|
189
|
+
);
|
|
190
|
+
const exit = await Effect.runPromiseExit(
|
|
191
|
+
ops.reconcile(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
192
|
+
);
|
|
193
|
+
expect(exit._tag).toBe('Failure');
|
|
194
|
+
expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('delete always refuses -- no DELETE is ever sent', async () => {
|
|
198
|
+
const fake = fakeUnifi((method, url) =>
|
|
199
|
+
method === 'GET' && url.pathname === NETWORK_PATH
|
|
200
|
+
? Response.json(liveNetwork())
|
|
201
|
+
: fakeFailure(400, 'unexpected request'),
|
|
202
|
+
);
|
|
203
|
+
const exit = await Effect.runPromiseExit(
|
|
204
|
+
ops.destroy(PROPS).pipe(Effect.provide(fakeUnifiLayer(fake.fetch))),
|
|
205
|
+
);
|
|
206
|
+
expect(exit._tag).toBe('Failure');
|
|
207
|
+
expect(fake.seen).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Unifi.Network` — one network on a UniFi Network site, READ-ONLY (`policy.ts`).
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY NETWORK IS IN THE FIRST IMPORT SET. `getNetworkDetails`/`getNetworksOverviewPage`
|
|
5
|
+
* (`../../distilled-unifi-network/src/services/networks.ts`) is a list+get pair over
|
|
6
|
+
* genuine configuration — VLAN, DHCP guarding, IPv4/IPv6 addressing, isolation — not runtime
|
|
7
|
+
* state like a connected client, a device statistic or a hotspot voucher. It is also the SDK
|
|
8
|
+
* package's own README recommendation for a first read/adopt-only resource, alongside `Site`
|
|
9
|
+
* (skipped here — no `getSite`, only the list operation, so it fails this task's own "both a
|
|
10
|
+
* list and a get" bar) and `FirewallZone`/`FirewallPolicy` (`firewall-zone.ts` ships
|
|
11
|
+
* `FirewallZone`; `FirewallPolicy`'s several discriminated filter variants and its
|
|
12
|
+
* list-replacing ordering endpoint are a bigger, separate PR).
|
|
13
|
+
*
|
|
14
|
+
* ⛔ THE WHOLE-OBJECT-PUT TRAP (`docs/unifi-api-notes.md`) NEVER FIRES HERE, BY CONSTRUCTION.
|
|
15
|
+
* `updateNetwork` is a `PUT` that disables `dhcpGuarding`/`ipv4Configuration`/`ipv6Configuration`
|
|
16
|
+
* outright when the caller omits them — the exact failure mode a read-only family cannot cause,
|
|
17
|
+
* because `resource.ts`'s `reconcile` never calls `updateNetwork` at all. Recorded here for
|
|
18
|
+
* whoever adds the write path this docs note anticipates: read-merge-write the WHOLE object,
|
|
19
|
+
* never a props-only body.
|
|
20
|
+
*/
|
|
21
|
+
import { Resource } from 'alchemy';
|
|
22
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
23
|
+
import * as Provider from 'alchemy/Provider';
|
|
24
|
+
import * as networks from '@distilled.cloud/unifi-network/networks';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import {
|
|
27
|
+
type NetworkAttributes,
|
|
28
|
+
type NetworkProps,
|
|
29
|
+
attributesOf,
|
|
30
|
+
matches,
|
|
31
|
+
} from './network-form.ts';
|
|
32
|
+
import type { UnifiRequirements, UnifiSpec } from './resource.ts';
|
|
33
|
+
import { unifiHandlers } from './resource.ts';
|
|
34
|
+
|
|
35
|
+
export type { NetworkAttributes, NetworkProps } from './network-form.ts';
|
|
36
|
+
export { declareNetwork } from './network-form.ts';
|
|
37
|
+
|
|
38
|
+
export interface UnifiNetwork extends Resource<
|
|
39
|
+
'Unifi.Network',
|
|
40
|
+
NetworkProps,
|
|
41
|
+
NetworkAttributes,
|
|
42
|
+
never,
|
|
43
|
+
UnifiRequirements
|
|
44
|
+
> {}
|
|
45
|
+
|
|
46
|
+
export const UnifiNetwork = Resource<UnifiNetwork>('Unifi.Network', {
|
|
47
|
+
defaultRemovalPolicy: 'retain',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `network('lan', props)` — `adopt(true)` piped on by default (H5). This family never writes, so
|
|
52
|
+
* the only thing a first deploy against an existing network could ever do is bind to it or refuse
|
|
53
|
+
* `OwnedBySomeoneElse`; piping `adopt(true)` here picks the former without a stack having to
|
|
54
|
+
* remember `--adopt`, mirroring `discord/application-command.ts`'s `applicationCommand`.
|
|
55
|
+
*/
|
|
56
|
+
export const network = (id: string, props: NetworkProps) =>
|
|
57
|
+
UnifiNetwork(id, props).pipe(adopt(true));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ★ EXPORTED for direct testing against a fake `Credentials` layer (`fake-unifi.ts`), the same
|
|
61
|
+
* seam `netbox/prefix.ts`'s `spec` and `discord/application-command.ts`'s `spec` use — `handlers`
|
|
62
|
+
* bakes in `CredentialsFromEnv`, which resolves environment variables a test cannot repoint.
|
|
63
|
+
*/
|
|
64
|
+
export const spec: UnifiSpec<
|
|
65
|
+
NetworkProps,
|
|
66
|
+
networks.NetworkDetails,
|
|
67
|
+
NetworkAttributes,
|
|
68
|
+
networks.GetNetworkDetailsError
|
|
69
|
+
> = {
|
|
70
|
+
type: 'Unifi.Network',
|
|
71
|
+
describe: (props) => `sites/${props.siteId}/networks/${props.networkId}`,
|
|
72
|
+
fetchLive: (props) =>
|
|
73
|
+
networks
|
|
74
|
+
.getNetworkDetails({ siteId: props.siteId, networkId: props.networkId })
|
|
75
|
+
.pipe(Effect.catchTag('NotFound', () => Effect.succeed(undefined))),
|
|
76
|
+
attributes: attributesOf,
|
|
77
|
+
matches,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const handlers = unifiHandlers(spec);
|
|
81
|
+
|
|
82
|
+
export const UnifiNetworkProvider = () =>
|
|
83
|
+
Provider.effect(UnifiNetwork, Effect.succeed(UnifiNetwork.Provider.of(handlers)));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refusal every `Unifi.*` write handler returns — never an SDK write call.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ TIM'S RULE, 2026-09-24: UniFi (and OPNsense) are READ-ONLY FOR NOW. This family has no
|
|
5
|
+
* `create`, no `update` body and no delete call anywhere in its source — `reconcile` and
|
|
6
|
+
* `delete` both fail with this typed error instead, and `resource.test.ts` proves the point
|
|
7
|
+
* with a fake `HttpClient` that fails the test on any request that is not a `GET`. Lifting the
|
|
8
|
+
* policy is a kit change (a new PR that adds a write path, reviewed as one), not a flag a stack
|
|
9
|
+
* can pass.
|
|
10
|
+
*
|
|
11
|
+
* ★ A TYPED TAG, NOT A THROWN STRING (S21). `Effect.catchTag('UnifiWriteRefused', …)` lets a
|
|
12
|
+
* caller (a future import script, a test) branch on the refusal without parsing a message.
|
|
13
|
+
*/
|
|
14
|
+
import * as Data from 'effect/Data';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
|
|
17
|
+
export const UNIFI_READ_ONLY_POLICY =
|
|
18
|
+
"read-only by Tim's rule, 2026-09-24; lifting it is a kit change";
|
|
19
|
+
|
|
20
|
+
/** What kind of write was asked for and refused — for the message only, never acted on. */
|
|
21
|
+
export type UnifiWriteAction = 'create' | 'update' | 'delete';
|
|
22
|
+
|
|
23
|
+
export class UnifiWriteRefused extends Data.TaggedError('UnifiWriteRefused')<{
|
|
24
|
+
/** The vendor type string, e.g. `Unifi.Network`. */
|
|
25
|
+
readonly type: string;
|
|
26
|
+
/** `spec.describe(props)` — the object the write would have touched. */
|
|
27
|
+
readonly identity: string;
|
|
28
|
+
readonly action: UnifiWriteAction;
|
|
29
|
+
}> {
|
|
30
|
+
override get message(): string {
|
|
31
|
+
return `${this.type} ${this.identity}: refusing to ${this.action} -- ${UNIFI_READ_ONLY_POLICY}.`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The only thing `reconcile`/`delete` ever return for a write this family cannot make. */
|
|
36
|
+
export const refuseWrite = (type: string, identity: string, action: UnifiWriteAction) =>
|
|
37
|
+
Effect.fail(new UnifiWriteRefused({ type, identity, action }));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `Unifi.*` provider as one layer for a stack.
|
|
3
|
+
*
|
|
4
|
+
* Layer.mergeAll(unifiProviders(), …the stack's other providers)
|
|
5
|
+
* .pipe(Layer.provide(FetchHttpClient.layer))
|
|
6
|
+
*
|
|
7
|
+
* S16: a subpath's `providers()` plays the role upstream's own `Providers.ts` does — one minimal
|
|
8
|
+
* insertion per family. `FetchHttpClient.layer` is the stack's own responsibility to provide,
|
|
9
|
+
* mirroring `../discord/providers.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import { UnifiFirewallZoneProvider } from './firewall-zone.ts';
|
|
13
|
+
import { UnifiNetworkProvider } from './network.ts';
|
|
14
|
+
|
|
15
|
+
export const providers = () => Layer.mergeAll(UnifiNetworkProvider(), UnifiFirewallZoneProvider());
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unifiOperations` in isolation, against a synthetic spec — no real UniFi SDK, no HTTP. Proves
|
|
3
|
+
* the read-only engine's own contract: `Unowned` on a cold read (H1), `noop`/`update` diff,
|
|
4
|
+
* `reconcile` succeeding with ZERO write calls on an exact match (the H6 forced post-adoption
|
|
5
|
+
* call this engine has to survive without writing), and a typed `UnifiWriteRefused` — never a
|
|
6
|
+
* silent success, never a raw SDK write — every other time `reconcile` or `delete` runs.
|
|
7
|
+
*
|
|
8
|
+
* `network.test.ts`/`firewall-zone.test.ts` prove the same contract again against the REAL SDK
|
|
9
|
+
* and a fake HTTP server, and additionally prove that no non-`GET` request is ever sent.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
13
|
+
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import { fakeUnifiLayer } from './fake-unifi.ts';
|
|
16
|
+
import { UnifiWriteRefused } from './policy.ts';
|
|
17
|
+
import { type UnifiSpec, unifiOperations } from './resource.ts';
|
|
18
|
+
|
|
19
|
+
interface WidgetProps {
|
|
20
|
+
id: string;
|
|
21
|
+
value: number;
|
|
22
|
+
}
|
|
23
|
+
type WidgetLive = WidgetProps;
|
|
24
|
+
type WidgetAttrs = WidgetProps;
|
|
25
|
+
|
|
26
|
+
/** `live: undefined` models a declared object nothing on the site matches. */
|
|
27
|
+
const specFor = (
|
|
28
|
+
live: WidgetLive | undefined,
|
|
29
|
+
): UnifiSpec<WidgetProps, WidgetLive, WidgetAttrs, never> => ({
|
|
30
|
+
type: 'Test.Widget',
|
|
31
|
+
describe: (props) => `widgets/${props.id}`,
|
|
32
|
+
fetchLive: () => Effect.succeed(live),
|
|
33
|
+
attributes: (l) => l,
|
|
34
|
+
matches: (attrs, props) => attrs.value === props.value,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const MATCH: WidgetProps = { id: 'w1', value: 1 };
|
|
38
|
+
const DRIFTED_LIVE: WidgetLive = { id: 'w1', value: 99 };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `UnifiSpec`'s `fetchLive` is typed against the real `UnifiNetworkOpContext` (`resource.ts`'s
|
|
42
|
+
* own header explains why the engine is not generic over it) even though `specFor` above never
|
|
43
|
+
* touches HTTP — so every run here still needs A layer satisfying that context. This one fails
|
|
44
|
+
* the test outright if anything ever calls it, which is itself part of the write-refusal proof:
|
|
45
|
+
* the synthetic spec has no operation that could, but if one ever did, this is not a silent pass.
|
|
46
|
+
*/
|
|
47
|
+
const unreachableFetch = (() => {
|
|
48
|
+
throw new Error('resource.test.ts: unexpected HTTP call from a spec with no HTTP operations');
|
|
49
|
+
}) as unknown as typeof globalThis.fetch;
|
|
50
|
+
|
|
51
|
+
const run = <A, E>(effect: Effect.Effect<A, E, UnifiNetworkOpContext>) =>
|
|
52
|
+
Effect.runPromise(effect.pipe(Effect.provide(fakeUnifiLayer(unreachableFetch))));
|
|
53
|
+
|
|
54
|
+
const refusal = async (
|
|
55
|
+
effect: Effect.Effect<unknown, UnifiWriteRefused, UnifiNetworkOpContext>,
|
|
56
|
+
) => {
|
|
57
|
+
const failure = await run(Effect.flip(effect));
|
|
58
|
+
expect(failure).toBeInstanceOf(UnifiWriteRefused);
|
|
59
|
+
return failure;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('unifiOperations.readHandler', () => {
|
|
63
|
+
test('cold read (no persisted output) of a match answers Unowned', async () => {
|
|
64
|
+
const attrs = await run(
|
|
65
|
+
unifiOperations(specFor(MATCH)).readHandler({ olds: MATCH, output: undefined }),
|
|
66
|
+
);
|
|
67
|
+
expect(Unowned.is(attrs)).toBe(true);
|
|
68
|
+
expect(attrs).toEqual(MATCH);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('warm read (persisted output says ours already) answers plain attributes', async () => {
|
|
72
|
+
const attrs = await run(
|
|
73
|
+
unifiOperations(specFor(MATCH)).readHandler({ olds: MATCH, output: MATCH }),
|
|
74
|
+
);
|
|
75
|
+
expect(Unowned.is(attrs)).toBe(false);
|
|
76
|
+
expect(attrs).toEqual(MATCH);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a genuinely absent object answers undefined either way', async () => {
|
|
80
|
+
const attrs = await run(
|
|
81
|
+
unifiOperations(specFor(undefined)).readHandler({ olds: MATCH, output: MATCH }),
|
|
82
|
+
);
|
|
83
|
+
expect(attrs).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('unifiOperations.diff', () => {
|
|
88
|
+
test('no persisted output defers to the engine default (undefined)', async () => {
|
|
89
|
+
const diff = await run(unifiOperations(specFor(MATCH)).diff(MATCH, undefined));
|
|
90
|
+
expect(diff).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('declared props match live -- noop', async () => {
|
|
94
|
+
const diff = await run(unifiOperations(specFor(MATCH)).diff(MATCH, MATCH));
|
|
95
|
+
expect(diff).toEqual({ action: 'noop' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('live object drifted from the declaration -- update, never silently absorbed', async () => {
|
|
99
|
+
const diff = await run(unifiOperations(specFor(DRIFTED_LIVE)).diff(MATCH, MATCH));
|
|
100
|
+
expect(diff).toEqual({ action: 'update' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('live object vanished since it was adopted -- update, never a quiet noop', async () => {
|
|
104
|
+
const diff = await run(unifiOperations(specFor(undefined)).diff(MATCH, MATCH));
|
|
105
|
+
expect(diff).toEqual({ action: 'update' });
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('unifiOperations.reconcile', () => {
|
|
110
|
+
test('exact match: zero write calls (the spec has none), returns live attributes -- H6', async () => {
|
|
111
|
+
const attrs = await run(unifiOperations(specFor(MATCH)).reconcile(MATCH));
|
|
112
|
+
expect(attrs).toEqual(MATCH);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('missing live object refuses "create", never fabricates one', async () => {
|
|
116
|
+
const failure = await refusal(unifiOperations(specFor(undefined)).reconcile(MATCH));
|
|
117
|
+
expect(failure.action).toBe('create');
|
|
118
|
+
expect(failure.type).toBe('Test.Widget');
|
|
119
|
+
expect(failure.identity).toBe('widgets/w1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('drifted live object refuses "update", never converges by writing', async () => {
|
|
123
|
+
const failure = await refusal(unifiOperations(specFor(DRIFTED_LIVE)).reconcile(MATCH));
|
|
124
|
+
expect(failure.action).toBe('update');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('unifiOperations.destroy', () => {
|
|
129
|
+
test('always refuses "delete" -- the spec has no destroy operation at all', async () => {
|
|
130
|
+
const failure = await refusal(unifiOperations(specFor(MATCH)).destroy(MATCH));
|
|
131
|
+
expect(failure.action).toBe('delete');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shape for every UniFi Network read-only object — `Unifi.Network`, `Unifi.FirewallZone`,
|
|
3
|
+
* and whatever import adds next.
|
|
4
|
+
*
|
|
5
|
+
* ★ MIRRORS `../discord/resource.ts` (S7-S10, H1, H5), NOT `../netbox/resource.ts`. Both are
|
|
6
|
+
* "marker-less API" families (S8): UniFi Network objects carry no tag or metadata field this
|
|
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.79#read`, the reference implementation S8 and H1
|
|
9
|
+
* both cite. `adopt(true)`, piped on by each resource's convenience constructor (H5), turns
|
|
10
|
+
* that into a silent one-time bind instead of an `OwnedBySomeoneElse` refusal.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ THE DIFFERENCE FROM EVERY OTHER FAMILY HERE: THERE IS NO `create`, `update` OR `destroy` IN
|
|
13
|
+
* THIS SPEC AT ALL. Tim's rule, 2026-09-24 (`policy.ts`): UniFi and OPNsense are read-only. So
|
|
14
|
+
* `reconcile` below can only ever refuse or report an exact match — it has no write path to
|
|
15
|
+
* fall into by accident, and no resource file wired through this engine can reach one either.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ RECONCILE STILL HAS TO SUCCEED ON AN EXACT-MATCH ADOPTION, NOT JUST REFUSE UNCONDITIONALLY.
|
|
18
|
+
* Measured against `Plan.ts@v2.0.0-beta.79` (`forceUpdateAfterAdoption`, H6): beta.79 forces
|
|
19
|
+
* ONE reconcile call after every cold adoption, whether or not the diff said noop — so a
|
|
20
|
+
* resource whose `reconcile` always failed would break the ordinary "adopt an unchanged
|
|
21
|
+
* object" deploy, not just a real write. The fix, exactly like `discordOperations.reconcile`:
|
|
22
|
+
* read the live object again, and refuse ONLY when it is missing (would need a create) or
|
|
23
|
+
* drifted (would need a write to converge). An exact match returns the live attributes and
|
|
24
|
+
* calls nothing — S10's "zero write calls when there is no drift" holds even under the forced
|
|
25
|
+
* post-adoption call.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ `NotFound` IS FOLDED TO "ABSENT" INSIDE EACH RESOURCE FILE'S OWN `fetchLive`, NOT HERE —
|
|
28
|
+
* same split netbox's `resource.ts` uses and for the same reason: `Effect.catchTag`'s
|
|
29
|
+
* tag-literal inference needs a concrete error union to resolve `"NotFound"` against, and a
|
|
30
|
+
* spec's `E` is only concrete at each resource file's own call site. Every OTHER failure (a
|
|
31
|
+
* real 5xx, a network error, `Forbidden`) is NOT folded anywhere — it fails the plan loudly,
|
|
32
|
+
* per the task's own rule: only a genuine not-found may mean absent.
|
|
33
|
+
*/
|
|
34
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
35
|
+
import { isResolved } from 'alchemy/Diff';
|
|
36
|
+
import type { Input } from 'alchemy/Input';
|
|
37
|
+
import { CredentialsFromEnv } from '@distilled.cloud/unifi-network/Credentials';
|
|
38
|
+
import type { UnifiNetworkOpContext } from '@distilled.cloud/unifi-network/Protocol';
|
|
39
|
+
import * as Effect from 'effect/Effect';
|
|
40
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
41
|
+
import { refuseWrite } from './policy.ts';
|
|
42
|
+
|
|
43
|
+
/** What every handler needs from the caller's runtime once `CredentialsFromEnv` is baked in. */
|
|
44
|
+
export type UnifiRequirements = HttpClient.HttpClient;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One UniFi Network object's read. `Live` is whatever the SDK decodes (a `NetworkDetails`, a
|
|
48
|
+
* `FirewallZone`, …); `E` is left to each resource file to declare (its own `fetchLive`'s error
|
|
49
|
+
* union, with `NotFound` already folded away) so the precise per-operation error union flows
|
|
50
|
+
* through instead of being widened by hand here.
|
|
51
|
+
*/
|
|
52
|
+
export type UnifiSpec<Props extends object, Live, Attributes extends object, E> = {
|
|
53
|
+
/** The vendor type string, e.g. `Unifi.Network` — for the refusal message only. */
|
|
54
|
+
readonly type: string;
|
|
55
|
+
/** Already folds a genuine not-found to `undefined`; nothing else is folded (see header). */
|
|
56
|
+
readonly fetchLive: (props: Props) => Effect.Effect<Live | undefined, E, UnifiNetworkOpContext>;
|
|
57
|
+
readonly attributes: (live: Live, props: Props) => Attributes;
|
|
58
|
+
readonly matches: (attributes: Attributes, props: Props) => boolean;
|
|
59
|
+
/** For the refusal message and read-back failures. */
|
|
60
|
+
readonly describe: (props: Props) => string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const unifiOperations = <Props extends object, Live, Attributes extends object, E>(
|
|
64
|
+
spec: UnifiSpec<Props, Live, Attributes, E>,
|
|
65
|
+
) => {
|
|
66
|
+
const read = (props: Props) =>
|
|
67
|
+
spec
|
|
68
|
+
.fetchLive(props)
|
|
69
|
+
.pipe(Effect.map((live) => (live === undefined ? undefined : spec.attributes(live, props))));
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
read,
|
|
73
|
+
|
|
74
|
+
readHandler: ({ olds, output }: { olds: Props; output: Attributes | undefined }) =>
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const attrs = yield* read(olds);
|
|
77
|
+
if (attrs === undefined) return undefined;
|
|
78
|
+
// Warm read (persisted `output` says a prior apply already adopted this exact object):
|
|
79
|
+
// plain attributes. Cold read (fresh store, or state lost): no ownership marker on this
|
|
80
|
+
// vendor exists to tell "ours" from "someone else's" apart, so Unowned until adopt(true).
|
|
81
|
+
return output !== undefined ? attrs : Unowned(attrs);
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
diff: (news: Input<Props>, output: Attributes | undefined) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
87
|
+
const live = yield* read(news);
|
|
88
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
89
|
+
return spec.matches(live, news)
|
|
90
|
+
? ({ action: 'noop' } as const)
|
|
91
|
+
: ({ action: 'update' } as const);
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
reconcile: (news: Props) =>
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const live = yield* read(news);
|
|
97
|
+
if (live === undefined) return yield* refuseWrite(spec.type, spec.describe(news), 'create');
|
|
98
|
+
if (!spec.matches(live, news)) {
|
|
99
|
+
return yield* refuseWrite(spec.type, spec.describe(news), 'update');
|
|
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;
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
destroy: (olds: Props) => refuseWrite(spec.type, spec.describe(olds), 'delete'),
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The four provider handlers for a spec'd read-only UniFi object, wired once.
|
|
112
|
+
*
|
|
113
|
+
* ⛔ `list` ANSWERS EMPTY. `GET /v1/sites/{siteId}/networks` answers every network on the site;
|
|
114
|
+
* adoption stays explicit, the same reasoning `Proxmox.User`'s and NetBox's `list` give.
|
|
115
|
+
*/
|
|
116
|
+
export const unifiHandlers = <Props extends object, Live, Attributes extends object, E>(
|
|
117
|
+
spec: UnifiSpec<Props, Live, Attributes, E>,
|
|
118
|
+
) => {
|
|
119
|
+
const ops = unifiOperations(spec);
|
|
120
|
+
const withCredentials = <A, Err>(effect: Effect.Effect<A, Err, UnifiNetworkOpContext>) =>
|
|
121
|
+
Effect.provide(effect, CredentialsFromEnv);
|
|
122
|
+
return {
|
|
123
|
+
list: () => Effect.succeed([]),
|
|
124
|
+
read: (args: { olds: Props; output: Attributes | undefined }) =>
|
|
125
|
+
withCredentials(ops.readHandler(args)),
|
|
126
|
+
diff: (args: { news: Input<Props>; output: Attributes | undefined }) =>
|
|
127
|
+
withCredentials(ops.diff(args.news, args.output)),
|
|
128
|
+
reconcile: (args: { news: Props }) => withCredentials(ops.reconcile(args.news)),
|
|
129
|
+
delete: (args: { olds: Props }) => withCredentials(ops.destroy(args.olds)),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
@@ -125,11 +125,24 @@ export type Body = Effect.Effect<unknown, unknown, unknown>;
|
|
|
125
125
|
type Compiled = { readonly name: string; readonly stage: string; readonly services: never };
|
|
126
126
|
type PlanView = { readonly resources: Readonly<Record<string, { readonly action: string }>> };
|
|
127
127
|
|
|
128
|
+
/** One row of `Alchemy.Drift.detect`'s dry-run report — the fields `drift` below asserts on. */
|
|
129
|
+
export type DriftRow = { readonly action: string };
|
|
130
|
+
|
|
128
131
|
export interface FakeEngine {
|
|
129
132
|
/** Plan and apply `body`, as `alchemy deploy --adopt` would; resolves to the planned actions. */
|
|
130
133
|
readonly deploy: (body: Body) => Promise<Record<string, string>>;
|
|
131
134
|
/** Verify `body` against what the earlier deploys recorded. */
|
|
132
135
|
readonly verify: (body: Body, options?: VerifyOptions) => Promise<AdoptReport>;
|
|
136
|
+
/**
|
|
137
|
+
* `alchemy drift`'s own dry-run (`Alchemy.Drift.detect`) over whatever `deploy` already
|
|
138
|
+
* persisted — `body` is used only to resolve the stack's name/stage/providers, exactly as
|
|
139
|
+
* `verify` does; drift itself reads the state store, not `body`'s declarations. ⚠️ A DIFFERENT
|
|
140
|
+
* ENGINE PATH FROM `verify`'s `--all` READ, proxmox/read-failure-propagation.test.ts's own ⛔:
|
|
141
|
+
* `Drift.ts` calls a provider's `read` with `output` set to the PERSISTED attributes, and folds
|
|
142
|
+
* a `read` that resolves `undefined` into `{action: 'missing'}` with no error — silent, unlike
|
|
143
|
+
* `verify.ts`'s `readWithState`, which already wraps that same call in `Effect.catchCause`.
|
|
144
|
+
*/
|
|
145
|
+
readonly drift: (body: Body) => Promise<Record<string, DriftRow>>;
|
|
133
146
|
/**
|
|
134
147
|
* Every state row the store holds, as JSON — what a Postgres store would persist. ★ For tests
|
|
135
148
|
* that must prove a value NEVER reaches state (proxmox/pbs-notification-target-state.test.ts).
|
|
@@ -152,6 +165,10 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
|
|
|
152
165
|
) => Effect.Effect<Compiled, unknown, never>;
|
|
153
166
|
const plan = Alchemy.Plan.make as unknown as (c: Compiled) => Effect.Effect<PlanView, unknown>;
|
|
154
167
|
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
168
|
+
const detectDrift = Alchemy.Drift.detect as unknown as (stack: {
|
|
169
|
+
name: string;
|
|
170
|
+
stage: string;
|
|
171
|
+
}) => Effect.Effect<{ resources: Record<string, DriftRow> }, unknown>;
|
|
155
172
|
|
|
156
173
|
const run = <A>(body: Body, next: (compiled: Compiled) => Effect.Effect<A, unknown, unknown>) =>
|
|
157
174
|
Effect.runPromise(
|
|
@@ -182,6 +199,12 @@ export const engineOver = <ROut, E, RIn>(layer: Layer.Layer<ROut, E, RIn>): Fake
|
|
|
182
199
|
run(body, (compiled) =>
|
|
183
200
|
verifySession({ context: compiled.services, stack: compiled as never }, options),
|
|
184
201
|
),
|
|
202
|
+
drift: (body) =>
|
|
203
|
+
run(body, (compiled) =>
|
|
204
|
+
detectDrift({ name: compiled.name, stage: compiled.stage }).pipe(
|
|
205
|
+
Effect.map((detected) => detected.resources),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
185
208
|
};
|
|
186
209
|
};
|
|
187
210
|
|