@alchemy-unifi/network 0.0.1 → 0.1.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/src/Lookup.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { UserFacingError } from "alchemy/UserFacingError";
2
+ import * as Data from "effect/Data";
3
+ import type { Site } from "./Site.ts";
4
+
5
+ /** A `lookup` found nothing matching its query. */
6
+ export class LookupNotFound extends Data.TaggedError("LookupNotFound")<{
7
+ readonly resource: string;
8
+ readonly query: string;
9
+ readonly site: Site;
10
+ }> {
11
+ readonly [UserFacingError]: true = true;
12
+ override get message() {
13
+ return `No ${this.resource} matches ${this.query} in site ${this.site.internalReference} (${this.site.name}).`;
14
+ }
15
+ }
16
+
17
+ /** A `lookup` matched several objects, e.g. networks sharing a name. */
18
+ export class LookupAmbiguous extends Data.TaggedError("LookupAmbiguous")<{
19
+ readonly resource: string;
20
+ readonly query: string;
21
+ readonly site: Site;
22
+ readonly count: number;
23
+ }> {
24
+ readonly [UserFacingError]: true = true;
25
+ override get message() {
26
+ return `${this.count} ${this.resource}s match ${this.query} in site ${this.site.internalReference} (${this.site.name}). Use a more specific query.`;
27
+ }
28
+ }
29
+
30
+ export const describeQuery = (query: object) =>
31
+ Object.entries(query)
32
+ .filter(([key, value]) => key !== "site" && value !== undefined)
33
+ .map(([key, value]) => `${key} ${JSON.stringify(value)}`)
34
+ .join(" and ");
package/src/Network.ts ADDED
@@ -0,0 +1,335 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import type { Diff } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import { UserFacingError } from "alchemy/UserFacingError";
7
+ import * as Data from "effect/Data";
8
+ import * as Effect from "effect/Effect";
9
+ import { withConnection } from "./Connection.ts";
10
+ import { type FirewallZoneReference, resolveFirewallZone } from "./FirewallZone.ts";
11
+ import { type DistributiveOmit, isCurrent, omitKeys } from "./internal/Fields.ts";
12
+ import { loadNetworks } from "./internal/Networks.ts";
13
+ import { planned, plannedString, UNKNOWN } from "./internal/Plan.ts";
14
+ import { captureServices } from "./internal/Services.ts";
15
+ import { describeQuery, LookupAmbiguous, LookupNotFound } from "./Lookup.ts";
16
+ import type { Providers } from "./Providers.ts";
17
+ import * as ReadCache from "./ReadCache.ts";
18
+ import { checkSameSite, resolveSite, type Site } from "./Site.ts";
19
+
20
+ interface SiteProp {
21
+ /** Site to create the network in. @default the profile's site, else the console's only site */
22
+ site?: string;
23
+ }
24
+
25
+ /** A device, e.g. the switch routing a switch-managed network. */
26
+ export interface DeviceReference {
27
+ readonly deviceId: string;
28
+ }
29
+
30
+ /** A network routed by the gateway: the usual kind, with DHCP, zones and internet access. */
31
+ export interface GatewayNetworkProps
32
+ extends Omit<Sdk.IntegrationGatewayManagedNetworkCreateUpdateDto, "zoneId">, SiteProp {
33
+ /**
34
+ * The network's firewall zone, e.g. `FirewallZone.INTERNAL` or a
35
+ * `FirewallZone` resource. Required once the site uses the zone-based
36
+ * firewall.
37
+ */
38
+ zone?: FirewallZoneReference;
39
+ }
40
+
41
+ /** A network routed by a layer-3 switch. */
42
+ export interface SwitchNetworkProps
43
+ extends Omit<Sdk.IntegrationSwitchManagedNetworkCreateUpdateDto, "deviceId">, SiteProp {
44
+ /** The switch routing the network. */
45
+ device: DeviceReference;
46
+ }
47
+
48
+ /** A VLAN that UniFi carries but doesn't route; something else on it does. */
49
+ export interface UnmanagedNetworkProps extends Sdk.IntegrationUnmanagedNetworkCreateUpdateDto, SiteProp {}
50
+
51
+ /** Props are the Integration API's network body, plus `site` and references; `management` picks the kind. */
52
+ export type NetworkProps = GatewayNetworkProps | SwitchNetworkProps | UnmanagedNetworkProps;
53
+
54
+ /** A network: a `Network` resource, a {@link Network.lookup} result, or `{ networkId }`. */
55
+ export interface NetworkReference {
56
+ readonly networkId: string;
57
+ readonly siteId?: string | undefined;
58
+ /** Set by resources and lookups. Some objects must reference the default network differently. */
59
+ readonly default?: boolean | undefined;
60
+ }
61
+
62
+ /** A change that would replace the site's default network, which can't be deleted. */
63
+ export class DefaultNetworkReplacement extends Data.TaggedError("DefaultNetworkReplacement")<{
64
+ readonly networkName: string;
65
+ readonly reason: string;
66
+ }> {
67
+ readonly [UserFacingError]: true = true;
68
+ override get message() {
69
+ return `${this.networkName} is the site's default network, which can't be replaced (${this.reason}). It must stay gateway-managed in its site.`;
70
+ }
71
+ }
72
+
73
+ export interface NetworkAttributes {
74
+ siteId: string;
75
+ networkId: string;
76
+ name: string;
77
+ vlanId: number;
78
+ management: NetworkProps["management"];
79
+ /** The network's firewall zone (gateway networks). */
80
+ zoneId: string | undefined;
81
+ /** Whether this is the site's default network, which is never deleted. */
82
+ default: boolean;
83
+ /** The whole network as the console reports it, so `alchemy drift` sees any change. */
84
+ config: DistributiveOmit<Sdk.NetworkDetails, "id" | "metadata" | "default">;
85
+ }
86
+
87
+ export interface Network extends Resource<"UnifiNetwork.Network", NetworkProps, NetworkAttributes, never, Providers> {}
88
+
89
+ const networksKey = ReadCache.key<ReadonlyArray<Sdk.NetworkOverview>, Effect.Error<ReturnType<typeof loadNetworks>>>(
90
+ "networks",
91
+ );
92
+
93
+ /** A site's networks, listed once per run. */
94
+ export const listNetworks = (siteId: string) => ReadCache.cached(networksKey, siteId, loadNetworks(siteId));
95
+
96
+ const toAttributes = (siteId: string, network: Sdk.NetworkDetails): NetworkAttributes => {
97
+ const { id, metadata: _metadata, default: isDefault, ...config } = network;
98
+ return {
99
+ siteId,
100
+ networkId: id,
101
+ name: network.name,
102
+ vlanId: network.vlanId,
103
+ management: network.management,
104
+ zoneId: "zoneId" in network ? network.zoneId : undefined,
105
+ default: isDefault,
106
+ config,
107
+ };
108
+ };
109
+
110
+ /** Fields the console reports but rejects in request bodies. */
111
+ const READ_ONLY = ["id", "metadata", "default"];
112
+
113
+ /**
114
+ * `body` with explicit values for the two unset fields that can't simply be
115
+ * left out (both verified):
116
+ * - `mdnsForwardingEnabled`: a `PUT` without it keeps the console's value
117
+ * instead of resetting it, so the default (`true`, what the console sets on
118
+ * create) is always sent;
119
+ * - a DHCP server's `domainName`: the console reports `""` when it's unset,
120
+ * so without the default the body would never match and every deploy
121
+ * would update it (and drift would report it) forever.
122
+ */
123
+ const withDefaults = (body: Sdk.CreateOrUpdateNetwork): Sdk.CreateOrUpdateNetwork => {
124
+ if (body.management !== "GATEWAY") return body;
125
+ const dhcp = body.ipv4Configuration.dhcpConfiguration;
126
+ return {
127
+ ...body,
128
+ mdnsForwardingEnabled: body.mdnsForwardingEnabled ?? true,
129
+ ipv4Configuration: {
130
+ ...body.ipv4Configuration,
131
+ ...(dhcp?.mode === "SERVER" ? { dhcpConfiguration: { ...dhcp, domainName: dhcp.domainName ?? "" } } : {}),
132
+ },
133
+ };
134
+ };
135
+
136
+ /** The request body for `props`, with references resolved to IDs. */
137
+ const toBody = Effect.fn(function* (props: NetworkProps, site: Site) {
138
+ switch (props.management) {
139
+ case "GATEWAY": {
140
+ const { site: _site, zone, ...rest } = props;
141
+ if (zone === undefined) return rest satisfies Sdk.CreateOrUpdateNetwork;
142
+ const zoneId = yield* resolveFirewallZone(zone, site);
143
+ return { ...rest, zoneId } satisfies Sdk.CreateOrUpdateNetwork;
144
+ }
145
+ case "SWITCH": {
146
+ const { site: _site, device, ...rest } = props;
147
+ return { ...rest, deviceId: device.deviceId } satisfies Sdk.CreateOrUpdateNetwork;
148
+ }
149
+ case "UNMANAGED": {
150
+ const { site: _site, ...rest } = props;
151
+ return rest satisfies Sdk.CreateOrUpdateNetwork;
152
+ }
153
+ }
154
+ });
155
+
156
+ /** The network ID a reference means in `site`. */
157
+ export const resolveNetwork = (reference: NetworkReference, site: Site) =>
158
+ checkSameSite("network", reference.siteId, site).pipe(Effect.as(reference.networkId));
159
+
160
+ const getNetwork = (siteId: string, networkId: string) =>
161
+ withConnection(Sdk.getNetworkDetails({ siteId, networkId })).pipe(
162
+ Effect.catchTag("NotFound", () => Effect.undefined),
163
+ );
164
+
165
+ /** The attributes of a network known to exist (e.g. from a list). */
166
+ const attributesOf = (siteId: string, networkId: string) =>
167
+ withConnection(Sdk.getNetworkDetails({ siteId, networkId })).pipe(
168
+ Effect.map((network) => toAttributes(siteId, network)),
169
+ );
170
+
171
+ const isUserDefined = (network: Sdk.NetworkOverview) => network.metadata.origin === "USER_DEFINED";
172
+
173
+ /**
174
+ * A network (VLAN). `management` picks the kind: `"GATEWAY"` (routed by the
175
+ * gateway), `"SWITCH"` (routed by a layer-3 switch) or `"UNMANAGED"`.
176
+ * Changing it, or the site, replaces the network: the old one is deleted
177
+ * first, since VLAN IDs must be unique.
178
+ *
179
+ * Props are authoritative: every update sends the whole network, with
180
+ * explicit defaults for unset fields (`mdnsForwardingEnabled: true`, and a
181
+ * DHCP server's `domainName: ""`), so changes made in the UI are put back.
182
+ * The `config` attribute mirrors the console's object, so `alchemy drift`
183
+ * sees them.
184
+ * Existing networks are adopted by VLAN ID
185
+ * with `--adopt` (and renamed to match), including the site's default
186
+ * network (VLAN 1), which is never deleted or replaced.
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * import * as UnifiNetwork from "@alchemy-unifi/network";
191
+ * import * as Alchemy from "alchemy";
192
+ * import * as Effect from "effect/Effect";
193
+ *
194
+ * export default Alchemy.Stack(
195
+ * "HomeNetwork",
196
+ * { providers: UnifiNetwork.providers(), state: UnifiNetwork.state() },
197
+ * Effect.gen(function* () {
198
+ * yield* UnifiNetwork.Network("guest", {
199
+ * management: "GATEWAY",
200
+ * name: "Guest",
201
+ * vlanId: 12,
202
+ * enabled: true,
203
+ * cellularBackupEnabled: false,
204
+ * internetAccessEnabled: true,
205
+ * isolationEnabled: true,
206
+ * zone: UnifiNetwork.FirewallZone.HOTSPOT,
207
+ * ipv4Configuration: { autoScaleEnabled: false, hostIpAddress: "192.168.12.1", prefixLength: 24 },
208
+ * });
209
+ * }),
210
+ * );
211
+ * ```
212
+ */
213
+ export const Network = Resource<Network>("UnifiNetwork.Network")({
214
+ /**
215
+ * A network in the site by name or VLAN ID. Dies with a user-facing
216
+ * `LookupNotFound` or `LookupAmbiguous`: stack bodies can't fail with typed
217
+ * errors.
218
+ */
219
+ lookup: Effect.fn("UnifiNetwork.Network.lookup")(function* (
220
+ query: ({ readonly name: string } | { readonly vlanId: number }) & { readonly site?: string },
221
+ ) {
222
+ const site = yield* resolveSite(query.site);
223
+ const networks = (yield* listNetworks(site.id)).filter((n) =>
224
+ "vlanId" in query ? n.vlanId === query.vlanId : n.name === query.name,
225
+ );
226
+ const [network] = networks;
227
+ if (network === undefined) {
228
+ return yield* new LookupNotFound({ resource: "network", query: describeQuery(query), site });
229
+ }
230
+ if (networks.length > 1) {
231
+ return yield* new LookupAmbiguous({
232
+ resource: "network",
233
+ query: describeQuery(query),
234
+ site,
235
+ count: networks.length,
236
+ });
237
+ }
238
+ return yield* attributesOf(site.id, network.id);
239
+ }, Effect.orDie),
240
+ });
241
+
242
+ export const NetworkProvider = () =>
243
+ Provider.effect(
244
+ Network,
245
+ Effect.gen(function* () {
246
+ const run = yield* captureServices;
247
+
248
+ return Network.Provider.of({
249
+ list: () =>
250
+ run(
251
+ Effect.gen(function* () {
252
+ const site = yield* resolveSite();
253
+ const networks = yield* listNetworks(site.id);
254
+ return yield* Effect.forEach(networks.filter(isUserDefined), (network) =>
255
+ attributesOf(site.id, network.id),
256
+ );
257
+ }),
258
+ ),
259
+
260
+ read: ({ olds, output }) =>
261
+ run(
262
+ Effect.gen(function* () {
263
+ if (output !== undefined) {
264
+ const network = yield* getNetwork(output.siteId, output.networkId);
265
+ return network === undefined ? undefined : toAttributes(output.siteId, network);
266
+ }
267
+ const site = yield* resolveSite(olds.site);
268
+ // VLAN IDs are unique per site, so the VLAN alone identifies the network, and adopting renames it.
269
+ const network = (yield* listNetworks(site.id)).find((n) => n.vlanId === olds.vlanId);
270
+ return network === undefined
271
+ ? undefined
272
+ : Unowned(yield* attributesOf(site.id, network.id));
273
+ }),
274
+ ),
275
+
276
+ diff: ({ news, output }) =>
277
+ run(
278
+ Effect.gen(function* () {
279
+ if (output === undefined) return undefined;
280
+ const replace = (reason: string) =>
281
+ output.default
282
+ ? Effect.fail(new DefaultNetworkReplacement({ networkName: output.name, reason }))
283
+ : Effect.succeed({ action: "replace", deleteFirst: true } satisfies Diff);
284
+ const management = planned(news, "management");
285
+ if (management !== UNKNOWN && management !== output.management) {
286
+ return yield* replace(`its management would change to ${String(management)}`);
287
+ }
288
+ const siteProp = plannedString(news, "site");
289
+ if (siteProp === UNKNOWN) return undefined;
290
+ const site = yield* resolveSite(siteProp);
291
+ if (site.id !== output.siteId)
292
+ return yield* replace(`it would move to site ${site.internalReference}`);
293
+ return undefined;
294
+ }),
295
+ ),
296
+
297
+ reconcile: ({ news, output }) =>
298
+ run(
299
+ Effect.gen(function* () {
300
+ const site = yield* resolveSite(news.site);
301
+ const body = withDefaults(yield* toBody(news, site));
302
+ const observed =
303
+ output !== undefined && output.siteId === site.id
304
+ ? yield* getNetwork(site.id, output.networkId)
305
+ : undefined;
306
+ const write = ReadCache.invalidating(networksKey, site.id);
307
+ if (observed === undefined) {
308
+ const created = yield* withConnection(
309
+ Sdk.createNetwork({ siteId: site.id, body }),
310
+ ).pipe(write);
311
+ return toAttributes(site.id, created);
312
+ }
313
+ if (isCurrent(body, omitKeys(observed, READ_ONLY))) return toAttributes(site.id, observed);
314
+ const updated = yield* withConnection(
315
+ Sdk.updateNetwork({ siteId: site.id, networkId: observed.id, body }),
316
+ ).pipe(write);
317
+ return toAttributes(site.id, updated);
318
+ }),
319
+ ),
320
+
321
+ delete: ({ output }) =>
322
+ output.default
323
+ ? Effect.logInfo(`Leaving ${output.name}, the site's default network, in place.`)
324
+ : run(
325
+ withConnection(
326
+ Sdk.deleteNetwork({ siteId: output.siteId, networkId: output.networkId }),
327
+ ).pipe(
328
+ Effect.asVoid,
329
+ Effect.catchTag("NotFound", () => Effect.void),
330
+ ReadCache.invalidating(networksKey, output.siteId),
331
+ ),
332
+ ),
333
+ });
334
+ }),
335
+ );
@@ -0,0 +1,59 @@
1
+ import * as Provider from "alchemy/Provider";
2
+ import * as Layer from "effect/Layer";
3
+ import { UnifiNetworkAuth } from "./AuthProvider.ts";
4
+ import * as Connection from "./Connection.ts";
5
+ import * as Credentials from "./Credentials.ts";
6
+ import { DnsPolicy, DnsPolicyProvider } from "./DnsPolicy.ts";
7
+ import { FirewallPolicy, FirewallPolicyProvider } from "./FirewallPolicy.ts";
8
+ import { FirewallPolicyOrdering, FirewallPolicyOrderingProvider } from "./FirewallPolicyOrdering.ts";
9
+ import { FirewallZone, FirewallZoneProvider } from "./FirewallZone.ts";
10
+ import { Network, NetworkProvider } from "./Network.ts";
11
+ import { TrafficMatchingList, TrafficMatchingListProvider } from "./TrafficMatchingList.ts";
12
+ import { WifiBroadcast, WifiBroadcastProvider } from "./WifiBroadcast.ts";
13
+ import * as ReadCache from "./ReadCache.ts";
14
+
15
+ export class Providers extends Provider.ProviderCollection<Providers>()("UnifiNetwork") {}
16
+
17
+ /**
18
+ * Registers every UniFi Network resource provider, the `UnifiNetwork` auth
19
+ * provider, the lazily-resolved credentials and connection, and the per-run
20
+ * read cache. Merge
21
+ * alongside other cloud `providers()` layers in a Stack.
22
+ *
23
+ * Its requirements (`AuthProviders`, `ProfileStore`, `FileSystem`, `Path`)
24
+ * come from Alchemy's runtime; it provides only its own services.
25
+ *
26
+ * Unlike most providers this doesn't provide an `HttpClient`: ours carries
27
+ * the console's TLS settings, and lifecycle operations run in the stack's
28
+ * merged context, so it would replace other providers' clients. SDK calls
29
+ * get it through `UnifiNetworkConnection` instead.
30
+ */
31
+ export const providers = () =>
32
+ Layer.effect(
33
+ Providers,
34
+ Provider.collection([
35
+ FirewallZone,
36
+ Network,
37
+ WifiBroadcast,
38
+ TrafficMatchingList,
39
+ FirewallPolicy,
40
+ FirewallPolicyOrdering,
41
+ DnsPolicy,
42
+ ]),
43
+ ).pipe(
44
+ Layer.provide(
45
+ Layer.mergeAll(
46
+ FirewallZoneProvider(),
47
+ NetworkProvider(),
48
+ WifiBroadcastProvider(),
49
+ TrafficMatchingListProvider(),
50
+ FirewallPolicyProvider(),
51
+ FirewallPolicyOrderingProvider(),
52
+ DnsPolicyProvider(),
53
+ ),
54
+ ),
55
+ Layer.provideMerge(Layer.merge(Connection.layer, ReadCache.layer)),
56
+ Layer.provideMerge(Credentials.fromAuthProvider()),
57
+ Layer.provideMerge(UnifiNetworkAuth),
58
+ Layer.orDie,
59
+ );
@@ -0,0 +1,79 @@
1
+ import * as Context from "effect/Context";
2
+ import * as Deferred from "effect/Deferred";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Exit from "effect/Exit";
5
+ import * as Layer from "effect/Layer";
6
+
7
+ /**
8
+ * Per-run memo of API reads, so a plan touching many resources lists each
9
+ * collection once instead of once per resource. One instance per
10
+ * `providers()` build, i.e. per CLI run or test file. The value is only an
11
+ * identity: entries live on each {@link Key}, keyed by it.
12
+ */
13
+ export class UnifiNetworkReadCache extends Context.Service<UnifiNetworkReadCache, { readonly run: object }>()(
14
+ "UnifiNetwork::ReadCache",
15
+ ) {
16
+ /** Alchemy's tag for environment services, which lets stack bodies (and so lookups) require this one. */
17
+ declare readonly kind: "Environment";
18
+ }
19
+
20
+ export const layer = Layer.sync(UnifiNetworkReadCache, () => ({ run: {} }));
21
+
22
+ /**
23
+ * One kind of cached read, e.g. a resource type's list. `scope` arguments
24
+ * separate its entries, e.g. by site ID.
25
+ */
26
+ export interface Key<A, E> {
27
+ readonly name: string;
28
+ readonly entries: WeakMap<object, Map<string, Deferred.Deferred<A, E>>>;
29
+ }
30
+
31
+ export const key = <A, E = never>(name: string): Key<A, E> => ({ name, entries: new WeakMap() });
32
+
33
+ const entriesFor = <A, E>(cacheKey: Key<A, E>, run: object) => {
34
+ const existing = cacheKey.entries.get(run);
35
+ if (existing !== undefined) return existing;
36
+ const entries = new Map<string, Deferred.Deferred<A, E>>();
37
+ cacheKey.entries.set(run, entries);
38
+ return entries;
39
+ };
40
+
41
+ /**
42
+ * `load`'s result, shared by every caller in this run until invalidated.
43
+ * Concurrent callers wait for one load. Failures aren't cached.
44
+ */
45
+ export const cached = <A, E, R>(
46
+ cacheKey: Key<A, E>,
47
+ scope: string,
48
+ load: Effect.Effect<A, E, R>,
49
+ ): Effect.Effect<A, E, R | UnifiNetworkReadCache> =>
50
+ Effect.gen(function* () {
51
+ const { run } = yield* UnifiNetworkReadCache;
52
+ const entries = entriesFor(cacheKey, run);
53
+ const existing = entries.get(scope);
54
+ if (existing !== undefined) return yield* Deferred.await(existing);
55
+ const deferred = Deferred.makeUnsafe<A, E>();
56
+ entries.set(scope, deferred);
57
+ return yield* load.pipe(
58
+ Effect.onExit((exit) => {
59
+ if (Exit.isFailure(exit) && entries.get(scope) === deferred) entries.delete(scope);
60
+ return Deferred.done(deferred, exit);
61
+ }),
62
+ );
63
+ });
64
+
65
+ /** Drops an entry, so the next read loads afresh. */
66
+ export const invalidate = <A, E>(
67
+ cacheKey: Key<A, E>,
68
+ scope: string,
69
+ ): Effect.Effect<void, never, UnifiNetworkReadCache> =>
70
+ Effect.gen(function* () {
71
+ const { run } = yield* UnifiNetworkReadCache;
72
+ cacheKey.entries.get(run)?.delete(scope);
73
+ });
74
+
75
+ /** Runs a write, then invalidates the entry it affects, whether or not the write succeeded. */
76
+ export const invalidating =
77
+ <A, E>(cacheKey: Key<A, E>, scope: string) =>
78
+ <B, E2, R>(write: Effect.Effect<B, E2, R>): Effect.Effect<B, E2, R | UnifiNetworkReadCache> =>
79
+ Effect.ensuring(write, invalidate(cacheKey, scope));
package/src/Site.ts ADDED
@@ -0,0 +1,100 @@
1
+ import * as Sdk from "@alchemy-unifi/network-sdk";
2
+ import { UserFacingError } from "alchemy/UserFacingError";
3
+ import * as Data from "effect/Data";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Stream from "effect/Stream";
6
+ import { withConnection } from "./Connection.ts";
7
+ import { UnifiNetworkCredentials } from "./Credentials.ts";
8
+ import * as ReadCache from "./ReadCache.ts";
9
+
10
+ /** A UniFi site: a namespace within one console. */
11
+ export interface Site {
12
+ /** The ID other Integration API calls take. */
13
+ readonly id: string;
14
+ /** Stable reference that survives renames: `default`, or a generated one like `x7k2m9q1`. */
15
+ readonly internalReference: string;
16
+ readonly name: string;
17
+ }
18
+
19
+ const describeSites = (sites: ReadonlyArray<Site>) =>
20
+ sites.map((site) => `${site.internalReference} (${site.name})`).join(", ") || "none";
21
+
22
+ export class SiteNotFound extends Data.TaggedError("SiteNotFound")<{
23
+ readonly site: string;
24
+ readonly available: ReadonlyArray<Site>;
25
+ }> {
26
+ readonly [UserFacingError]: true = true;
27
+ override get message() {
28
+ return `The console has no site "${this.site}". Its sites: ${describeSites(this.available)}. Point the profile's site, or the resource's site prop, at one of them.`;
29
+ }
30
+ }
31
+
32
+ export class SiteRequired extends Data.TaggedError("SiteRequired")<{ readonly available: ReadonlyArray<Site> }> {
33
+ readonly [UserFacingError]: true = true;
34
+ override get message() {
35
+ return `The console hosts several sites (${describeSites(this.available)}), so resources need one: set site in the profile (alchemy profile edit --reconfigure UnifiNetwork), or the resource's site prop.`;
36
+ }
37
+ }
38
+
39
+ export class AmbiguousSite extends Data.TaggedError("AmbiguousSite")<{
40
+ readonly site: string;
41
+ readonly matches: ReadonlyArray<Site>;
42
+ }> {
43
+ readonly [UserFacingError]: true = true;
44
+ override get message() {
45
+ return `Several sites are named "${this.site}" (${describeSites(this.matches)}). Use the internal reference instead.`;
46
+ }
47
+ }
48
+
49
+ /** A resource referenced another that lives in a different site. */
50
+ export class CrossSiteReference extends Data.TaggedError("CrossSiteReference")<{
51
+ readonly what: string;
52
+ readonly referencedSiteId: string;
53
+ readonly site: Site;
54
+ }> {
55
+ readonly [UserFacingError]: true = true;
56
+ override get message() {
57
+ return `The referenced ${this.what} is in another site (${this.referencedSiteId}), not ${this.site.internalReference} (${this.site.name}). UniFi objects can only reference objects in their own site.`;
58
+ }
59
+ }
60
+
61
+ /** Fails if a reference carries a site ID (resources and lookups do) other than `site`'s. */
62
+ export const checkSameSite = (what: string, referencedSiteId: string | undefined, site: Site) =>
63
+ referencedSiteId === undefined || referencedSiteId === site.id
64
+ ? Effect.void
65
+ : Effect.fail(new CrossSiteReference({ what, referencedSiteId, site }));
66
+
67
+ const loadSites = withConnection(
68
+ Sdk.getSiteOverviewPage.items({}).pipe(
69
+ Stream.map(({ id, internalReference, name }): Site => ({ id, internalReference, name })),
70
+ Stream.runCollect,
71
+ ),
72
+ );
73
+
74
+ export const sitesKey = ReadCache.key<ReadonlyArray<Site>, Effect.Error<typeof loadSites>>("sites");
75
+
76
+ /** The console's sites, listed once per run. */
77
+ export const listSites = ReadCache.cached(sitesKey, "", loadSites);
78
+
79
+ /**
80
+ * The site a resource belongs to: `site` (an ID, internal reference or
81
+ * name), else the profile's site, else the console's only site. Fails,
82
+ * listing the console's sites, when it doesn't exist or is ambiguous, so a
83
+ * plan stops before changing anything.
84
+ */
85
+ export const resolveSite = Effect.fn("UnifiNetwork.resolveSite")(function* (site?: string) {
86
+ const requested = site ?? (yield* yield* UnifiNetworkCredentials).site;
87
+ const sites = yield* listSites;
88
+ if (requested === undefined) {
89
+ const [only] = sites;
90
+ if (only !== undefined && sites.length === 1) return only;
91
+ return yield* new SiteRequired({ available: sites });
92
+ }
93
+ const byReference = sites.find((s) => s.id === requested || s.internalReference === requested);
94
+ if (byReference !== undefined) return byReference;
95
+ const byName = sites.filter((s) => s.name === requested);
96
+ const [match] = byName;
97
+ if (match !== undefined && byName.length === 1) return match;
98
+ if (byName.length > 1) return yield* new AmbiguousSite({ site: requested, matches: byName });
99
+ return yield* new SiteNotFound({ site: requested, available: sites });
100
+ });
@@ -0,0 +1,68 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as HttpClient from "effect/http/HttpClient";
3
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
4
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
5
+ import * as Redacted from "effect/Redacted";
6
+ import * as Schema from "effect/Schema";
7
+ import { SITE_MANAGER_URL } from "./Connection.ts";
8
+
9
+ /**
10
+ * Just enough of the Site Manager API (`GET /v1/hosts`) for `configure` to
11
+ * list the consoles a key can reach. A full SDK belongs in its own package.
12
+ */
13
+ const Host = Schema.Struct({
14
+ id: Schema.String,
15
+ type: Schema.optional(Schema.NullOr(Schema.String)),
16
+ reportedState: Schema.optional(
17
+ Schema.NullOr(
18
+ Schema.Struct({
19
+ name: Schema.optional(Schema.NullOr(Schema.String)),
20
+ hostname: Schema.optional(Schema.NullOr(Schema.String)),
21
+ }),
22
+ ),
23
+ ),
24
+ });
25
+
26
+ const HostsPage = Schema.Struct({
27
+ data: Schema.Array(Host),
28
+ nextToken: Schema.optional(Schema.NullOr(Schema.String)),
29
+ });
30
+
31
+ export interface Console {
32
+ /** The host ID, which the cloud connector takes as the console ID. */
33
+ readonly id: string;
34
+ readonly name: string;
35
+ /** Host type as reported by Site Manager, e.g. `console`. */
36
+ readonly type: string | undefined;
37
+ }
38
+
39
+ export const listConsoles = (
40
+ apiKey: Redacted.Redacted<string>,
41
+ ): Effect.Effect<ReadonlyArray<Console>, unknown, HttpClient.HttpClient> =>
42
+ Effect.gen(function* () {
43
+ const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
44
+ const consoles: Console[] = [];
45
+ let nextToken: string | undefined;
46
+ do {
47
+ const page = yield* client
48
+ .execute(
49
+ HttpClientRequest.get(`${SITE_MANAGER_URL}/v1/hosts`).pipe(
50
+ HttpClientRequest.setHeaders({
51
+ "X-API-KEY": Redacted.value(apiKey),
52
+ Accept: "application/json",
53
+ }),
54
+ HttpClientRequest.setUrlParams(nextToken === undefined ? {} : { nextToken }),
55
+ ),
56
+ )
57
+ .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(HostsPage)));
58
+ for (const host of page.data) {
59
+ consoles.push({
60
+ id: host.id,
61
+ name: host.reportedState?.name ?? host.reportedState?.hostname ?? host.id,
62
+ type: host.type ?? undefined,
63
+ });
64
+ }
65
+ nextToken = page.nextToken ?? undefined;
66
+ } while (nextToken !== undefined);
67
+ return consoles;
68
+ });