1import * as sasportal from "@distilled.cloud/gcp/sasportal_v1alpha1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 deviceBody,
12 deviceConfigOf,
13 deviceGrantsOf,
14 deviceMetadataOf,
15 encodeOwnershipLine,
16 expandNode,
17 findOwnedNodeDevice,
18 frequencyRangesOf,
19 getNodeDevice,
20 hasOwnershipMarker,
21 lastSegment,
22 listNodeDevices,
23 MAX_DISPLAY_NAME_LENGTH,
24 ownedByAlchemy,
25 ownershipLabels,
26 parentOf,
27 parseOwnership,
28 replaceOnIdentity,
29 retryDelete,
30 sameJson,
31 sameText,
32 scanOwnedNodeDevice,
33 toDisplayName,
34 toSerialNumber,
35 updateMaskOf,
36 waitUntilGone,
37 walkNodes,
38 type DeviceConfig,
39 type DeviceGrant,
40 type DeviceMetadata,
41 type FrequencyRange,
42} from "./internal.ts";
43
44export type NodesDeviceProps = {
45 /**
46 * Parent SAS node, as `nodes/{node}` or
47 * `customers/{customer}/nodes/{node}`. Immutable — changing it
48 * replaces the device.
49 */
50 parent: string;
51 /**
52 * Server-assigned device resource name. Immutable — changing it
53 * replaces the device.
54 */
55 name?: string;
56 /**
57 * Display name. SAS Portal devices have no labels field, so Alchemy
58 * stamps ownership into a `[alchemy …]` prefix and strips it from
59 * attributes.
60 */
61 displayName?: string;
62 /**
63 * Manufacturer serial number. Immutable — changing it replaces the
64 * device.
65 */
66 serialNumber?: string;
67 /**
68 * FCC identifier of the CBSD. Immutable — changing it replaces the
69 * device.
70 */
71 fccId?: string;
72 /**
73 * Frequency ranges available for new grants.
74 */
75 grantRangeAllowlists?: FrequencyRange[];
76 /**
77 * Device configuration supplied through the SAS Portal API.
78 */
79 preloadedConfig?: DeviceConfig;
80 /**
81 * Overridable device metadata (ICG, CCG, antenna model).
82 */
83 deviceMetadata?: DeviceMetadata;
84};
85
86export type NodesDevice = Resource<
87 "GCP.SasPortal.NodesDevice",
88 NodesDeviceProps,
89 {
90 /** Resource name of the device. */
91 name: string;
92 /** Device id (last path segment). */
93 deviceId: string;
94 /** Parent node resource name. */
95 parent: string;
96 /** Project id used when the device was reconciled. */
97 project: string;
98 /** User-facing display name with the Alchemy prefix stripped. */
99 displayName: string | undefined;
100 /** Manufacturer serial number. */
101 serialNumber: string | undefined;
102 /** FCC identifier. */
103 fccId: string | undefined;
104 /** Grant-range allowlists. */
105 grantRangeAllowlists: FrequencyRange[] | undefined;
106 /** Portal-supplied configuration. */
107 preloadedConfig: DeviceConfig | undefined;
108 /** Current SAS-registered configuration. */
109 activeConfig: DeviceConfig | undefined;
110 /** Overridable device metadata. */
111 deviceMetadata: DeviceMetadata | undefined;
112 /** Device state. */
113 state: string | undefined;
114 /** Grants held by the device. */
115 grants: DeviceGrant[] | undefined;
116 },
117 never,
118 Providers
119>;
120
121/**
122 * A Spectrum Access System (SAS) Portal CBSD under a node.
123 *
124 * SAS Portal devices have no labels field, so Alchemy stamps ownership
125 * into `displayName` for `list` / nuke. Parent node, FCC id, and serial
126 * number are identity. Display name, grant allowlists, preloaded
127 * config, and metadata update in place.
128 *
129 * ### Creating a Device
130 * **Example:** Under a customer node
131 * ```typescript
132 * const device = yield* GCP.SasPortal.NodesDevice("Cbsd", {
133 * parent: node.name,
134 * displayName: "sector-a",
135 * fccId: "TESTFCC",
136 * serialNumber: "SN2001",
137 * });
138 * ```
139 *
140 * ### Updating a Device
141 * Change props on the same logical id; the engine keeps the physical id.
142 *
143 * **Example:** Rename
144 * ```typescript
145 * const device = yield* GCP.SasPortal.NodesDevice("Cbsd", {
146 * parent: node.name,
147 * displayName: "sector-b",
148 * fccId: "TESTFCC",
149 * serialNumber: "SN2001",
150 * });
151 * ```
152 *
153 * @resource
154 * @category SasPortal
155 */
156export const NodesDevice = Resource<NodesDevice>("GCP.SasPortal.NodesDevice");
157
158export class NodesDeviceNotResolved extends Data.TaggedError(
159 "GCP.SasPortal.NodesDeviceNotResolved",
160)<{
161 parent: string;
162 name: string;
163}> {}
164
165const toAttrs = (
166 device: sasportal.SasPortalDevice,
167 parent: string,
168 project: string,
169) => {
170 const name = device.name ?? "";
171 return {
172 name,
173 deviceId: lastSegment(name),
174 parent: parentOf(name) || parent,
175 project,
176 displayName: parseOwnership(device.displayName).text,
177 serialNumber: device.serialNumber,
178 fccId: device.fccId,
179 grantRangeAllowlists: frequencyRangesOf(device.grantRangeAllowlists),
180 preloadedConfig: deviceConfigOf(device.preloadedConfig),
181 activeConfig: deviceConfigOf(device.activeConfig),
182 deviceMetadata: deviceMetadataOf(device.deviceMetadata),
183 state: device.state,
184 grants: deviceGrantsOf(device.grants),
185 };
186};
187
188export const NodesDeviceProvider = () =>
189 Provider.succeed(NodesDevice, {
190 stables: ["name", "deviceId", "parent", "project", "serialNumber", "fccId"],
191
192 diff: Effect.fn(function* ({ news, olds, output }) {
193 if (!isResolved(news)) return undefined;
194 return replaceOnIdentity({
195 previousParent: olds?.parent ?? output?.parent,
196 nextParent: expandNode(news.parent),
197 previousId: olds?.name ?? output?.name,
198 nextId: news.name,
199 extra:
200 (news.serialNumber !== undefined &&
201 output?.serialNumber !== undefined &&
202 news.serialNumber !== output.serialNumber) ||
203 (news.fccId !== undefined &&
204 output?.fccId !== undefined &&
205 news.fccId !== output.fccId),
206 });
207 }),
208
209 read: Effect.fn(function* ({ id, olds, output }) {
210 const env = yield* GcpEnvironment.current;
211 const parent = expandNode(olds?.parent ?? output?.parent ?? "");
212 const name = olds?.name ?? output?.name ?? "";
213 let existing = yield* getNodeDevice(name);
214 let locatedParent = existing
215 ? parentOf(existing.name ?? "") || parent
216 : parent;
217 if (existing === undefined) {
218 const found = yield* scanOwnedNodeDevice(id, parent);
219 existing = found?.row;
220 locatedParent = found?.parent ?? locatedParent;
221 }
222 if (existing === undefined) return undefined;
223 const attrs = toAttrs(existing, locatedParent, env.project);
224 return (yield* ownedByAlchemy(id, existing.displayName))
225 ? attrs
226 : Unowned(attrs);
227 }),
228
229 list: () =>
230 Effect.gen(function* () {
231 const env = yield* GcpEnvironment.current;
232 const nodes = yield* walkNodes();
233 const pages = yield* Effect.forEach(
234 nodes,
235 (entry) => {
236 const parent = entry.node.name ?? "";
237 return parent.length === 0
238 ? Effect.succeed([])
239 : listNodeDevices(parent).pipe(
240 Effect.map((rows) =>
241 rows
242 .filter((row) => hasOwnershipMarker(row.displayName))
243 .map((row) => toAttrs(row, parent, env.project)),
244 ),
245 );
246 },
247 { concurrency: 4 },
248 );
249 return pages.flat();
250 }),
251
252 reconcile: Effect.fn(function* ({ id, news, output }) {
253 const env = yield* GcpEnvironment.current;
254 const parent = expandNode(news.parent);
255 const ownership = yield* ownershipLabels(id);
256 const displayName = encodeOwnershipLine(
257 ownership,
258 yield* toDisplayName(id, news.displayName, output?.displayName),
259 MAX_DISPLAY_NAME_LENGTH,
260 );
261 const serialNumber = yield* toSerialNumber(
262 id,
263 news.serialNumber,
264 output?.serialNumber,
265 );
266 const desired = deviceBody({
267 displayName,
268 serialNumber,
269 fccId: news.fccId ?? output?.fccId,
270 grantRangeAllowlists: news.grantRangeAllowlists,
271 preloadedConfig: news.preloadedConfig,
272 deviceMetadata: news.deviceMetadata,
273 });
274
275 let current = yield* getNodeDevice(news.name ?? output?.name ?? "");
276 if (current === undefined) {
277 const found =
278 (yield* findOwnedNodeDevice(id, parent)) ??
279 (yield* scanOwnedNodeDevice(id, parent));
280 current = found?.row;
281 }
282
283 if (current === undefined) {
284 const created = yield* sasportal
285 .createNodesDevices({
286 parent,
287 body: desired,
288 })
289 .pipe(
290 Effect.catchTag("Conflict", () =>
291 findOwnedNodeDevice(id, parent).pipe(
292 Effect.map((found) => found?.row),
293 ),
294 ),
295 );
296 current = created ?? undefined;
297 }
298
299 if (current === undefined) {
300 return yield* new NodesDeviceNotResolved({
301 parent,
302 name: news.name ?? output?.name ?? displayName,
303 });
304 }
305
306 const name = current.name ?? news.name ?? output?.name ?? "";
307 const nameChanged = !sameText(current.displayName, displayName);
308 const allowlistsChanged =
309 news.grantRangeAllowlists !== undefined &&
310 !sameJson(
311 frequencyRangesOf(current.grantRangeAllowlists),
312 news.grantRangeAllowlists,
313 );
314 const configChanged =
315 news.preloadedConfig !== undefined &&
316 !sameJson(
317 deviceConfigOf(current.preloadedConfig),
318 news.preloadedConfig,
319 );
320 const metadataChanged =
321 news.deviceMetadata !== undefined &&
322 !sameJson(
323 deviceMetadataOf(current.deviceMetadata),
324 news.deviceMetadata,
325 );
326 if (
327 nameChanged ||
328 allowlistsChanged ||
329 configChanged ||
330 metadataChanged
331 ) {
332 current = yield* sasportal.patchNodesDevices({
333 name,
334 updateMask: updateMaskOf(
335 "displayName",
336 news.grantRangeAllowlists !== undefined
337 ? "grantRangeAllowlists"
338 : undefined,
339 news.preloadedConfig !== undefined ? "preloadedConfig" : undefined,
340 news.deviceMetadata !== undefined ? "deviceMetadata" : undefined,
341 ),
342 body: desired,
343 });
344 }
345
346 return toAttrs(current, parent, env.project);
347 }),
348
349 delete: Effect.fn(function* ({ output }) {
350 if (!output.name) return;
351 yield* retryDelete(
352 sasportal.deleteNodesDevices({ name: output.name }),
353 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
354 yield* waitUntilGone(getNodeDevice(output.name));
355 }),
356 });