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
27 .getProjectsLocationsEnvironmentsUserWorkloadsConfigMaps({ name })
28 .pipe(
29 Effect.as("found" as const),
30 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
31 Effect.repeat({
32 schedule: Schedule.spaced("1 second"),
33 until: (status) => status === "gone",
34 times: 10,
35 }),
36 );
37
38test.provider(
39 "getProjectsLocationsEnvironmentsUserWorkloadsConfigMaps on a missing config map fails with NotFound",
40 (stack) =>
41 Effect.gen(function* () {
42 const { project } = yield* GcpEnvironment.current;
43 const missingParent = missingParentOf(project);
44 yield* stack.destroy();
45
46 const error = yield* Effect.flip(
47 composer.getProjectsLocationsEnvironmentsUserWorkloadsConfigMaps({
48 name: `${missingParent}/userWorkloadsConfigMaps/alchemy-missing`,
49 }),
50 );
51 expect(error._tag).toEqual("NotFound");
52
53 const created = yield* Effect.flip(
54 composer.createProjectsLocationsEnvironmentsUserWorkloadsConfigMaps({
55 parent: missingParent,
56 body: {
57 name: `${missingParent}/userWorkloadsConfigMaps/alchemy-missing`,
58 data: { LOG_LEVEL: "INFO" },
59 },
60 }),
61 );
62 expect(created._tag).toEqual("NotFound");
63
64 yield* stack.destroy();
65 }).pipe(logLevel),
66 { tags: ["provider:gcp", "provider:gcp:composer", "live"], timeout: 90_000 },
67);
68
69test.provider.skipIf(!runLifecycle)(
70 "create, update, and delete a user workloads config map",
71 (stack) =>
72 Effect.gen(function* () {
73 const serviceAccount = yield* defaultComputeServiceAccount;
74 yield* stack.destroy();
75
76 const created = yield* stack.deploy(
77 Effect.gen(function* () {
78 const airflow = yield* GCP.Composer.Environment("Airflow", {
79 location: CAPACITY_REGION,
80 config: {
81 environmentSize: "ENVIRONMENT_SIZE_SMALL",
82 nodeConfig: { serviceAccount },
83 softwareConfig: { imageVersion: "composer-3-airflow-2" },
84 },
85 });
86 const config = yield* GCP.Composer.EnvironmentsUserWorkloadsConfigMap(
87 "TaskConfig",
88 {
89 environmentName: airflow.name,
90 data: { LOG_LEVEL: "INFO" },
91 },
92 );
93 return { airflow, config };
94 }),
95 );
96
97 expect(created.config.name).toContain("/userWorkloadsConfigMaps/");
98 expect(created.config.environmentName).toEqual(created.airflow.name);
99 expect(created.config.data).toMatchObject({ LOG_LEVEL: "INFO" });
100 expect(created.config.data["alchemy-id"]).toBeUndefined();
101
102 const fetched =
103 yield* composer.getProjectsLocationsEnvironmentsUserWorkloadsConfigMaps(
104 { name: created.config.name },
105 );
106 expect(fetched.name).toEqual(created.config.name);
107 expect(fetched.data?.LOG_LEVEL).toEqual("INFO");
108 expect(fetched.data?.["alchemy-id"]).toBeUndefined();
109
110 const updated = yield* stack.deploy(
111 Effect.gen(function* () {
112 const airflow = yield* GCP.Composer.Environment("Airflow", {
113 environmentId: created.airflow.environmentId,
114 location: CAPACITY_REGION,
115 config: {
116 environmentSize: "ENVIRONMENT_SIZE_SMALL",
117 nodeConfig: { serviceAccount },
118 softwareConfig: { imageVersion: "composer-3-airflow-2" },
119 },
120 });
121 const config = yield* GCP.Composer.EnvironmentsUserWorkloadsConfigMap(
122 "TaskConfig",
123 {
124 environmentName: airflow.name,
125 configMapId: created.config.configMapId,
126 data: { LOG_LEVEL: "DEBUG", REGION: "us-central1" },
127 },
128 );
129 return { airflow, config };
130 }),
131 );
132
133 expect(updated.config.name).toEqual(created.config.name);
134 expect(updated.config.data).toMatchObject({
135 LOG_LEVEL: "DEBUG",
136 REGION: "us-central1",
137 });
138
139 const refetched =
140 yield* composer.getProjectsLocationsEnvironmentsUserWorkloadsConfigMaps(
141 { name: created.config.name },
142 );
143 expect(refetched.data?.LOG_LEVEL).toEqual("DEBUG");
144 expect(refetched.data?.REGION).toEqual("us-central1");
145
146 yield* stack.destroy();
147
148 const gone = yield* waitUntilGone(created.config.name);
149 expect(gone).toEqual("gone");
150 }).pipe(logLevel),
151 // Create (~45 min) + update + delete; a timed-out lifecycle is not retried.
152 {
153 tags: ["provider:gcp", "provider:gcp:composer", "live"],
154 timeout: 5_400_000,
155 retry: 0,
156 },
157);