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