1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as composer from "@distilled.cloud/gcp/composer_v1";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import { GcpEnvironment } from "@/GCP/Environment";
9import { defaultComputeServiceAccount } from "./serviceAccount.ts";
10import { CAPACITY_REGION } from "../zones.ts";
11
12const { test } = Test.make({ providers: GCP.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Composer environments take 20-45 minutes to provision.
20const runLifecycle = !!process.env.GCP_TEST_SLOW && !process.env.FAST;
21
22const missingParentOf = (project: string) =>
23 `projects/${project}/locations/${CAPACITY_REGION}/environments/alchemy-composer-missing`;
24
25const waitUntilGone = (name: string) =>
26 composer.getProjectsLocationsEnvironmentsUserWorkloadsSecrets({ name }).pipe(
27 Effect.as("found" as const),
28 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
29 Effect.repeat({
30 schedule: Schedule.spaced("1 second"),
31 until: (status) => status === "gone",
32 times: 10,
33 }),
34 );
35
36test.provider(
37 "getProjectsLocationsEnvironmentsUserWorkloadsSecrets on a missing secret fails with NotFound",
38 (stack) =>
39 Effect.gen(function* () {
40 const { project } = yield* GcpEnvironment.current;
41 const missingParent = missingParentOf(project);
42 yield* stack.destroy();
43
44 const error = yield* Effect.flip(
45 composer.getProjectsLocationsEnvironmentsUserWorkloadsSecrets({
46 name: `${missingParent}/userWorkloadsSecrets/alchemy-missing`,
47 }),
48 );
49 expect(error._tag).toEqual("NotFound");
50
51 const created = yield* Effect.flip(
52 composer.createProjectsLocationsEnvironmentsUserWorkloadsSecrets({
53 parent: missingParent,
54 body: {
55 name: `${missingParent}/userWorkloadsSecrets/alchemy-missing`,
56 data: { password: btoa("s3cret") },
57 },
58 }),
59 );
60 expect(created._tag).toEqual("NotFound");
61
62 yield* stack.destroy();
63 }).pipe(logLevel),
64 { tags: ["provider:gcp", "provider:gcp:composer", "live"], timeout: 90_000 },
65);
66
67test.provider.skipIf(!runLifecycle)(
68 "create, update, and delete a user workloads secret",
69 (stack) =>
70 Effect.gen(function* () {
71 const serviceAccount = yield* defaultComputeServiceAccount;
72 yield* stack.destroy();
73
74 const created = yield* stack.deploy(
75 Effect.gen(function* () {
76 const airflow = yield* GCP.Composer.Environment("Airflow", {
77 location: CAPACITY_REGION,
78 config: {
79 environmentSize: "ENVIRONMENT_SIZE_SMALL",
80 nodeConfig: { serviceAccount },
81 softwareConfig: { imageVersion: "composer-3-airflow-2" },
82 },
83 });
84 const secret = yield* GCP.Composer.EnvironmentsUserWorkloadsSecret(
85 "TaskSecret",
86 {
87 environmentName: airflow.name,
88 data: { password: btoa("s3cret") },
89 },
90 );
91 return { airflow, secret };
92 }),
93 );
94
95 expect(created.secret.name).toContain("/userWorkloadsSecrets/");
96 expect(created.secret.environmentName).toEqual(created.airflow.name);
97 expect(created.secret.data["alchemy-id"]).toBeUndefined();
98
99 const fetched =
100 yield* composer.getProjectsLocationsEnvironmentsUserWorkloadsSecrets({
101 name: created.secret.name,
102 });
103 expect(fetched.name).toEqual(created.secret.name);
104 expect(Object.keys(fetched.data ?? {}).sort()).toEqual(["password"]);
105
106 const updated = yield* stack.deploy(
107 Effect.gen(function* () {
108 const airflow = yield* GCP.Composer.Environment("Airflow", {
109 environmentId: created.airflow.environmentId,
110 location: CAPACITY_REGION,
111 config: {
112 environmentSize: "ENVIRONMENT_SIZE_SMALL",
113 nodeConfig: { serviceAccount },
114 softwareConfig: { imageVersion: "composer-3-airflow-2" },
115 },
116 });
117 const secret = yield* GCP.Composer.EnvironmentsUserWorkloadsSecret(
118 "TaskSecret",
119 {
120 environmentName: airflow.name,
121 secretId: created.secret.secretId,
122 data: { password: btoa("rotated"), token: btoa("abc123") },
123 },
124 );
125 return { airflow, secret };
126 }),
127 );
128
129 expect(updated.secret.name).toEqual(created.secret.name);
130
131 const refetched =
132 yield* composer.getProjectsLocationsEnvironmentsUserWorkloadsSecrets({
133 name: created.secret.name,
134 });
135 expect(Object.keys(refetched.data ?? {}).sort()).toEqual([
136 "password",
137 "token",
138 ]);
139
140 yield* stack.destroy();
141
142 const gone = yield* waitUntilGone(created.secret.name);
143 expect(gone).toEqual("gone");
144 }).pipe(logLevel),
145 // Create (~45 min) + update + delete; a timed-out lifecycle is not retried.
146 {
147 tags: ["provider:gcp", "provider:gcp:composer", "live"],
148 timeout: 5_400_000,
149 retry: 0,
150 },
151);