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