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 encodeOwnershipLine,
12 expandCustomer,
13 findOwnedCustomerDeployment,
14 getCustomerDeployment,
15 hasOwnershipMarker,
16 lastSegment,
17 listCustomerDeployments,
18 listCustomers,
19 MAX_DISPLAY_NAME_LENGTH,
20 ownedByAlchemy,
21 ownershipLabels,
22 parentOf,
23 parseOwnership,
24 replaceOnIdentity,
25 retryDelete,
26 sameStringList,
27 sameText,
28 scanOwnedCustomerDeployment,
29 toDisplayName,
30 updateMaskOf,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type CustomersDeploymentProps = {
35 /**
36 * Parent SAS customer, as `customers/{customer}` or the customer id.
37 * Immutable — changing it replaces the deployment.
38 */
39 parent: string;
40 /**
41 * Server-assigned deployment resource name
42 * (`customers/{customer}/deployments/{deployment}`). Immutable —
43 * changing it replaces the deployment.
44 */
45 name?: string;
46 /**
47 * Display name. SAS Portal deployments have no labels field, so
48 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
49 * from attributes.
50 */
51 displayName?: string;
52 /**
53 * SAS user ids used by devices that belong to this deployment. Each
54 * deployment should have one unique user id.
55 */
56 sasUserIds?: string[];
57};
58
59export type CustomersDeployment = Resource<
60 "GCP.SasPortal.CustomersDeployment",
61 CustomersDeploymentProps,
62 {
63 /** Resource name `customers/{customer}/deployments/{deployment}`. */
64 name: string;
65 /** Deployment id (last path segment). */
66 deploymentId: string;
67 /** Parent customer resource name. */
68 parent: string;
69 /** Project id used when the deployment was reconciled. */
70 project: string;
71 /** User-facing display name with the Alchemy prefix stripped. */
72 displayName: string | undefined;
73 /** SAS user ids on the deployment. */
74 sasUserIds: string[] | undefined;
75 /** FCC Registration Numbers inherited from the parent. */
76 frns: string[] | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * A Spectrum Access System (SAS) Portal deployment under a customer.
84 *
85 * SAS Portal deployments have no labels field, so Alchemy stamps
86 * ownership into `displayName` for `list` / nuke. Parent customer is
87 * identity. Display name and SAS user ids update in place. `frns` is
88 * output-only.
89 *
90 * ### Creating a Deployment
91 * **Example:** Generated display name
92 * ```typescript
93 * const deployment = yield* GCP.SasPortal.CustomersDeployment("Site", {
94 * parent: "customers/123",
95 * });
96 * ```
97 *
98 * **Example:** Explicit name and SAS user id
99 * ```typescript
100 * const deployment = yield* GCP.SasPortal.CustomersDeployment("Site", {
101 * parent: "customers/123",
102 * displayName: "downtown",
103 * sasUserIds: ["operator-1"],
104 * });
105 * ```
106 *
107 * ### Updating a Deployment
108 * Change props on the same logical id; the engine keeps the physical id.
109 *
110 * **Example:** Rename
111 * ```typescript
112 * const deployment = yield* GCP.SasPortal.CustomersDeployment("Site", {
113 * parent: "customers/123",
114 * displayName: "downtown-west",
115 * sasUserIds: ["operator-1"],
116 * });
117 * ```
118 *
119 * @resource
120 * @category SasPortal
121 */
122export const CustomersDeployment = Resource<CustomersDeployment>(
123 "GCP.SasPortal.CustomersDeployment",
124);
125
126export class CustomersDeploymentNotResolved extends Data.TaggedError(
127 "GCP.SasPortal.CustomersDeploymentNotResolved",
128)<{
129 parent: string;
130 name: string;
131}> {}
132
133const toAttrs = (
134 deployment: sasportal.SasPortalDeployment,
135 parent: string,
136 project: string,
137) => {
138 const name = deployment.name ?? "";
139 return {
140 name,
141 deploymentId: lastSegment(name),
142 parent: parentOf(name) || parent,
143 project,
144 displayName: parseOwnership(deployment.displayName).text,
145 sasUserIds: deployment.sasUserIds,
146 frns: deployment.frns,
147 };
148};
149
150export const CustomersDeploymentProvider = () =>
151 Provider.succeed(CustomersDeployment, {
152 stables: ["name", "deploymentId", "parent", "project"],
153
154 diff: Effect.fn(function* ({ news, olds, output }) {
155 if (!isResolved(news)) return undefined;
156 return replaceOnIdentity({
157 previousParent: olds?.parent ?? output?.parent,
158 nextParent: expandCustomer(news.parent),
159 previousId: olds?.name ?? output?.name,
160 nextId: news.name,
161 });
162 }),
163
164 read: Effect.fn(function* ({ id, olds, output }) {
165 const env = yield* GcpEnvironment.current;
166 const parent = expandCustomer(olds?.parent ?? output?.parent ?? "");
167 const name = olds?.name ?? output?.name ?? "";
168 let existing = yield* getCustomerDeployment(name);
169 let locatedParent = existing
170 ? parentOf(existing.name ?? "") || parent
171 : parent;
172 if (existing === undefined) {
173 const found =
174 (yield* findOwnedCustomerDeployment(id, parent)) ??
175 (yield* scanOwnedCustomerDeployment(id));
176 existing = found?.row;
177 locatedParent = found?.parent ?? locatedParent;
178 }
179 if (existing === undefined) return undefined;
180 const attrs = toAttrs(existing, locatedParent, env.project);
181 return (yield* ownedByAlchemy(id, existing.displayName))
182 ? attrs
183 : Unowned(attrs);
184 }),
185
186 list: () =>
187 Effect.gen(function* () {
188 const env = yield* GcpEnvironment.current;
189 const customers = yield* listCustomers();
190 const pages = yield* Effect.forEach(
191 customers,
192 (customer) =>
193 customer.name
194 ? listCustomerDeployments(customer.name).pipe(
195 Effect.map((rows) =>
196 rows
197 .filter((row) => hasOwnershipMarker(row.displayName))
198 .map((row) =>
199 toAttrs(row, customer.name ?? "", env.project),
200 ),
201 ),
202 )
203 : Effect.succeed([]),
204 { concurrency: 4 },
205 );
206 return pages.flat();
207 }),
208
209 reconcile: Effect.fn(function* ({ id, news, output }) {
210 const env = yield* GcpEnvironment.current;
211 const parent = expandCustomer(news.parent);
212 const ownership = yield* ownershipLabels(id);
213 const displayName = encodeOwnershipLine(
214 ownership,
215 yield* toDisplayName(id, news.displayName, output?.displayName),
216 MAX_DISPLAY_NAME_LENGTH,
217 );
218 const desired: sasportal.SasPortalDeployment = {
219 displayName,
220 sasUserIds: news.sasUserIds,
221 };
222
223 let current = yield* getCustomerDeployment(
224 news.name ?? output?.name ?? "",
225 );
226 if (current === undefined) {
227 const found =
228 (yield* findOwnedCustomerDeployment(id, parent)) ??
229 (yield* scanOwnedCustomerDeployment(id));
230 current = found?.row;
231 }
232
233 if (current === undefined) {
234 const created = yield* sasportal
235 .createCustomersDeployments({
236 parent,
237 body: desired,
238 })
239 .pipe(
240 Effect.catchTag("Conflict", () =>
241 findOwnedCustomerDeployment(id, parent).pipe(
242 Effect.map((found) => found?.row),
243 ),
244 ),
245 );
246 current = created ?? undefined;
247 }
248
249 if (current === undefined) {
250 return yield* new CustomersDeploymentNotResolved({
251 parent,
252 name: news.name ?? output?.name ?? displayName,
253 });
254 }
255
256 const name = current.name ?? news.name ?? output?.name ?? "";
257 const nameChanged = !sameText(current.displayName, displayName);
258 const usersChanged =
259 news.sasUserIds !== undefined &&
260 !sameStringList(current.sasUserIds, news.sasUserIds);
261 if (nameChanged || usersChanged) {
262 current = yield* sasportal.patchCustomersDeployments({
263 name,
264 updateMask: updateMaskOf(
265 "displayName",
266 news.sasUserIds !== undefined ? "sasUserIds" : undefined,
267 ),
268 body: desired,
269 });
270 }
271
272 return toAttrs(current, parent, env.project);
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 if (!output.name) return;
277 yield* retryDelete(
278 sasportal.deleteCustomersDeployments({ name: output.name }),
279 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
280 yield* waitUntilGone(getCustomerDeployment(output.name));
281 }),
282 });