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