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 expandPath,
13 findOwnedCustomerNodeDeployment,
14 getNodeDeployment,
15 hasOwnershipMarker,
16 lastSegment,
17 listCustomerNodeDeployments,
18 MAX_DISPLAY_NAME_LENGTH,
19 ownedByAlchemy,
20 ownershipLabels,
21 parentOf,
22 parseOwnership,
23 replaceOnIdentity,
24 retryDelete,
25 sameStringList,
26 sameText,
27 scanOwnedCustomerNodeDeployment,
28 toDisplayName,
29 updateMaskOf,
30 waitUntilGone,
31 walkCustomerNodes,
32} from "./internal.ts";
33
34export type CustomersNodesDeploymentProps = {
35 /**
36 * Parent SAS node, as `customers/{customer}/nodes/{node}`.
37 * Immutable — changing it replaces the deployment.
38 */
39 parent: string;
40 /**
41 * Server-assigned deployment resource name
42 * (`customers/{customer}/nodes/{node}/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 CustomersNodesDeployment = Resource<
60 "GCP.SasPortal.CustomersNodesDeployment",
61 CustomersNodesDeploymentProps,
62 {
63 /** Resource name `customers/{customer}/nodes/{node}/deployments/{deployment}`. */
64 name: string;
65 /** Deployment id (last path segment). */
66 deploymentId: string;
67 /** Parent 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 node.
84 *
85 * SAS Portal deployments have no labels field, so Alchemy stamps
86 * ownership into `displayName` for `list` / nuke. Parent is identity.
87 * Display name and SAS user ids update in place. `frns` is output-only.
88 *
89 * ### Creating a Deployment
90 * **Example:** Explicit name and SAS user id
91 * ```typescript
92 * const deployment = yield* GCP.SasPortal.CustomersNodesDeployment("Site", {
93 * parent: node.name,
94 * displayName: "downtown",
95 * sasUserIds: ["operator-1"],
96 * });
97 * ```
98 *
99 * ### Updating a Deployment
100 * Change props on the same logical id; the engine keeps the physical id.
101 *
102 * **Example:** Rename
103 * ```typescript
104 * const deployment = yield* GCP.SasPortal.CustomersNodesDeployment("Site", {
105 * parent: node.name,
106 * displayName: "downtown-west",
107 * sasUserIds: ["operator-1"],
108 * });
109 * ```
110 *
111 * @resource
112 * @category SasPortal
113 */
114export const CustomersNodesDeployment = Resource<CustomersNodesDeployment>(
115 "GCP.SasPortal.CustomersNodesDeployment",
116);
117
118export class CustomersNodesDeploymentNotResolved extends Data.TaggedError(
119 "GCP.SasPortal.CustomersNodesDeploymentNotResolved",
120)<{
121 parent: string;
122 name: string;
123}> {}
124
125const toAttrs = (
126 deployment: sasportal.SasPortalDeployment,
127 parent: string,
128 project: string,
129) => {
130 const name = deployment.name ?? "";
131 return {
132 name,
133 deploymentId: lastSegment(name),
134 parent: parentOf(name) || parent,
135 project,
136 displayName: parseOwnership(deployment.displayName).text,
137 sasUserIds: deployment.sasUserIds,
138 frns: deployment.frns,
139 };
140};
141
142export const CustomersNodesDeploymentProvider = () =>
143 Provider.succeed(CustomersNodesDeployment, {
144 stables: ["name", "deploymentId", "parent", "project"],
145
146 diff: Effect.fn(function* ({ news, olds, output }) {
147 if (!isResolved(news)) return undefined;
148 return replaceOnIdentity({
149 previousParent: olds?.parent ?? output?.parent,
150 nextParent: expandPath(news.parent),
151 previousId: olds?.name ?? output?.name,
152 nextId: news.name,
153 });
154 }),
155
156 read: Effect.fn(function* ({ id, olds, output }) {
157 const env = yield* GcpEnvironment.current;
158 const parent = expandPath(olds?.parent ?? output?.parent ?? "");
159 const name = olds?.name ?? output?.name ?? "";
160 let existing = yield* getNodeDeployment(name);
161 let locatedParent = existing
162 ? parentOf(existing.name ?? "") || parent
163 : parent;
164 if (existing === undefined) {
165 const found =
166 (yield* findOwnedCustomerNodeDeployment(id, parent)) ??
167 (yield* scanOwnedCustomerNodeDeployment(id, parent));
168 existing = found?.row;
169 locatedParent = found?.parent ?? locatedParent;
170 }
171 if (existing === undefined) return undefined;
172 const attrs = toAttrs(existing, locatedParent, env.project);
173 return (yield* ownedByAlchemy(id, existing.displayName))
174 ? attrs
175 : Unowned(attrs);
176 }),
177
178 list: () =>
179 Effect.gen(function* () {
180 const env = yield* GcpEnvironment.current;
181 const parents = yield* walkCustomerNodes();
182 const pages = yield* Effect.forEach(
183 parents,
184 (entry) => {
185 const parent = entry.node.name ?? "";
186 return parent.length === 0
187 ? Effect.succeed([])
188 : listCustomerNodeDeployments(parent).pipe(
189 Effect.map((rows) =>
190 rows
191 .filter((row) => hasOwnershipMarker(row.displayName))
192 .map((row) => toAttrs(row, parent, env.project)),
193 ),
194 );
195 },
196 { concurrency: 4 },
197 );
198 return pages.flat();
199 }),
200
201 reconcile: Effect.fn(function* ({ id, news, output }) {
202 const env = yield* GcpEnvironment.current;
203 const parent = expandPath(news.parent);
204 const ownership = yield* ownershipLabels(id);
205 const displayName = encodeOwnershipLine(
206 ownership,
207 yield* toDisplayName(id, news.displayName, output?.displayName),
208 MAX_DISPLAY_NAME_LENGTH,
209 );
210 const desired: sasportal.SasPortalDeployment = {
211 displayName,
212 sasUserIds: news.sasUserIds,
213 };
214
215 let current = yield* getNodeDeployment(news.name ?? output?.name ?? "");
216 if (current === undefined) {
217 const found =
218 (yield* findOwnedCustomerNodeDeployment(id, parent)) ??
219 (yield* scanOwnedCustomerNodeDeployment(id, parent));
220 current = found?.row;
221 }
222
223 if (current === undefined) {
224 const created = yield* sasportal
225 .createCustomersNodesDeployments({
226 parent,
227 body: desired,
228 })
229 .pipe(
230 Effect.catchTag("Conflict", () =>
231 findOwnedCustomerNodeDeployment(id, parent).pipe(
232 Effect.map((found) => found?.row),
233 ),
234 ),
235 );
236 current = created ?? undefined;
237 }
238
239 if (current === undefined) {
240 return yield* new CustomersNodesDeploymentNotResolved({
241 parent,
242 name: news.name ?? output?.name ?? displayName,
243 });
244 }
245
246 const name = current.name ?? news.name ?? output?.name ?? "";
247 const nameChanged = !sameText(current.displayName, displayName);
248 const usersChanged =
249 news.sasUserIds !== undefined &&
250 !sameStringList(current.sasUserIds, news.sasUserIds);
251 if (nameChanged || usersChanged) {
252 current = yield* sasportal.patchNodesDeployments({
253 name,
254 updateMask: updateMaskOf(
255 "displayName",
256 news.sasUserIds !== undefined ? "sasUserIds" : undefined,
257 ),
258 body: desired,
259 });
260 }
261
262 return toAttrs(current, parent, env.project);
263 }),
264
265 delete: Effect.fn(function* ({ output }) {
266 if (!output.name) return;
267 yield* retryDelete(
268 sasportal.deleteNodesDeployments({ name: output.name }),
269 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
270 yield* waitUntilGone(getNodeDeployment(output.name));
271 }),
272 });