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 findOwnedNodeDeploymentDevice,
20 frequencyRangesOf,
21 getDeploymentDevice,
22 hasOwnershipMarker,
23 lastSegment,
24 listNodeDeploymentDevices,
25 MAX_DISPLAY_NAME_LENGTH,
26 ownedByAlchemy,
27 ownershipLabels,
28 parentOf,
29 parseOwnership,
30 replaceOnIdentity,
31 retryDelete,
32 sameJson,
33 sameText,
34 scanOwnedNodeDeploymentDevice,
35 signedDeviceBody,
36 toDisplayName,
37 updateMaskOf,
38 waitUntilGone,
39 walkNodeDeployments,
40 type DeviceConfig,
41 type DeviceGrant,
42 type DeviceMetadata,
43 type FrequencyRange,
44} from "./internal.ts";
45
46export type SignedNodesDeploymentsDeviceProps = {
47 /**
48 * Parent SAS deployment under a node, as `nodes/{node}/deployments/{deployment}`.
49 * Immutable — changing it replaces the device.
50 */
51 parent: string;
52 /**
53 * Server-assigned device resource name
54 * (`nodes/{node}/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 SignedNodesDeploymentsDevice = Resource<
98 "GCP.SasPortal.SignedNodesDeploymentsDevice",
99 SignedNodesDeploymentsDeviceProps,
100 {
101 /** Resource name `nodes/{node}/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 node 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.SignedNodesDeploymentsDevice("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.SignedNodesDeploymentsDevice("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 SignedNodesDeploymentsDevice =
167 Resource<SignedNodesDeploymentsDevice>(
168 "GCP.SasPortal.SignedNodesDeploymentsDevice",
169 );
170
171export class SignedNodesDeploymentsDeviceNotResolved extends Data.TaggedError(
172 "GCP.SasPortal.SignedNodesDeploymentsDeviceNotResolved",
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 SignedNodesDeploymentsDeviceProvider = () =>
202 Provider.succeed(SignedNodesDeploymentsDevice, {
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* findOwnedNodeDeploymentDevice(id, parent)) ??
226 (yield* scanOwnedNodeDeploymentDevice(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* walkNodeDeployments();
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 : listNodeDeploymentDevices(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* findOwnedNodeDeploymentDevice(id, parent)) ??
289 (yield* scanOwnedNodeDeploymentDevice(id, parent));
290 current = found?.row;
291 }
292 if (current === undefined) {
293 const rows = yield* listNodeDeploymentDevices(parent);
294 current = findDeviceBySerial(rows, serialNumber);
295 }
296
297 if (current === undefined) {
298 const created = yield* sasportal
299 .createSignedNodesDeploymentsDevices({
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* findOwnedNodeDeploymentDevice(id, parent);
310 if (found?.row !== undefined) return found.row;
311 const rows = yield* listNodeDeploymentDevices(parent);
312 return findDeviceBySerial(rows, serialNumber);
313 }),
314 ),
315 );
316 current = created ?? undefined;
317 }
318
319 if (current === undefined) {
320 return yield* new SignedNodesDeploymentsDeviceNotResolved({
321 parent,
322 name: news.name ?? output?.name ?? displayName,
323 });
324 }
325
326 const name = current.name ?? news.name ?? output?.name ?? "";
327 const nameChanged = !sameText(current.displayName, displayName);
328 const allowlistsChanged =
329 news.grantRangeAllowlists !== undefined &&
330 !sameJson(
331 frequencyRangesOf(current.grantRangeAllowlists),
332 news.grantRangeAllowlists,
333 );
334 const configChanged =
335 news.preloadedConfig !== undefined &&
336 !sameJson(
337 deviceConfigOf(current.preloadedConfig),
338 news.preloadedConfig,
339 );
340 const metadataChanged =
341 news.deviceMetadata !== undefined &&
342 !sameJson(
343 deviceMetadataOf(current.deviceMetadata),
344 news.deviceMetadata,
345 );
346 if (
347 nameChanged ||
348 allowlistsChanged ||
349 configChanged ||
350 metadataChanged
351 ) {
352 current = yield* sasportal.patchDeploymentsDevices({
353 name,
354 updateMask: updateMaskOf(
355 "displayName",
356 news.grantRangeAllowlists !== undefined
357 ? "grantRangeAllowlists"
358 : undefined,
359 news.preloadedConfig !== undefined ? "preloadedConfig" : undefined,
360 news.deviceMetadata !== undefined ? "deviceMetadata" : undefined,
361 ),
362 body: desired,
363 });
364 }
365
366 return toAttrs(current, parent, env.project);
367 }),
368
369 delete: Effect.fn(function* ({ output }) {
370 if (!output.name) return;
371 yield* retryDelete(
372 sasportal.deleteDeploymentsDevices({ name: output.name }),
373 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
374 yield* waitUntilGone(getDeploymentDevice(output.name));
375 }),
376 });