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