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