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 expandPath,
17 findOwnedNodeNodeDevice,
18 frequencyRangesOf,
19 getNodeDevice,
20 hasOwnershipMarker,
21 lastSegment,
22 listNodeNodeDevices,
23 MAX_DISPLAY_NAME_LENGTH,
24 ownedByAlchemy,
25 ownershipLabels,
26 parentOf,
27 parseOwnership,
28 replaceOnIdentity,
29 retryDelete,
30 sameJson,
31 sameText,
32 scanOwnedNodeNodeDevice,
33 toDisplayName,
34 toSerialNumber,
35 updateMaskOf,
36 waitUntilGone,
37 walkNodeChildren,
38 type DeviceConfig,
39 type DeviceGrant,
40 type DeviceMetadata,
41 type FrequencyRange,
42} from "./internal.ts";
43
44export type NodesNodesDeviceProps = {
45 /**
46 * Parent nested SAS node, as `nodes/{node}/nodes/{node}`.
47 * Immutable — changing it replaces the device.
48 */
49 parent: string;
50 /**
51 * Server-assigned device resource name
52 * (`nodes/{node}/nodes/{node}/devices/{device}`). 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 NodesNodesDevice = Resource<
87 "GCP.SasPortal.NodesNodesDevice",
88 NodesNodesDeviceProps,
89 {
90 /** Resource name `nodes/{node}/nodes/{node}/devices/{device}`. */
91 name: string;
92 /** Device id (last path segment). */
93 deviceId: string;
94 /** Parent 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 nested node.
123 *
124 * SAS Portal devices have no labels field, so Alchemy stamps ownership
125 * into `displayName` for `list` / nuke. Parent, 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:** Category A CBSD
131 * ```typescript
132 * const device = yield* GCP.SasPortal.NodesNodesDevice("Cbsd", {
133 * parent: child.name,
134 * displayName: "sector-a",
135 * fccId: "TESTFCC",
136 * serialNumber: "SN1001",
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.NodesNodesDevice("Cbsd", {
146 * parent: child.name,
147 * displayName: "sector-a-2",
148 * fccId: "TESTFCC",
149 * serialNumber: "SN1001",
150 * });
151 * ```
152 *
153 * @resource
154 * @category SasPortal
155 */
156export const NodesNodesDevice = Resource<NodesNodesDevice>(
157 "GCP.SasPortal.NodesNodesDevice",
158);
159
160export class NodesNodesDeviceNotResolved extends Data.TaggedError(
161 "GCP.SasPortal.NodesNodesDeviceNotResolved",
162)<{
163 parent: string;
164 name: string;
165}> {}
166
167const toAttrs = (
168 device: sasportal.SasPortalDevice,
169 parent: string,
170 project: string,
171) => {
172 const name = device.name ?? "";
173 return {
174 name,
175 deviceId: lastSegment(name),
176 parent: parentOf(name) || parent,
177 project,
178 displayName: parseOwnership(device.displayName).text,
179 serialNumber: device.serialNumber,
180 fccId: device.fccId,
181 grantRangeAllowlists: frequencyRangesOf(device.grantRangeAllowlists),
182 preloadedConfig: deviceConfigOf(device.preloadedConfig),
183 activeConfig: deviceConfigOf(device.activeConfig),
184 deviceMetadata: deviceMetadataOf(device.deviceMetadata),
185 state: device.state,
186 grants: deviceGrantsOf(device.grants),
187 };
188};
189
190export const NodesNodesDeviceProvider = () =>
191 Provider.succeed(NodesNodesDevice, {
192 stables: ["name", "deviceId", "parent", "project", "serialNumber", "fccId"],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 return replaceOnIdentity({
197 previousParent: olds?.parent ?? output?.parent,
198 nextParent: expandPath(news.parent),
199 previousId: olds?.name ?? output?.name,
200 nextId: news.name,
201 extra:
202 (news.serialNumber !== undefined &&
203 output?.serialNumber !== undefined &&
204 news.serialNumber !== output.serialNumber) ||
205 (news.fccId !== undefined &&
206 output?.fccId !== undefined &&
207 news.fccId !== output.fccId),
208 });
209 }),
210
211 read: Effect.fn(function* ({ id, olds, output }) {
212 const env = yield* GcpEnvironment.current;
213 const parent = expandPath(olds?.parent ?? output?.parent ?? "");
214 const name = olds?.name ?? output?.name ?? "";
215 let existing = yield* getNodeDevice(name);
216 let locatedParent = existing
217 ? parentOf(existing.name ?? "") || parent
218 : parent;
219 if (existing === undefined) {
220 const found =
221 (yield* findOwnedNodeNodeDevice(id, parent)) ??
222 (yield* scanOwnedNodeNodeDevice(id, parent));
223 existing = found?.row;
224 locatedParent = found?.parent ?? locatedParent;
225 }
226 if (existing === undefined) return undefined;
227 const attrs = toAttrs(existing, locatedParent, env.project);
228 return (yield* ownedByAlchemy(id, existing.displayName))
229 ? attrs
230 : Unowned(attrs);
231 }),
232
233 list: () =>
234 Effect.gen(function* () {
235 const env = yield* GcpEnvironment.current;
236 const parents = yield* walkNodeChildren();
237 const pages = yield* Effect.forEach(
238 parents,
239 (entry) => {
240 const parent = entry.node.name ?? "";
241 return parent.length === 0
242 ? Effect.succeed([])
243 : listNodeNodeDevices(parent).pipe(
244 Effect.map((rows) =>
245 rows
246 .filter((row) => hasOwnershipMarker(row.displayName))
247 .map((row) => toAttrs(row, parent, env.project)),
248 ),
249 );
250 },
251 { concurrency: 4 },
252 );
253 return pages.flat();
254 }),
255
256 reconcile: Effect.fn(function* ({ id, news, output }) {
257 const env = yield* GcpEnvironment.current;
258 const parent = expandPath(news.parent);
259 const ownership = yield* ownershipLabels(id);
260 const displayName = encodeOwnershipLine(
261 ownership,
262 yield* toDisplayName(id, news.displayName, output?.displayName),
263 MAX_DISPLAY_NAME_LENGTH,
264 );
265 const serialNumber = yield* toSerialNumber(
266 id,
267 news.serialNumber,
268 output?.serialNumber,
269 );
270 const desired = deviceBody({
271 displayName,
272 serialNumber,
273 fccId: news.fccId ?? output?.fccId,
274 grantRangeAllowlists: news.grantRangeAllowlists,
275 preloadedConfig: news.preloadedConfig,
276 deviceMetadata: news.deviceMetadata,
277 });
278
279 let current = yield* getNodeDevice(news.name ?? output?.name ?? "");
280 if (current === undefined) {
281 const found =
282 (yield* findOwnedNodeNodeDevice(id, parent)) ??
283 (yield* scanOwnedNodeNodeDevice(id, parent));
284 current = found?.row;
285 }
286
287 if (current === undefined) {
288 const created = yield* sasportal
289 .createNodesNodesDevices({
290 parent,
291 body: desired,
292 })
293 .pipe(
294 Effect.catchTag("Conflict", () =>
295 findOwnedNodeNodeDevice(id, parent).pipe(
296 Effect.map((found) => found?.row),
297 ),
298 ),
299 );
300 current = created ?? undefined;
301 }
302
303 if (current === undefined) {
304 return yield* new NodesNodesDeviceNotResolved({
305 parent,
306 name: news.name ?? output?.name ?? displayName,
307 });
308 }
309
310 const name = current.name ?? news.name ?? output?.name ?? "";
311 const nameChanged = !sameText(current.displayName, displayName);
312 const allowlistsChanged =
313 news.grantRangeAllowlists !== undefined &&
314 !sameJson(
315 frequencyRangesOf(current.grantRangeAllowlists),
316 news.grantRangeAllowlists,
317 );
318 const configChanged =
319 news.preloadedConfig !== undefined &&
320 !sameJson(
321 deviceConfigOf(current.preloadedConfig),
322 news.preloadedConfig,
323 );
324 const metadataChanged =
325 news.deviceMetadata !== undefined &&
326 !sameJson(
327 deviceMetadataOf(current.deviceMetadata),
328 news.deviceMetadata,
329 );
330 if (
331 nameChanged ||
332 allowlistsChanged ||
333 configChanged ||
334 metadataChanged
335 ) {
336 current = yield* sasportal.patchNodesDevices({
337 name,
338 updateMask: updateMaskOf(
339 "displayName",
340 news.grantRangeAllowlists !== undefined
341 ? "grantRangeAllowlists"
342 : undefined,
343 news.preloadedConfig !== undefined ? "preloadedConfig" : undefined,
344 news.deviceMetadata !== undefined ? "deviceMetadata" : undefined,
345 ),
346 body: desired,
347 });
348 }
349
350 return toAttrs(current, parent, env.project);
351 }),
352
353 delete: Effect.fn(function* ({ output }) {
354 if (!output.name) return;
355 yield* retryDelete(
356 sasportal.deleteNodesDevices({ name: output.name }),
357 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
358 yield* waitUntilGone(getNodeDevice(output.name));
359 }),
360 });