1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { tagRecord } from "../../Tags.ts";
7import { alchemyLabelKeys, stripInternalLabels } from "../Labels.ts";
8
9export const DEFAULT_GLOBAL = "global";
10export const DEFAULT_ZONE = "us-central1-a";
11export const MAX_NAME_LENGTH = 63;
12
13export class NetworksecurityNotResolved extends Data.TaggedError(
14 "GCP.NetworkSecurity.NotResolved",
15)<{
16 name: string;
17}> {}
18
19export class NetworksecurityStillExists extends Data.TaggedError(
20 "GCP.NetworkSecurity.StillExists",
21)<{
22 name: string;
23}> {}
24
25export class NetworksecurityFailed extends Data.TaggedError(
26 "GCP.NetworkSecurity.Failed",
27)<{
28 name: string;
29 state: string | undefined;
30}> {}
31
32export const lastSegment = (value: string) => {
33 const trimmed = value.replace(/\/+$/, "");
34 const parts = trimmed.split("/");
35 return parts[parts.length - 1] || trimmed;
36};
37
38export const rfc1035 = (name: string, fallback = "resource"): string => {
39 let next = name
40 .toLowerCase()
41 .replace(/[^a-z0-9-]/g, "-")
42 .replace(/-+/g, "-")
43 .replace(/^-+/, "")
44 .replace(/-+$/, "");
45 if (!/^[a-z]/.test(next)) {
46 next = `n${next}`;
47 }
48 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
49 return next.length > 0 ? next : fallback;
50};
51
52export const normalizeLocation = (
53 location: string | undefined,
54 fallback: string,
55) => lastSegment(location ?? fallback).toLowerCase();
56
57export const parentOf = (project: string, location: string) =>
58 `projects/${project}/locations/${location}`;
59
60export const parseName = (
61 name: string,
62 collection: string,
63 fallbackLocation: string,
64) => {
65 const parts = name.split("/").filter((part) => part.length > 0);
66 const collectionAt = parts.lastIndexOf(collection);
67 const locationsAt = parts.lastIndexOf("locations");
68 const projectsAt = parts.lastIndexOf("projects");
69 return {
70 project:
71 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
72 location:
73 locationsAt >= 0 && parts[locationsAt + 1]
74 ? parts[locationsAt + 1]!
75 : fallbackLocation,
76 id:
77 collectionAt >= 0 && parts[collectionAt + 1]
78 ? parts[collectionAt + 1]!
79 : lastSegment(name),
80 };
81};
82
83export const toPhysicalId = (
84 id: string,
85 explicit: string | undefined,
86 existing: string | undefined,
87 fallback = "resource",
88) =>
89 Effect.gen(function* () {
90 if (explicit !== undefined) return rfc1035(explicit, fallback);
91 if (existing !== undefined) return existing;
92 return rfc1035(
93 yield* createPhysicalName({
94 id,
95 maxLength: MAX_NAME_LENGTH,
96 lowercase: true,
97 }),
98 fallback,
99 );
100 });
101
102export const userLabels = (
103 labels: Record<string, string | undefined> | null | undefined,
104): Record<string, string> => stripInternalLabels(tagRecord(labels));
105
106export const hasAlchemyLabelKeys = (
107 labels: Record<string, string | undefined> | null | undefined,
108) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
109
110export const encodeOwnership = (
111 labels: Record<string, string>,
112 text: string | undefined,
113): string => {
114 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
115 const trimmed = text?.trim();
116 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
117};
118
119export const parseOwnership = (
120 text: string | undefined,
121): {
122 labels: Record<string, string>;
123 text: string | undefined;
124} => {
125 if (!text?.startsWith("[alchemy ")) {
126 return { labels: {}, text };
127 }
128 const end = text.indexOf("]");
129 if (end < 0) return { labels: {}, text };
130 const labels: Record<string, string> = {};
131 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
132 const eq = part.indexOf("=");
133 if (eq > 0) {
134 labels[part.slice(0, eq)] = part.slice(eq + 1);
135 }
136 }
137 const rest = text.slice(end + 1).replace(/^\s+/, "");
138 return { labels, text: rest.length > 0 ? rest : undefined };
139};
140
141export const hasOwnershipMarker = (text: string | undefined) =>
142 Object.keys(parseOwnership(text).labels).some((key) =>
143 key.startsWith("alchemy-"),
144 );
145
146export const sameJson = (left: unknown, right: unknown) =>
147 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
148
149export const sameStringList = (
150 left: readonly string[] | undefined,
151 right: readonly string[] | undefined,
152) =>
153 JSON.stringify([...(left ?? [])].map(canonicalizeLink).sort()) ===
154 JSON.stringify([...(right ?? [])].map(canonicalizeLink).sort());
155
156export const canonicalizeLink = (value: string | undefined) => {
157 if (value === undefined || value.length === 0) return "";
158 return value
159 .replace(/^https?:\/\/[^/]+\//, "")
160 .replace(/^compute\/v1\//, "")
161 .replace(/\/+$/, "");
162};
163
164export const linkKey = (value: string | undefined) =>
165 lastSegment(canonicalizeLink(value)).toLowerCase();
166
167export const changedFields = (
168 pairs: ReadonlyArray<readonly [string, boolean]>,
169) => pairs.filter(([, changed]) => changed).map(([field]) => field);
170
171export const waitUntilPresent = <A, E, R>(
172 get: Effect.Effect<A | undefined, E, R>,
173 name: string,
174) =>
175 get.pipe(
176 Effect.flatMap((value) =>
177 value
178 ? Effect.succeed(value)
179 : Effect.fail(new NetworksecurityNotResolved({ name })),
180 ),
181 Effect.retry({
182 while: (error) => error instanceof NetworksecurityNotResolved,
183 times: 8,
184 schedule: Schedule.spaced("1 second"),
185 }),
186 );
187
188export const waitUntilGone = <A, E, R>(
189 get: Effect.Effect<A | undefined, E, R>,
190 name: string,
191) =>
192 get.pipe(
193 Effect.flatMap((value) =>
194 value === undefined
195 ? Effect.void
196 : Effect.fail(new NetworksecurityStillExists({ name })),
197 ),
198 Effect.retry({
199 while: (error) => error instanceof NetworksecurityStillExists,
200 times: 10,
201 schedule: Schedule.spaced("2 seconds"),
202 }),
203 );
204
205const PENDING_STATES = new Set([
206 "STATE_UNSPECIFIED",
207 "CREATING",
208 "UPDATING",
209 "DELETING",
210 "ACCEPTING",
211 "REJECTING",
212]);
213
214const FAILED_STATES = new Set(["FAILED", "DELETE_FAILED", "OBSOLETE"]);
215
216export const waitUntilReady = <A extends { state?: string }, E, R>(
217 get: Effect.Effect<A | undefined, E, R>,
218 name: string,
219) =>
220 get.pipe(
221 Effect.filterOrFail(
222 (value): value is A => value !== undefined,
223 () => new NetworksecurityNotResolved({ name }),
224 ),
225 Effect.filterOrFail(
226 (value) => !FAILED_STATES.has(value.state ?? ""),
227 (value) => new NetworksecurityFailed({ name, state: value.state }),
228 ),
229 Effect.filterOrFail(
230 (value) => !PENDING_STATES.has(value.state ?? ""),
231 () => new NetworksecurityNotResolved({ name }),
232 ),
233 Effect.retry({
234 while: (error) => error instanceof NetworksecurityNotResolved,
235 times: 10,
236 schedule: Schedule.spaced("4 seconds"),
237 }),
238 );
239
240/** Collect every page; a missing parent (`NotFound`) lists as empty. */
241export const collectPages = <
242 Page,
243 Item,
244 E extends { readonly _tag: string },
245 R,
246>(
247 stream: Stream.Stream<Page, E, R>,
248 pick: (page: Page) => readonly Item[] | undefined,
249) =>
250 stream.pipe(
251 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
252 Stream.runCollect,
253 Effect.map((chunk): Item[] => Array.from(chunk)),
254 Effect.catchIf(
255 (error) => error._tag === "NotFound",
256 () => Effect.succeed<Item[]>([]),
257 ),
258 );
259
260export { waitForOperation } from "./operations.ts";