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