1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { stripInternalLabels } from "../Labels.ts";
10import { waitForOperation as waitForLongRunning } from "../Operation.ts";
11
12export const DEFAULT_LOCATION = "global";
13export const DEFAULT_ZONE = "us-central1-a";
14export const MAX_NAME_LENGTH = 63;
15
16export class ResourceNotResolved extends Data.TaggedError(
17 "GCP.NetworkSecurity.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export class ResourceStillExists extends Data.TaggedError(
23 "GCP.NetworkSecurity.ResourceStillExists",
24)<{
25 name: string;
26}> {}
27
28export class OrganizationRequired extends Data.TaggedError(
29 "GCP.NetworkSecurity.OrganizationRequired",
30)<{
31 project: string;
32}> {}
33
34export const lastSegment = (value: string) => {
35 const trimmed = value.replace(/\/+$/, "");
36 const parts = trimmed.split("/");
37 return parts[parts.length - 1] || trimmed;
38};
39
40export const rfc1035 = (name: string, fallback = "resource"): string => {
41 let next = name
42 .toLowerCase()
43 .replace(/[^a-z0-9-]/g, "-")
44 .replace(/-+/g, "-")
45 .replace(/^-+/, "")
46 .replace(/-+$/, "");
47 if (!/^[a-z]/.test(next)) {
48 next = `${fallback[0] ?? "n"}${next}`;
49 }
50 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
51 return next.length > 0 ? next : fallback;
52};
53
54export const normalizeLocation = (
55 location: string | undefined,
56 fallback = DEFAULT_LOCATION,
57) => lastSegment(location ?? fallback).toLowerCase();
58
59export const normalizeZone = (
60 location: string | undefined,
61 fallback = DEFAULT_ZONE,
62) => lastSegment(location ?? fallback).toLowerCase();
63
64export const projectParent = (project: string, location: string) =>
65 `projects/${project}/locations/${location}`;
66
67export const organizationParent = (organization: string, location: string) =>
68 `organizations/${lastSegment(organization)}/locations/${location}`;
69
70export const parseResourceName = (name: string) => {
71 const parts = name.split("/").filter((part) => part.length > 0);
72 const locationsAt = parts.lastIndexOf("locations");
73 const projectsAt = parts.lastIndexOf("projects");
74 const organizationsAt = parts.lastIndexOf("organizations");
75 const parentAt = organizationsAt >= 0 ? organizationsAt : projectsAt;
76 return {
77 parentId: parentAt >= 0 && parts[parentAt + 1] ? parts[parentAt + 1]! : "",
78 location:
79 locationsAt >= 0 && parts[locationsAt + 1]
80 ? parts[locationsAt + 1]!
81 : DEFAULT_LOCATION,
82 id: lastSegment(name),
83 };
84};
85
86export const toPhysicalId = (
87 id: string,
88 explicit: string | undefined,
89 existing: string | undefined,
90 fallback = "resource",
91) =>
92 Effect.gen(function* () {
93 if (explicit !== undefined) return explicit;
94 if (existing !== undefined) return existing;
95 return rfc1035(
96 yield* createPhysicalName({
97 id,
98 maxLength: MAX_NAME_LENGTH,
99 lowercase: true,
100 }),
101 fallback,
102 );
103 });
104
105export const userLabels = (
106 labels: Record<string, string | undefined> | null | undefined,
107): Record<string, string> => stripInternalLabels(tagRecord(labels));
108
109export const sameJson = (left: unknown, right: unknown) =>
110 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
111
112export const sortedStrings = (values: ReadonlyArray<string> | undefined) =>
113 [...(values ?? [])].map((value) => value).sort();
114
115export const sameStringList = (
116 left: readonly string[] | undefined,
117 right: readonly string[] | undefined,
118) =>
119 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
120
121/**
122 * Wait for a Network Security long-running operation. Cloud NGFW firewall
123 * endpoints take ~20 minutes to provision; intercept and mirroring
124 * deployments take several minutes.
125 * `ALREADY_EXISTS` (a concurrent create won) always succeeds;
126 * `notFoundOk` also accepts a `NOT_FOUND` result or an operation that has
127 * already been garbage-collected (deletes).
128 */
129export const waitForOperation = (
130 operation: networksecurity.Operation,
131 options?: { notFoundOk?: boolean },
132) =>
133 waitForLongRunning(
134 operation,
135 (name) => networksecurity.getProjectsLocationsOperations({ name }),
136 { budget: "30 minutes" },
137 ).pipe(
138 Effect.catchIf(
139 (error) =>
140 error._tag === "GCP.OperationFailed" &&
141 (error.code === 6 ||
142 (options?.notFoundOk === true && error.code === 5)),
143 () => Effect.void,
144 ),
145 Effect.catchIf(
146 (error) => options?.notFoundOk === true && error._tag === "NotFound",
147 () => Effect.void,
148 ),
149 );
150
151export const waitUntilExists = <A, E, R>(
152 get: Effect.Effect<A | undefined, E, R>,
153 name: string,
154) =>
155 get.pipe(
156 Effect.flatMap((value) =>
157 value
158 ? Effect.succeed(value)
159 : Effect.fail(new ResourceNotResolved({ name })),
160 ),
161 Effect.retry({
162 while: (error) => error instanceof ResourceNotResolved,
163 times: 8,
164 schedule: Schedule.spaced("1 second"),
165 }),
166 );
167
168export const waitUntilGone = <A, E, R>(
169 get: Effect.Effect<A | undefined, E, R>,
170 name: string,
171) =>
172 get.pipe(
173 Effect.flatMap((value) =>
174 value === undefined
175 ? Effect.void
176 : Effect.fail(new ResourceStillExists({ name })),
177 ),
178 Effect.retry({
179 while: (error) => error instanceof ResourceStillExists,
180 times: 10,
181 schedule: Schedule.spaced("2 seconds"),
182 }),
183 );
184
185const parentOf = (name: string) =>
186 name.startsWith("projects/")
187 ? resourcemanager.getProjects({ name }).pipe(
188 Effect.map((resource) => resource.parent),
189 Effect.catchTag("NotFound", () =>
190 Effect.succeed(undefined),
191 ),
192 )
193 : name.startsWith("folders/")
194 ? resourcemanager.getFolders({ name }).pipe(
195 Effect.map((folder) => folder.parent),
196 Effect.catchTag("NotFound", () =>
197 Effect.succeed(undefined),
198 ),
199 )
200 : Effect.succeed(undefined);
201
202const tryResolveOrganizationId = (project: string) =>
203 Effect.gen(function* () {
204 let current: string | undefined = `projects/${project}`;
205 for (let i = 0; i < 8; i++) {
206 if (current === undefined) return undefined;
207 if (current.startsWith("organizations/")) return lastSegment(current);
208 current = yield* parentOf(current);
209 }
210 return undefined;
211 });
212
213export const resolveOrganization = (
214 explicit: string | undefined,
215 existing: string | undefined,
216) =>
217 Effect.gen(function* () {
218 if (explicit !== undefined) return lastSegment(explicit);
219 if (existing !== undefined) return lastSegment(existing);
220 const env = yield* GcpEnvironment.current;
221 const resolved = yield* tryResolveOrganizationId(env.project);
222 if (resolved === undefined) {
223 return yield* new OrganizationRequired({ project: env.project });
224 }
225 return resolved;
226 });
227
228export const listOrganizations = (project: string) =>
229 Effect.gen(function* () {
230 const resolved = yield* tryResolveOrganizationId(project);
231 return resolved === undefined ? [] : [resolved];
232 });