1import * as composer from "@distilled.cloud/gcp/composer_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 dataKey,
12 environmentParent,
13 lastSegment,
14 mapOf,
15 parseWorkloadName,
16 toPhysicalId,
17} from "./internal.ts";
18
19export type EnvironmentsUserWorkloadsConfigMapProps = {
20 /**
21 * Parent environment resource name
22 * (`projects/{project}/locations/{location}/environments/{environment}`).
23 * Immutable — changing it replaces the ConfigMap.
24 */
25 environmentName: string;
26 /**
27 * ConfigMap id (the last path segment). If omitted, a unique RFC1035
28 * name is generated from the stack, stage, and logical id. Must start
29 * with a lowercase letter, be 1-63 characters, and match
30 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable — changing it replaces the
31 * ConfigMap.
32 */
33 configMapId?: string;
34 /**
35 * Kubernetes ConfigMap data as key-value pairs. Stored exactly as
36 * given: ConfigMaps have no labels, so Alchemy tracks them by name.
37 */
38 data?: Record<string, string>;
39};
40
41export type EnvironmentsUserWorkloadsConfigMap = Resource<
42 "GCP.Composer.EnvironmentsUserWorkloadsConfigMap",
43 EnvironmentsUserWorkloadsConfigMapProps,
44 {
45 /** Full resource name `{environment}/userWorkloadsConfigMaps/{configMapId}`. */
46 name: string;
47 /** ConfigMap id (last path segment). */
48 configMapId: string;
49 /** Parent environment resource name. */
50 environmentName: string;
51 /** Project id. */
52 project: string;
53 /** Region id (`us-central1`, …). */
54 location: string;
55 /** Parent environment id. */
56 environmentId: string;
57 /** ConfigMap data. */
58 data: Record<string, string>;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * A user workloads ConfigMap for Airflow tasks that run with the
66 * Kubernetes executor or KubernetesPodOperator.
67 *
68 * Supported on Cloud Composer 3 (`composer-3-airflow-2` and newer).
69 * ConfigMaps have no labels field, so Alchemy tracks them by name and
70 * never adds keys to `data`. `environmentName` and `configMapId` are
71 * identity — changing either replaces the ConfigMap.
72 *
73 * ### Creating a User Workloads ConfigMap
74 * **Example:** Generated name
75 * ```typescript
76 * const airflow = yield* GCP.Composer.Environment("Airflow", {
77 * config: {
78 * environmentSize: "ENVIRONMENT_SIZE_SMALL",
79 * nodeConfig: {
80 * serviceAccount: "composer-env@my-project.iam.gserviceaccount.com",
81 * },
82 * softwareConfig: { imageVersion: "composer-3-airflow-2" },
83 * },
84 * });
85 * const config = yield* GCP.Composer.EnvironmentsUserWorkloadsConfigMap(
86 * "TaskConfig",
87 * {
88 * environmentName: airflow.name,
89 * data: { LOG_LEVEL: "INFO" },
90 * },
91 * );
92 * ```
93 *
94 * **Example:** Explicit id
95 * ```typescript
96 * const config = yield* GCP.Composer.EnvironmentsUserWorkloadsConfigMap(
97 * "TaskConfig",
98 * {
99 * environmentName: airflow.name,
100 * configMapId: "task-config",
101 * data: { LOG_LEVEL: "DEBUG", REGION: "us-central1" },
102 * },
103 * );
104 * ```
105 *
106 * ### Updating data
107 * **Example:** Change ConfigMap values
108 * ```typescript
109 * const config = yield* GCP.Composer.EnvironmentsUserWorkloadsConfigMap(
110 * "TaskConfig",
111 * {
112 * environmentName: airflow.name,
113 * configMapId: "task-config",
114 * data: { LOG_LEVEL: "WARN" },
115 * },
116 * );
117 * ```
118 *
119 * @resource
120 * @category Composer
121 */
122export const EnvironmentsUserWorkloadsConfigMap =
123 Resource<EnvironmentsUserWorkloadsConfigMap>(
124 "GCP.Composer.EnvironmentsUserWorkloadsConfigMap",
125 );
126
127export class EnvironmentsUserWorkloadsConfigMapNotResolved extends Data.TaggedError(
128 "GCP.Composer.EnvironmentsUserWorkloadsConfigMapNotResolved",
129)<{
130 name: string;
131}> {}
132
133export class EnvironmentsUserWorkloadsConfigMapStillExists extends Data.TaggedError(
134 "GCP.Composer.EnvironmentsUserWorkloadsConfigMapStillExists",
135)<{
136 name: string;
137}> {}
138
139const resourceName = (environmentName: string, configMapId: string) =>
140 `${environmentParent(environmentName)}/userWorkloadsConfigMaps/${configMapId}`;
141
142const toAttrs = (
143 configMap: composer.UserWorkloadsConfigMap,
144 environmentName: string,
145) => {
146 const name = configMap.name ?? resourceName(environmentName, "");
147 const parsed = parseWorkloadName(name);
148 const parent = environmentParent(environmentName || name);
149 return {
150 name,
151 configMapId: parsed.configMapId ?? lastSegment(name),
152 environmentName: parent,
153 project: parsed.project,
154 location: parsed.location,
155 environmentId: parsed.environmentId,
156 data: mapOf(configMap.data),
157 };
158};
159
160const getByName = (name: string) =>
161 composer
162 .getProjectsLocationsEnvironmentsUserWorkloadsConfigMaps({ name })
163 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
164
165const waitUntilGone = (name: string) =>
166 getByName(name).pipe(
167 Effect.flatMap((current) =>
168 current === undefined
169 ? Effect.void
170 : Effect.fail(
171 new EnvironmentsUserWorkloadsConfigMapStillExists({ name }),
172 ),
173 ),
174 Effect.retry({
175 while: (error) =>
176 error._tag ===
177 "GCP.Composer.EnvironmentsUserWorkloadsConfigMapStillExists",
178 times: 8,
179 schedule: Schedule.spaced("2 seconds"),
180 }),
181 );
182
183export const EnvironmentsUserWorkloadsConfigMapProvider = () =>
184 Provider.succeed(EnvironmentsUserWorkloadsConfigMap, {
185 stables: [
186 "name",
187 "configMapId",
188 "environmentName",
189 "project",
190 "location",
191 "environmentId",
192 ],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const previousId = olds?.configMapId ?? output?.configMapId;
197 const idChanged =
198 previousId !== undefined &&
199 news.configMapId !== undefined &&
200 news.configMapId !== previousId;
201 const previousParent = olds?.environmentName ?? output?.environmentName;
202 const parentChanged =
203 previousParent !== undefined &&
204 environmentParent(news.environmentName) !==
205 environmentParent(previousParent);
206 if (!idChanged && !parentChanged) return undefined;
207 return { action: "replace" as const, deleteFirst: false };
208 }),
209
210 read: Effect.fn(function* ({ id, olds, output }) {
211 const environmentName = olds?.environmentName ?? output?.environmentName;
212 if (environmentName === undefined) return undefined;
213 const configMapId = yield* toPhysicalId(
214 id,
215 olds?.configMapId,
216 output?.configMapId,
217 );
218 const name = output?.name ?? resourceName(environmentName, configMapId);
219 const existing = yield* getByName(name);
220 if (existing === undefined) return undefined;
221 const attrs = toAttrs(existing, environmentName);
222 // No labels: a generated id embeds stack/stage/id, and a row we
223 // created is in state. Only an explicit id found without state is
224 // ambiguous.
225 return output === undefined && olds?.configMapId !== undefined
226 ? Unowned(attrs)
227 : attrs;
228 }),
229
230 reconcile: Effect.fn(function* ({ id, news, output }) {
231 const configMapId = yield* toPhysicalId(
232 id,
233 news.configMapId,
234 output?.configMapId,
235 );
236 const parent = environmentParent(news.environmentName);
237 const name = resourceName(parent, configMapId);
238 const desiredData = mapOf(news.data);
239
240 let current = yield* getByName(output?.name ?? name);
241
242 if (current === undefined) {
243 const created = yield* composer
244 .createProjectsLocationsEnvironmentsUserWorkloadsConfigMaps({
245 parent,
246 body: {
247 name,
248 data: desiredData,
249 },
250 })
251 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
252 current = created ?? undefined;
253 }
254
255 if (current === undefined) {
256 return yield* new EnvironmentsUserWorkloadsConfigMapNotResolved({
257 name,
258 });
259 }
260
261 if (dataKey(current.data) !== dataKey(desiredData)) {
262 current =
263 yield* composer.updateProjectsLocationsEnvironmentsUserWorkloadsConfigMaps(
264 {
265 name: current.name ?? name,
266 body: {
267 name: current.name ?? name,
268 data: desiredData,
269 },
270 },
271 );
272 }
273
274 return toAttrs(current, parent);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 yield* composer
279 .deleteProjectsLocationsEnvironmentsUserWorkloadsConfigMaps({
280 name: output.name,
281 })
282 .pipe(
283 Effect.catchTag("NotFound", () => Effect.void),
284 Effect.retry({
285 while: (error) => error._tag === "Conflict",
286 times: 8,
287 schedule: Schedule.spaced("2 seconds"),
288 }),
289 );
290 yield* waitUntilGone(output.name);
291 }),
292 });