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