1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20export type BackendBucketProps = {
21 /**
22 * Name of the backend bucket. If omitted, a unique RFC1035 name is
23 * generated from the stack, stage, and logical id. Changing this
24 * replaces the resource.
25 */
26 name?: string;
27 /**
28 * Cloud Storage bucket this backend serves. The bucket must already
29 * exist in the same project.
30 */
31 bucketName: string;
32 /**
33 * Optional textual description. Alchemy ownership is stamped into the
34 * stored description; this field is the user-facing portion.
35 */
36 description?: string;
37 /**
38 * Enable Cloud CDN for this backend bucket. Cannot be true when
39 * `loadBalancingScheme` is `INTERNAL_MANAGED`.
40 * @default false
41 */
42 enableCdn?: boolean;
43 /**
44 * Compress text responses using Brotli or gzip based on
45 * `Accept-Encoding`.
46 */
47 compressionMode?: "AUTOMATIC" | "DISABLED";
48 /**
49 * Headers the Application Load Balancer should add to proxied
50 * responses, e.g. `"X-Frame-Options: DENY"`.
51 */
52 customResponseHeaders?: string[];
53 /**
54 * Load balancing scheme. `INTERNAL_MANAGED` is required for
55 * cross-region internal Application Load Balancers. Changing this
56 * replaces the resource.
57 */
58 loadBalancingScheme?: "EXTERNAL_MANAGED" | "INTERNAL_MANAGED";
59 /**
60 * Cloud CDN policy. Only applied when set; omitted values leave the
61 * observed policy in place.
62 */
63 cdnPolicy?: compute.BackendBucketCdnPolicy;
64};
65
66export type BackendBucket = Resource<
67 "GCP.Compute.BackendBucket",
68 BackendBucketProps,
69 {
70 /** RFC1035 resource name. */
71 name: string;
72 /** Cloud Storage bucket name. */
73 bucketName: string;
74 /** Project id. */
75 project: string;
76 /** User-facing description (Alchemy ownership marker stripped). */
77 description: string | undefined;
78 /** Whether Cloud CDN is enabled. */
79 enableCdn: boolean;
80 /** Compression mode, if set. */
81 compressionMode: string | undefined;
82 /** Response headers added by the load balancer. */
83 customResponseHeaders: string[];
84 /** Load balancing scheme, if set. */
85 loadBalancingScheme: string | undefined;
86 /** Cloud CDN policy, if configured. */
87 cdnPolicy: compute.BackendBucketCdnPolicy | undefined;
88 /** Server-defined resource URL. */
89 selfLink: string | undefined;
90 /** RFC3339 creation timestamp. */
91 creationTimestamp: string | undefined;
92 /** Server-assigned numeric id. */
93 id: string | undefined;
94 },
95 never,
96 Providers
97>;
98
99/**
100 * A global Compute Engine backend bucket that fronts a Cloud Storage
101 * bucket for HTTP(S) load balancing.
102 *
103 * Compute Engine backend buckets have no labels field, so Alchemy
104 * stamps ownership into the description (`[alchemy alchemy-stack=…
105 * alchemy-stage=… alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find
106 * them.
107 *
108 * ### Creating a Backend Bucket
109 * **Example:** Generated name in front of a Storage bucket
110 * ```typescript
111 * const assets = yield* GCP.Storage.Bucket("assets", {
112 * forceDestroy: true,
113 * });
114 * const backend = yield* GCP.Compute.BackendBucket("cdn", {
115 * bucketName: assets.bucketName,
116 * description: "static assets",
117 * });
118 * ```
119 *
120 * **Example:** Explicit name with Cloud CDN
121 * ```typescript
122 * const backend = yield* GCP.Compute.BackendBucket("cdn", {
123 * name: "app-static",
124 * bucketName: assets.bucketName,
125 * enableCdn: true,
126 * compressionMode: "AUTOMATIC",
127 * customResponseHeaders: ["X-Frame-Options: DENY"],
128 * });
129 * ```
130 *
131 * @resource
132 * @category Compute
133 */
134export const BackendBucket = Resource<BackendBucket>(
135 "GCP.Compute.BackendBucket",
136);
137
138export class BackendBucketNotResolved extends Data.TaggedError(
139 "GCP.Compute.BackendBucketNotResolved",
140)<{
141 name: string;
142}> {}
143
144const rfc1035 = (name: string): string => {
145 let next = name
146 .toLowerCase()
147 .replace(/[^a-z0-9-]/g, "-")
148 .replace(/-+/g, "-")
149 .replace(/^-+/, "")
150 .replace(/-+$/, "");
151 if (!/^[a-z]/.test(next)) {
152 next = `b${next}`;
153 }
154 next = next.slice(0, 63).replace(/-+$/, "");
155 return next.length > 0 ? next : "backend";
156};
157
158const toName = (id: string, name: string | undefined, existing?: string) =>
159 Effect.gen(function* () {
160 if (name !== undefined) return name;
161 if (existing !== undefined) return existing;
162 return rfc1035(
163 yield* createPhysicalName({
164 id,
165 maxLength: 63,
166 lowercase: true,
167 }),
168 );
169 });
170
171const encodeDescription = (
172 user: string | undefined,
173 labels: Record<string, string>,
174): string => {
175 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
176 return user ? `${marker}\n${user}` : marker;
177};
178
179const parseDescription = (
180 description: string | undefined,
181): { labels: Record<string, string>; user: string | undefined } => {
182 if (!description?.startsWith("[alchemy ")) {
183 return { labels: {}, user: description };
184 }
185 const end = description.indexOf("]");
186 if (end < 0) return { labels: {}, user: description };
187 const labels: Record<string, string> = {};
188 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
189 const eq = part.indexOf("=");
190 if (eq > 0) {
191 labels[part.slice(0, eq)] = part.slice(eq + 1);
192 }
193 }
194 const rest = description.slice(end + 1).replace(/^\n/, "");
195 return { labels, user: rest.length > 0 ? rest : undefined };
196};
197
198const hasOwnershipMarker = (description: string | undefined): boolean =>
199 (description ?? "").startsWith("[alchemy ");
200
201const toAttrs = (bucket: compute.BackendBucket, project: string) => ({
202 name: bucket.name ?? "",
203 bucketName: bucket.bucketName ?? "",
204 project,
205 description: parseDescription(bucket.description).user,
206 enableCdn: bucket.enableCdn === true,
207 compressionMode: bucket.compressionMode,
208 customResponseHeaders: bucket.customResponseHeaders ?? [],
209 loadBalancingScheme: bucket.loadBalancingScheme,
210 cdnPolicy: bucket.cdnPolicy,
211 selfLink: bucket.selfLink,
212 creationTimestamp: bucket.creationTimestamp,
213 id: bucket.id,
214});
215
216const sameList = (
217 left: readonly string[] | undefined,
218 right: readonly string[] | undefined,
219): boolean => JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
220
221const normalizeCdnPolicy = (
222 policy: compute.BackendBucketCdnPolicy | undefined,
223): compute.BackendBucketCdnPolicy | undefined => {
224 if (policy === undefined) return undefined;
225 const { signedUrlKeyNames: _signed, ...rest } = policy;
226 return rest;
227};
228
229const sameCdnPolicy = (
230 left: compute.BackendBucketCdnPolicy | undefined,
231 right: compute.BackendBucketCdnPolicy | undefined,
232): boolean =>
233 JSON.stringify(normalizeCdnPolicy(left) ?? null) ===
234 JSON.stringify(normalizeCdnPolicy(right) ?? null);
235
236const getByName = (project: string, name: string) =>
237 compute
238 .getBackendBuckets({ project, backendBucket: name })
239 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
240
241const awaitResource = (project: string, name: string) =>
242 getByName(project, name).pipe(
243 Effect.repeat({
244 schedule: Schedule.spaced("1 second"),
245 until: (bucket) => bucket !== undefined,
246 times: 8,
247 }),
248 );
249
250export const BackendBucketProvider = () =>
251 Provider.succeed(BackendBucket, {
252 stables: ["name", "project", "selfLink", "creationTimestamp", "id"],
253
254 diff: Effect.fn(function* ({ news, olds, output }) {
255 if (!isResolved(news)) return undefined;
256 const previousName = olds?.name ?? output?.name;
257 if (
258 news.name !== undefined &&
259 previousName !== undefined &&
260 news.name !== previousName
261 ) {
262 return { action: "replace" as const };
263 }
264 const previousScheme =
265 olds?.loadBalancingScheme ?? output?.loadBalancingScheme;
266 const nextScheme = news.loadBalancingScheme;
267 if ((previousScheme ?? "") !== (nextScheme ?? "")) {
268 return {
269 action: "replace" as const,
270 deleteFirst:
271 previousName !== undefined &&
272 (news.name === undefined || news.name === previousName),
273 };
274 }
275 return undefined;
276 }),
277
278 read: Effect.fn(function* ({ id, olds, output }) {
279 const env = yield* GcpEnvironment.current;
280 const name = yield* toName(id, olds?.name, output?.name);
281 const existing = yield* getByName(env.project, name);
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, env.project);
284 const parsed = parseDescription(existing.description);
285 return (yield* hasAlchemyLabels(id, parsed.labels))
286 ? attrs
287 : Unowned(attrs);
288 }),
289
290 list: () =>
291 Effect.gen(function* () {
292 const env = yield* GcpEnvironment.current;
293 return yield* compute.listBackendBuckets
294 .items({ project: env.project, maxResults: 500 })
295 .pipe(
296 Stream.filter((item) => hasOwnershipMarker(item.description)),
297 Stream.map((item) => toAttrs(item, env.project)),
298 Stream.runCollect,
299 Effect.map((chunk) => Array.from(chunk)),
300 );
301 }),
302
303 reconcile: Effect.fn(function* ({ id, news, output }) {
304 const env = yield* GcpEnvironment.current;
305 const name = yield* toName(id, news.name, output?.name);
306 const ownership = yield* createInternalLabels(id);
307 const desiredDescription = encodeDescription(news.description, ownership);
308 const enableCdn = news.enableCdn === true;
309 const desiredHeaders = news.customResponseHeaders ?? [];
310
311 let current = yield* getByName(env.project, name);
312
313 if (current === undefined) {
314 const inserted = yield* compute
315 .insertBackendBuckets({
316 project: env.project,
317 body: {
318 name,
319 bucketName: news.bucketName,
320 description: desiredDescription,
321 enableCdn,
322 compressionMode: news.compressionMode,
323 customResponseHeaders:
324 desiredHeaders.length > 0 ? desiredHeaders : undefined,
325 loadBalancingScheme: news.loadBalancingScheme,
326 cdnPolicy: news.cdnPolicy,
327 },
328 })
329 .pipe(
330 Effect.flatMap((operation) =>
331 waitGlobalOperation(env.project, operation),
332 ),
333 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
334 );
335 if (inserted !== undefined) {
336 current = yield* awaitResource(env.project, name);
337 } else {
338 current = yield* getByName(env.project, name);
339 }
340 }
341
342 if (current === undefined) {
343 return yield* new BackendBucketNotResolved({ name });
344 }
345
346 const observedDescription = current.description ?? "";
347 const descriptionChanged = observedDescription !== desiredDescription;
348 const bucketChanged = (current.bucketName ?? "") !== news.bucketName;
349 const cdnChanged = (current.enableCdn === true) !== enableCdn;
350 const compressionChanged =
351 news.compressionMode !== undefined &&
352 (current.compressionMode ?? "") !== news.compressionMode;
353 const headersChanged = !sameList(
354 current.customResponseHeaders,
355 desiredHeaders,
356 );
357 const policyChanged =
358 news.cdnPolicy !== undefined &&
359 !sameCdnPolicy(current.cdnPolicy, news.cdnPolicy);
360
361 if (
362 descriptionChanged ||
363 bucketChanged ||
364 cdnChanged ||
365 compressionChanged ||
366 headersChanged ||
367 policyChanged
368 ) {
369 yield* compute
370 .patchBackendBuckets({
371 project: env.project,
372 backendBucket: name,
373 body: {
374 bucketName: news.bucketName,
375 description: desiredDescription,
376 enableCdn,
377 compressionMode: news.compressionMode,
378 customResponseHeaders: desiredHeaders,
379 cdnPolicy: news.cdnPolicy,
380 },
381 })
382 .pipe(
383 Effect.flatMap((operation) =>
384 waitGlobalOperation(env.project, operation),
385 ),
386 );
387 current = yield* awaitResource(env.project, name);
388 }
389
390 if (current === undefined) {
391 return yield* new BackendBucketNotResolved({ name });
392 }
393
394 return toAttrs(current, env.project);
395 }),
396
397 delete: Effect.fn(function* ({ output }) {
398 const env = yield* GcpEnvironment.current;
399 yield* compute
400 .deleteBackendBuckets({
401 project: env.project,
402 backendBucket: output.name,
403 })
404 .pipe(
405 Effect.flatMap((operation) =>
406 waitGlobalOperation(env.project, operation),
407 ),
408 Effect.catchTag("NotFound", () => Effect.void),
409 );
410 }),
411 });