1import * as config from "@distilled.cloud/gcp/config_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 expandNamed,
14 fingerprint,
15 listAtLocation,
16 listLabeledPages,
17 normalizeLocation,
18 parentOf,
19 parseName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 serviceAccountName,
23 stringMap,
24 toPhysicalId,
25 userLabels,
26 waitForOperation,
27 waitUntilExists,
28 waitUntilGone,
29} from "./internal.ts";
30
31export type PreviewProps = {
32 /**
33 * Preview id (the `{preview}` segment of
34 * `projects/{project}/locations/{location}/previews/{preview}`).
35 * If omitted, a unique RFC1035 name is generated from the stack, stage,
36 * and logical id. Immutable — changing it replaces the preview.
37 */
38 previewId?: string;
39 /**
40 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
41 * replaces the preview. `US-CENTRAL1` is accepted and normalized to
42 * `us-central1`.
43 * @default the stack's GCP region (`GCP.Region`, else the profile
44 * region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * Terraform blueprint to preview. Required unless `deployment` is set.
49 * There is no update API — changing the blueprint replaces the preview.
50 */
51 terraformBlueprint?: config.TerraformBlueprint;
52 /**
53 * Existing deployment whose current state is used as the preview
54 * baseline, as
55 * `projects/{project}/locations/{location}/deployments/{deployment}`
56 * or a deployment id. Changing it replaces the preview.
57 */
58 deployment?: string;
59 /**
60 * Preview mode (`DEFAULT` or `DELETE`). Changing it replaces the
61 * preview.
62 */
63 previewMode?: config.PreviewPreviewModeEnum | (string & {});
64 /**
65 * Service account used to preview resources. Format
66 * `projects/{project}/serviceAccounts/{email}`, or an email / account
67 * id which is expanded. Required by the API. Changing it replaces the
68 * preview.
69 */
70 serviceAccount: string;
71 /**
72 * GCS prefix for Cloud Build logs and artifacts (`gs://{bucket}/{folder}`).
73 * Changing it replaces the preview.
74 */
75 artifactsGcsBucket?: string;
76 /**
77 * Cloud Build worker pool
78 * `projects/{project}/locations/{location}/workerPools/{workerPoolId}`.
79 * Changing it replaces the preview.
80 */
81 workerPool?: string;
82 /**
83 * Terraform version constraint (e.g. `"=1.5.7"`). Changing it replaces
84 * the preview.
85 */
86 tfVersionConstraint?: string;
87 /**
88 * Terraform provider source configuration. Changing it replaces the
89 * preview.
90 */
91 providerConfig?: config.ProviderConfig;
92 /**
93 * User annotations. Changing them replaces the preview (no patch API).
94 */
95 annotations?: Record<string, string>;
96 /**
97 * User labels. Alchemy ownership labels are merged in automatically.
98 * Changing them replaces the preview (no patch API).
99 */
100 labels?: Record<string, string>;
101};
102
103export type Preview = Resource<
104 "GCP.Config.Preview",
105 PreviewProps,
106 {
107 /** Full resource name. */
108 name: string;
109 /** Preview id (last path segment). */
110 previewId: string;
111 /** Project id. */
112 project: string;
113 /** Location id. */
114 location: string;
115 /** Terraform blueprint that was previewed. */
116 terraformBlueprint: config.TerraformBlueprint | undefined;
117 /** Baseline deployment resource name. */
118 deployment: string | undefined;
119 /** Preview mode. */
120 previewMode: string | undefined;
121 /** Service account used for the preview. */
122 serviceAccount: string | undefined;
123 /** Artifacts bucket prefix. */
124 artifactsGcsBucket: string | undefined;
125 /** Cloud Build worker pool. */
126 workerPool: string | undefined;
127 /** Terraform version constraint. */
128 tfVersionConstraint: string | undefined;
129 /** Provider configuration. */
130 providerConfig: config.ProviderConfig | undefined;
131 /** Server-reported preview state. */
132 state: string | undefined;
133 /** Server-reported error code. */
134 errorCode: string | undefined;
135 /** Cloud Build id. */
136 build: string | undefined;
137 /** Preview artifacts. */
138 previewArtifacts: config.PreviewArtifacts | undefined;
139 /** Preview logs GCS URI. */
140 logs: string | undefined;
141 /** Resolved Terraform version. */
142 tfVersion: string | undefined;
143 /** User annotations. */
144 annotations: Record<string, string>;
145 /** User labels (Alchemy ownership labels stripped). */
146 labels: Record<string, string>;
147 /** RFC3339 creation timestamp. */
148 createTime: string | undefined;
149 },
150 never,
151 Providers
152>;
153
154/**
155 * An Infrastructure Manager preview — the Terraform plan Infra Manager
156 * would apply to reach the desired blueprint (or to delete a
157 * deployment).
158 *
159 * Previews have no patch API. Changing identity (`previewId`,
160 * `location`) or any input (blueprint, service account, labels, …)
161 * replaces the preview. Create and delete are long-running operations
162 * and typically invoke Cloud Build.
163 *
164 * ### Creating a Preview
165 * **Example:** Git blueprint
166 * ```typescript
167 * const preview = yield* GCP.Config.Preview("Plan", {
168 * serviceAccount:
169 * "projects/my-project/serviceAccounts/sa@my-project.iam.gserviceaccount.com",
170 * terraformBlueprint: {
171 * gitSource: {
172 * repo: "https://github.com/terraform-google-modules/terraform-docs-samples.git",
173 * directory: "storage/quickstart",
174 * },
175 * },
176 * labels: { env: "test" },
177 * });
178 * ```
179 *
180 * **Example:** Preview against an existing deployment
181 * ```typescript
182 * const preview = yield* GCP.Config.Preview("Plan", {
183 * serviceAccount:
184 * "projects/my-project/serviceAccounts/sa@my-project.iam.gserviceaccount.com",
185 * deployment: existingDeployment,
186 * previewMode: "DEFAULT",
187 * });
188 * ```
189 *
190 * @resource
191 * @category Config
192 */
193export const Preview = Resource<Preview>("GCP.Config.Preview");
194
195const resourceName = (project: string, location: string, previewId: string) =>
196 `projects/${project}/locations/${location}/previews/${previewId}`;
197
198const toAttrs = (item: config.Preview, project: string, region: string) => {
199 const name = item.name ?? "";
200 const parsed = parseName(name, "previews", region);
201 return {
202 name,
203 previewId: parsed.id,
204 project: parsed.project || project,
205 location: parsed.location,
206 terraformBlueprint: item.terraformBlueprint,
207 deployment: item.deployment,
208 previewMode: item.previewMode,
209 serviceAccount: item.serviceAccount,
210 artifactsGcsBucket: item.artifactsGcsBucket,
211 workerPool: item.workerPool,
212 tfVersionConstraint: item.tfVersionConstraint,
213 providerConfig: item.providerConfig,
214 state: item.state,
215 errorCode: item.errorCode,
216 build: item.build,
217 previewArtifacts: item.previewArtifacts,
218 logs: item.logs,
219 tfVersion: item.tfVersion,
220 annotations: stringMap(item.annotations),
221 labels: userLabels(item.labels),
222 createTime: item.createTime,
223 };
224};
225
226const snapshotOf = (input: {
227 terraformBlueprint?: config.TerraformBlueprint;
228 deployment?: string;
229 previewMode?: string;
230 serviceAccount?: string;
231 artifactsGcsBucket?: string;
232 workerPool?: string;
233 tfVersionConstraint?: string;
234 providerConfig?: config.ProviderConfig;
235 annotations?: Record<string, string>;
236 labels?: Record<string, string>;
237}) => ({
238 terraformBlueprint: input.terraformBlueprint,
239 deployment: input.deployment,
240 previewMode: input.previewMode,
241 serviceAccount: input.serviceAccount,
242 artifactsGcsBucket: input.artifactsGcsBucket,
243 workerPool: input.workerPool,
244 tfVersionConstraint: input.tfVersionConstraint,
245 providerConfig: input.providerConfig,
246 annotations: input.annotations ?? {},
247 labels: toLabels(input.labels),
248});
249
250const getByName = (name: string) =>
251 config
252 .getProjectsLocationsPreviews({ name })
253 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
254
255const listOwned = (project: string, region: string) =>
256 listAtLocation(project, region, (parent) =>
257 listLabeledPages(
258 config.listProjectsLocationsPreviews.pages({
259 parent,
260 pageSize: 1000,
261 }),
262 (page) => page.previews,
263 (item) => item.labels,
264 ),
265 );
266
267export const PreviewProvider = () =>
268 Provider.succeed(Preview, {
269 stables: ["name", "previewId", "project", "location", "createTime"],
270
271 diff: Effect.fn(function* ({ news, olds, output }) {
272 if (!isResolved(news)) return undefined;
273 const env = yield* GcpEnvironment.current;
274 const location = normalizeLocation(
275 news.location ?? olds?.location ?? output?.location,
276 env.region,
277 );
278 const extra =
279 olds === undefined
280 ? false
281 : fingerprint(
282 snapshotOf({
283 terraformBlueprint: olds.terraformBlueprint,
284 deployment:
285 olds.deployment === undefined
286 ? undefined
287 : expandNamed(
288 olds.deployment,
289 env.project,
290 location,
291 "deployments",
292 ),
293 previewMode: olds.previewMode,
294 serviceAccount: serviceAccountName(
295 olds.serviceAccount,
296 env.project,
297 ),
298 artifactsGcsBucket: olds.artifactsGcsBucket,
299 workerPool: olds.workerPool,
300 tfVersionConstraint: olds.tfVersionConstraint,
301 providerConfig: olds.providerConfig,
302 annotations: olds.annotations,
303 labels: olds.labels,
304 }),
305 ) !==
306 fingerprint(
307 snapshotOf({
308 terraformBlueprint: news.terraformBlueprint,
309 deployment:
310 news.deployment === undefined
311 ? undefined
312 : expandNamed(
313 news.deployment,
314 env.project,
315 location,
316 "deployments",
317 ),
318 previewMode: news.previewMode,
319 serviceAccount: serviceAccountName(
320 news.serviceAccount,
321 env.project,
322 ),
323 artifactsGcsBucket: news.artifactsGcsBucket,
324 workerPool: news.workerPool,
325 tfVersionConstraint: news.tfVersionConstraint,
326 providerConfig: news.providerConfig,
327 annotations: news.annotations,
328 labels: news.labels,
329 }),
330 );
331 return replaceOnIdentity({
332 previousId: olds?.previewId ?? output?.previewId,
333 nextId: news.previewId ?? olds?.previewId ?? output?.previewId,
334 previousLocation: normalizeLocation(
335 olds?.location ?? output?.location,
336 env.region,
337 ),
338 nextLocation: location,
339 extra,
340 });
341 }),
342
343 read: Effect.fn(function* ({ id, olds, output }) {
344 const env = yield* GcpEnvironment.current;
345 const previewId = yield* toPhysicalId(
346 id,
347 olds?.previewId,
348 output?.previewId,
349 "preview",
350 );
351 const location = normalizeLocation(
352 olds?.location ?? output?.location,
353 env.region,
354 );
355 const name =
356 output?.name ?? resourceName(env.project, location, previewId);
357 const existing = yield* getByName(name);
358 if (existing === undefined) return undefined;
359 const attrs = toAttrs(existing, env.project, env.region);
360 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
361 ? attrs
362 : Unowned(attrs);
363 }),
364
365 list: () =>
366 Effect.gen(function* () {
367 const env = yield* GcpEnvironment.current;
368 const items = yield* listOwned(env.project, env.region);
369 return items.map((item) => toAttrs(item, env.project, env.region));
370 }),
371
372 reconcile: Effect.fn(function* ({ id, news, output }) {
373 const env = yield* GcpEnvironment.current;
374 const previewId = yield* toPhysicalId(
375 id,
376 news.previewId,
377 output?.previewId,
378 "preview",
379 );
380 const location = normalizeLocation(
381 news.location ?? output?.location,
382 env.region,
383 );
384 const name = resourceName(env.project, location, previewId);
385 const desiredLabels = {
386 ...toLabels(news.labels),
387 ...(yield* createInternalLabels(id)),
388 };
389 const desiredAnnotations = news.annotations ?? {};
390 const serviceAccount = serviceAccountName(
391 news.serviceAccount,
392 env.project,
393 );
394 const deployment =
395 news.deployment === undefined
396 ? undefined
397 : expandNamed(news.deployment, env.project, location, "deployments");
398
399 let current = yield* getByName(output?.name ?? name);
400
401 if (current === undefined) {
402 const created = yield* config
403 .createProjectsLocationsPreviews({
404 parent: parentOf(env.project, location),
405 previewId,
406 body: {
407 terraformBlueprint: news.terraformBlueprint,
408 deployment,
409 previewMode: news.previewMode,
410 serviceAccount,
411 artifactsGcsBucket: news.artifactsGcsBucket,
412 workerPool: news.workerPool,
413 tfVersionConstraint: news.tfVersionConstraint,
414 providerConfig: news.providerConfig,
415 annotations: desiredAnnotations,
416 labels: desiredLabels,
417 },
418 })
419 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
420 if (created !== undefined) {
421 yield* waitForOperation(created);
422 }
423 current = yield* waitUntilExists(getByName(name), name);
424 }
425
426 if (current === undefined) {
427 return yield* new ResourceNotResolved({ name });
428 }
429
430 return toAttrs(current, env.project, env.region);
431 }),
432
433 delete: Effect.fn(function* ({ output }) {
434 const operation = yield* config
435 .deleteProjectsLocationsPreviews({
436 name: output.name,
437 })
438 .pipe(
439 Effect.retry({
440 while: (error) => error._tag === "Conflict",
441 times: 8,
442 schedule: Schedule.spaced("2 seconds"),
443 }),
444 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
445 );
446 if (operation !== undefined) {
447 yield* waitForOperation(operation, { notFoundOk: true });
448 }
449 yield* waitUntilGone(getByName(output.name), output.name);
450 }),
451 });