1import { ignoredCodes } from "./internal.ts";
2import * as compute from "@distilled.cloud/gcp/compute_v1";
3import { waitRegionOperation } from "./operations.ts";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 alchemyLabelKeys,
16 createInternalLabels,
17 hasAlchemyLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const MAX_NAME_LENGTH = 63;
22
23export type NetworkEdgeSecurityServiceProps = {
24 /**
25 * Service name (RFC1035, 1-63 characters). If omitted, a unique name is
26 * generated from the stack, stage, and logical id. Immutable — changing
27 * it replaces the service.
28 */
29 networkEdgeSecurityServiceName?: string;
30 /**
31 * Region the service lives in. Immutable — changing it replaces the
32 * service. `US-CENTRAL1` is accepted and normalized to `us-central1`.
33 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
34 */
35 region?: string;
36 /**
37 * Optional description. Network edge security services have no labels
38 * field, so Alchemy ownership (`alchemy-stack` / `alchemy-stage` /
39 * `alchemy-id`) is stored in a `[alchemy …]` prefix for `list` / nuke.
40 * Updated in place via `patch`.
41 */
42 description?: string;
43 /**
44 * Cloud Armor network security policy URL attached to this service.
45 * Updated in place via `patch`.
46 */
47 securityPolicy?: string;
48};
49
50export type NetworkEdgeSecurityService = Resource<
51 "GCP.Compute.NetworkEdgeSecurityService",
52 NetworkEdgeSecurityServiceProps,
53 {
54 /** Service name. */
55 networkEdgeSecurityServiceName: string;
56 /** Project id. */
57 project: string;
58 /** Region short name (`us-central1`). */
59 region: string;
60 /** User description with the Alchemy ownership prefix stripped. */
61 description: string | undefined;
62 /** Attached Cloud Armor network security policy URL. */
63 securityPolicy: string | undefined;
64 /** Optimistic-locking fingerprint. */
65 fingerprint: string | undefined;
66 /** Server-assigned numeric id. */
67 networkEdgeSecurityServiceId: string | undefined;
68 /** Resource self-link. */
69 selfLink: string | undefined;
70 /** Self-link including the numeric id. */
71 selfLinkWithId: string | undefined;
72 /** RFC3339 creation timestamp. */
73 creationTimestamp: string | undefined;
74 /** Resource kind. */
75 kind: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A regional Compute Engine network edge security service.
83 *
84 * Network edge security services attach a Cloud Armor network (L3/L4)
85 * security policy to a region. Name and region are immutable. Description
86 * and `securityPolicy` update in place via
87 * `networkEdgeSecurityServices.patch`. Compute NetworkEdgeSecurityService
88 * has no labels field — Alchemy stamps ownership into the description so
89 * nuke can find leaked services.
90 *
91 * ### Creating a Network Edge Security Service
92 * **Example:** Generated name
93 * ```typescript
94 * const ness = yield* GCP.Compute.NetworkEdgeSecurityService("EdgeArmor", {
95 * region: "us-central1",
96 * description: "regional network armor",
97 * });
98 * ```
99 *
100 * **Example:** Attach a network security policy
101 * ```typescript
102 * const ness = yield* GCP.Compute.NetworkEdgeSecurityService("EdgeArmor", {
103 * networkEdgeSecurityServiceName: "app-ness",
104 * securityPolicy: policy.selfLink,
105 * });
106 * ```
107 *
108 * @resource
109 * @category Compute
110 */
111export const NetworkEdgeSecurityService = Resource<NetworkEdgeSecurityService>(
112 "GCP.Compute.NetworkEdgeSecurityService",
113);
114
115export class NetworkEdgeSecurityServiceNotResolved extends Data.TaggedError(
116 "GCP.Compute.NetworkEdgeSecurityServiceNotResolved",
117)<{
118 networkEdgeSecurityServiceName: string;
119 region: string;
120}> {}
121
122export class NetworkEdgeSecurityServiceStillExists extends Data.TaggedError(
123 "GCP.Compute.NetworkEdgeSecurityServiceStillExists",
124)<{
125 networkEdgeSecurityServiceName: string;
126}> {}
127
128const lastSegment = (value: string | undefined): string => {
129 if (value === undefined || value.length === 0) return "";
130 const trimmed = value.replace(/\/+$/, "");
131 const parts = trimmed.split("/");
132 return parts[parts.length - 1] || trimmed;
133};
134
135const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
136 lastSegment(region ?? defaultRegion).toLowerCase();
137
138const rfc1035 = (name: string): string => {
139 let next = name
140 .toLowerCase()
141 .replace(/[^a-z0-9-]/g, "-")
142 .replace(/-+/g, "-")
143 .replace(/^-+/, "")
144 .replace(/-+$/, "");
145 if (!/^[a-z]/.test(next)) next = `e${next}`;
146 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
147 return next.length > 0 ? next : "ness";
148};
149
150const toName = (id: string, name: string | undefined, existing?: string) =>
151 Effect.gen(function* () {
152 if (name !== undefined) return name;
153 if (existing !== undefined) return existing;
154 return rfc1035(
155 yield* createPhysicalName({
156 id,
157 maxLength: MAX_NAME_LENGTH,
158 lowercase: true,
159 }),
160 );
161 });
162
163const encodeDescription = (
164 labels: Record<string, string>,
165 description: string | undefined,
166): string => {
167 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
168 return description ? `${marker}\n${description}` : marker;
169};
170
171const parseDescription = (
172 description: string | undefined,
173): {
174 labels: Record<string, string>;
175 description: string | undefined;
176} => {
177 if (!description?.startsWith("[alchemy ")) {
178 return { labels: {}, description };
179 }
180 const end = description.indexOf("]");
181 if (end < 0) return { labels: {}, description };
182 const labels: Record<string, string> = {};
183 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
184 const eq = part.indexOf("=");
185 if (eq > 0) labels[part.slice(0, eq)] = part.slice(eq + 1);
186 }
187 const rest = description.slice(end + 1).replace(/^\n/, "");
188 return { labels, description: rest.length > 0 ? rest : undefined };
189};
190
191const hasOwnershipMarker = (description: string | undefined) =>
192 Object.keys(parseDescription(description).labels).some((key) =>
193 key.startsWith("alchemy-"),
194 );
195
196const toAttrs = (
197 service: compute.NetworkEdgeSecurityService,
198 project: string,
199): NetworkEdgeSecurityService["Attributes"] => {
200 const parsed = parseDescription(service.description);
201 return {
202 networkEdgeSecurityServiceName: service.name ?? "",
203 project,
204 region: lastSegment(service.region).toLowerCase(),
205 description: parsed.description,
206 securityPolicy: service.securityPolicy,
207 fingerprint: service.fingerprint,
208 networkEdgeSecurityServiceId: service.id,
209 selfLink: service.selfLink,
210 selfLinkWithId: service.selfLinkWithId,
211 creationTimestamp: service.creationTimestamp,
212 kind: service.kind,
213 };
214};
215
216const getByName = (
217 project: string,
218 region: string,
219 networkEdgeSecurityService: string,
220) =>
221 compute
222 .getNetworkEdgeSecurityServices({
223 project,
224 region,
225 networkEdgeSecurityService,
226 })
227 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
228
229const awaitResource = (
230 project: string,
231 region: string,
232 networkEdgeSecurityServiceName: string,
233) =>
234 getByName(project, region, networkEdgeSecurityServiceName).pipe(
235 Effect.flatMap((service) =>
236 service !== undefined
237 ? Effect.succeed(service)
238 : Effect.fail(
239 new NetworkEdgeSecurityServiceNotResolved({
240 networkEdgeSecurityServiceName,
241 region,
242 }),
243 ),
244 ),
245 Effect.retry({
246 while: (error) =>
247 error._tag === "GCP.Compute.NetworkEdgeSecurityServiceNotResolved",
248 times: 8,
249 schedule: Schedule.spaced("1 second"),
250 }),
251 );
252
253const waitUntilGone = (
254 project: string,
255 region: string,
256 networkEdgeSecurityServiceName: string,
257) =>
258 getByName(project, region, networkEdgeSecurityServiceName).pipe(
259 Effect.flatMap((service) =>
260 service === undefined
261 ? Effect.void
262 : Effect.fail(
263 new NetworkEdgeSecurityServiceStillExists({
264 networkEdgeSecurityServiceName,
265 }),
266 ),
267 ),
268 Effect.retry({
269 while: (error) =>
270 error._tag === "GCP.Compute.NetworkEdgeSecurityServiceStillExists",
271 times: 10,
272 schedule: Schedule.spaced("2 seconds"),
273 }),
274 Effect.catchTag(
275 "GCP.Compute.NetworkEdgeSecurityServiceStillExists",
276 () => Effect.void,
277 ),
278 );
279
280const runOp = <E extends { readonly _tag: string }, R>(
281 project: string,
282 region: string,
283 networkEdgeSecurityServiceName: string,
284 start: Effect.Effect<compute.Operation, E, R>,
285 options?: { ignoreAlreadyExists?: boolean; ignoreNotFound?: boolean },
286) =>
287 start.pipe(
288 Effect.flatMap((operation) =>
289 waitRegionOperation(project, region, operation, {
290 ignore: ignoredCodes(options),
291 }),
292 ),
293 Effect.retry({
294 while: (error) => error._tag === "Conflict",
295 times: 5,
296 schedule: Schedule.spaced("1 second"),
297 }),
298 );
299
300export const NetworkEdgeSecurityServiceProvider = () =>
301 Provider.succeed(NetworkEdgeSecurityService, {
302 stables: [
303 "networkEdgeSecurityServiceName",
304 "project",
305 "region",
306 "networkEdgeSecurityServiceId",
307 "selfLink",
308 "selfLinkWithId",
309 "creationTimestamp",
310 ],
311
312 diff: Effect.fn(function* ({ news, olds, output }) {
313 if (!isResolved(news)) return undefined;
314 const env = yield* GcpEnvironment.current;
315 const previousName =
316 olds?.networkEdgeSecurityServiceName ??
317 output?.networkEdgeSecurityServiceName;
318 const nextName = news.networkEdgeSecurityServiceName ?? previousName;
319 const nameChanged =
320 previousName !== undefined &&
321 nextName !== undefined &&
322 previousName !== nextName;
323 const previousRegion = normalizeRegion(
324 olds?.region ?? output?.region,
325 env.region,
326 );
327 const nextRegion = normalizeRegion(
328 news.region ?? previousRegion,
329 env.region,
330 );
331 if (nameChanged) {
332 return { action: "replace" as const, deleteFirst: false };
333 }
334 if (previousRegion !== nextRegion) {
335 return { action: "replace" as const, deleteFirst: true };
336 }
337 return undefined;
338 }),
339
340 read: Effect.fn(function* ({ id, olds, output }) {
341 const env = yield* GcpEnvironment.current;
342 const networkEdgeSecurityServiceName = yield* toName(
343 id,
344 olds?.networkEdgeSecurityServiceName,
345 output?.networkEdgeSecurityServiceName,
346 );
347 const region = normalizeRegion(
348 olds?.region ?? output?.region,
349 env.region,
350 );
351 const existing = yield* getByName(
352 env.project,
353 region,
354 networkEdgeSecurityServiceName,
355 );
356 if (existing === undefined) return undefined;
357 const attrs = toAttrs(existing, env.project);
358 const { labels } = parseDescription(existing.description);
359 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
360 }),
361
362 list: () =>
363 Effect.gen(function* () {
364 const env = yield* GcpEnvironment.current;
365 const pages = yield* compute.aggregatedListNetworkEdgeSecurityServices
366 .pages({
367 project: env.project,
368 returnPartialSuccess: true,
369 maxResults: 500,
370 })
371 .pipe(Stream.runCollect);
372 return Array.from(pages).flatMap((page) =>
373 Object.values(page.items ?? {}).flatMap((scoped) =>
374 (scoped?.networkEdgeSecurityServices ?? [])
375 .filter((item) => hasOwnershipMarker(item.description))
376 .map((item) => toAttrs(item, env.project)),
377 ),
378 );
379 }),
380
381 reconcile: Effect.fn(function* ({ id, news, output }) {
382 const env = yield* GcpEnvironment.current;
383 const networkEdgeSecurityServiceName = yield* toName(
384 id,
385 news.networkEdgeSecurityServiceName,
386 output?.networkEdgeSecurityServiceName,
387 );
388 const region = normalizeRegion(news.region ?? output?.region, env.region);
389 const ownership = yield* createInternalLabels(id);
390 const desiredDescription = encodeDescription(ownership, news.description);
391
392 let current = yield* getByName(
393 env.project,
394 region,
395 networkEdgeSecurityServiceName,
396 );
397
398 if (current === undefined) {
399 yield* compute
400 .insertNetworkEdgeSecurityServices({
401 project: env.project,
402 region,
403 body: {
404 name: networkEdgeSecurityServiceName,
405 description: desiredDescription,
406 securityPolicy: news.securityPolicy,
407 },
408 })
409 .pipe(
410 Effect.flatMap((operation) =>
411 waitRegionOperation(env.project, region, operation, {
412 ignore: ["RESOURCE_ALREADY_EXISTS"],
413 }),
414 ),
415 Effect.catchTag("Conflict", () => Effect.void),
416 );
417 current = yield* awaitResource(
418 env.project,
419 region,
420 networkEdgeSecurityServiceName,
421 );
422 }
423
424 const needsPatch =
425 (current.description ?? "") !== desiredDescription ||
426 (news.securityPolicy !== undefined &&
427 lastSegment(current.securityPolicy) !==
428 lastSegment(news.securityPolicy));
429
430 if (needsPatch) {
431 const latest =
432 (yield* getByName(
433 env.project,
434 region,
435 networkEdgeSecurityServiceName,
436 )) ?? current;
437 yield* runOp(
438 env.project,
439 region,
440 networkEdgeSecurityServiceName,
441 compute.patchNetworkEdgeSecurityServices({
442 project: env.project,
443 region,
444 networkEdgeSecurityService: networkEdgeSecurityServiceName,
445 body: {
446 fingerprint: latest.fingerprint,
447 description: desiredDescription,
448 securityPolicy: news.securityPolicy ?? current.securityPolicy,
449 },
450 }),
451 );
452 current =
453 (yield* getByName(
454 env.project,
455 region,
456 networkEdgeSecurityServiceName,
457 )) ?? current;
458 }
459
460 return toAttrs(current, env.project);
461 }),
462
463 delete: Effect.fn(function* ({ output }) {
464 if (!output.networkEdgeSecurityServiceName) return;
465 const env = yield* GcpEnvironment.current;
466 const project = output.project || env.project;
467 const region = normalizeRegion(output.region, env.region);
468 yield* compute
469 .deleteNetworkEdgeSecurityServices({
470 project,
471 region,
472 networkEdgeSecurityService: output.networkEdgeSecurityServiceName,
473 })
474 .pipe(
475 Effect.flatMap((operation) =>
476 waitRegionOperation(project, region, operation, {
477 ignore: ["RESOURCE_NOT_FOUND"],
478 }),
479 ),
480 Effect.catchTag("NotFound", () => Effect.void),
481 Effect.retry({
482 while: (error) => error._tag === "Conflict",
483 times: 8,
484 schedule: Schedule.spaced("2 seconds"),
485 }),
486 );
487 yield* waitUntilGone(
488 project,
489 region,
490 output.networkEdgeSecurityServiceName,
491 );
492 }),
493 });