1import * as workloadmanager from "@distilled.cloud/gcp/workloadmanager_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 expandNamed,
11 hasAlchemyActuationId,
12 lastSegment,
13 listAtNested,
14 listPages,
15 normalizeLocation,
16 parseName,
17 replaceOnIdentity,
18 resourceNameFromOperation,
19 ResourceNotResolved,
20 toActuationId,
21 waitForOperation,
22 waitUntilExists,
23 waitUntilGone,
24} from "./internal.ts";
25
26export type ActuationOutput = {
27 /** Blueprint Controller deployment and revision resource. */
28 blueprintId?: string;
29 /** Terraform template used. */
30 terraformTemplate?: string;
31 /** Cloud Storage file that stores build logs. */
32 actuateLogs?: string;
33 /** Error code when actuation failed. */
34 errorCode?: string;
35 /** Error message returned from Ansible. */
36 ansibleError?: string;
37 /** Error message returned from Terraform. */
38 terraformError?: string;
39 /** Cloud Build instance UUID. */
40 cloudbuildId?: string;
41 /** Failed Ansible task names. */
42 ansibleFailedTask?: string[];
43 /** Link to the actuation Cloud Build log. */
44 errorLogs?: string;
45 /** Whether the error message is user facing. */
46 hasUserFacingErrorMsg?: boolean;
47};
48
49export type DeploymentOutput = {
50 /** Type of the deployed resource. */
51 type?: string;
52 /** Name of the deployed resource. */
53 name?: string;
54};
55
56export type DeploymentsActuationProps = {
57 /**
58 * Parent Deployment. Full name
59 * `projects/{project}/locations/{location}/deployments/{deployment}`
60 * or the deployment id (combined with `location`). Immutable —
61 * changing it replaces the actuation.
62 */
63 deployment: string;
64 /**
65 * Region used when `deployment` is a bare id.
66 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
67 */
68 location?: string;
69 /**
70 * Actuation id (the `{actuation}` segment). If omitted, a unique
71 * RFC1035 name prefixed `alch-` is generated from the stack, stage,
72 * and logical id so `list` / nuke can find it (actuations have no
73 * labels or description). Immutable — changing it replaces the
74 * actuation. The API has no patch — actuations are existence-only.
75 */
76 actuationId?: string;
77};
78
79export type DeploymentsActuation = Resource<
80 "GCP.WorkloadManager.DeploymentsActuation",
81 DeploymentsActuationProps,
82 {
83 /** Full resource name. */
84 name: string;
85 /** Actuation id (last path segment). */
86 actuationId: string;
87 /** Parent Deployment name. */
88 deployment: string;
89 /** Project id. */
90 project: string;
91 /** Location id. */
92 location: string;
93 /** Server-reported actuation state. */
94 state: string | undefined;
95 /** Actuation output (logs, errors, Cloud Build id). */
96 actuationOutput: ActuationOutput | undefined;
97 /** Resources produced by the deployment. */
98 deploymentOutput: DeploymentOutput[];
99 /** RFC3339 start timestamp. */
100 startTime: string | undefined;
101 /** RFC3339 end timestamp. */
102 endTime: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * A Workload Manager actuation — a bootstrap run of a Deployment that
110 * applies Terraform/Ansible and records infrastructure output.
111 *
112 * Actuations have no labels, description, or patch API. Identity is
113 * `deployment` + `location` + `actuationId`; changing any replaces the
114 * actuation. Generated ids are prefixed `alch-` so `list` / nuke can
115 * find Alchemy-owned rows.
116 *
117 * ### Creating an Actuation
118 * **Example:** Generated name
119 * ```typescript
120 * const actuation = yield* GCP.WorkloadManager.DeploymentsActuation(
121 * "Bootstrap",
122 * { deployment: existingDeployment.name },
123 * );
124 * ```
125 *
126 * **Example:** Explicit id
127 * ```typescript
128 * const actuation = yield* GCP.WorkloadManager.DeploymentsActuation(
129 * "Bootstrap",
130 * {
131 * deployment: existingDeployment.name,
132 * actuationId: "alch-bootstrap-1",
133 * },
134 * );
135 * ```
136 *
137 * @resource
138 * @category WorkloadManager
139 */
140export const DeploymentsActuation = Resource<DeploymentsActuation>(
141 "GCP.WorkloadManager.DeploymentsActuation",
142);
143
144const resourceName = (deployment: string, actuationId: string) =>
145 `${deployment}/actuations/${actuationId}`;
146
147const toOutput = (
148 output: workloadmanager.ActuationOutput | undefined,
149): ActuationOutput | undefined =>
150 output === undefined
151 ? undefined
152 : {
153 blueprintId: output.blueprintId,
154 terraformTemplate: output.terraformTemplate,
155 actuateLogs: output.actuateLogs,
156 errorCode: output.errorCode,
157 ansibleError: output.ansibleError,
158 terraformError: output.terraformError,
159 cloudbuildId: output.cloudbuildId,
160 ansibleFailedTask: output.ansibleFailedTask,
161 errorLogs: output.errorLogs,
162 hasUserFacingErrorMsg: output.hasUserFacingErrorMsg,
163 };
164
165const toAttrs = (
166 item: workloadmanager.Actuation,
167 project: string,
168 region: string,
169) => {
170 const name = item.name ?? "";
171 const parsed = parseName(name, "actuations", region);
172 return {
173 name,
174 actuationId: parsed.id,
175 deployment: parsed.parent,
176 project: parsed.project || project,
177 location: parsed.location,
178 state: item.state,
179 actuationOutput: toOutput(item.actuationOutput),
180 deploymentOutput: (item.deploymentOutput ?? []).map((entry) => ({
181 type: entry.type,
182 name: entry.name,
183 })),
184 startTime: item.startTime,
185 endTime: item.endTime,
186 };
187};
188
189const getByName = (name: string) =>
190 workloadmanager
191 .getProjectsLocationsDeploymentsActuations({ name })
192 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
193
194const listOwned = (project: string, region: string) =>
195 listAtNested(project, region, "deployments/-", (parent) =>
196 listPages(
197 workloadmanager.listProjectsLocationsDeploymentsActuations.pages({
198 parent,
199 pageSize: 1000,
200 }),
201 (page) => page.actuations,
202 (item) => hasAlchemyActuationId(lastSegment(item.name ?? "")),
203 ),
204 );
205
206export const DeploymentsActuationProvider = () =>
207 Provider.succeed(DeploymentsActuation, {
208 stables: [
209 "name",
210 "actuationId",
211 "deployment",
212 "project",
213 "location",
214 "startTime",
215 ],
216
217 diff: Effect.fn(function* ({ news, olds, output }) {
218 if (!isResolved(news)) return undefined;
219 const env = yield* GcpEnvironment.current;
220 const previousLocation = normalizeLocation(
221 olds?.location ?? output?.location,
222 env.region,
223 );
224 const nextLocation = normalizeLocation(
225 news.location ?? olds?.location ?? output?.location,
226 env.region,
227 );
228 const previousParent =
229 (olds?.deployment ?? output?.deployment)
230 ? expandNamed(
231 olds?.deployment ?? output?.deployment ?? "",
232 env.project,
233 previousLocation,
234 "deployments",
235 )
236 : undefined;
237 const nextParent = expandNamed(
238 news.deployment,
239 env.project,
240 nextLocation,
241 "deployments",
242 );
243 return replaceOnIdentity({
244 previousId: olds?.actuationId ?? output?.actuationId,
245 nextId: news.actuationId ?? olds?.actuationId ?? output?.actuationId,
246 previousLocation,
247 nextLocation,
248 previousParent,
249 nextParent,
250 });
251 }),
252
253 read: Effect.fn(function* ({ id, olds, output }) {
254 const env = yield* GcpEnvironment.current;
255 const actuationId = yield* toActuationId(
256 id,
257 olds?.actuationId,
258 output?.actuationId,
259 );
260 const location = normalizeLocation(
261 olds?.location ?? output?.location,
262 env.region,
263 );
264 const deployment = expandNamed(
265 olds?.deployment ?? output?.deployment ?? "",
266 env.project,
267 location,
268 "deployments",
269 );
270 const name = output?.name ?? resourceName(deployment, actuationId);
271 const existing = yield* getByName(name);
272 if (existing === undefined) return undefined;
273 return toAttrs(existing, env.project, env.region);
274 }),
275
276 list: () =>
277 Effect.gen(function* () {
278 const env = yield* GcpEnvironment.current;
279 const items = yield* listOwned(env.project, env.region);
280 return items.map((item) => toAttrs(item, env.project, env.region));
281 }),
282
283 reconcile: Effect.fn(function* ({ id, news, output }) {
284 const env = yield* GcpEnvironment.current;
285 const actuationId = yield* toActuationId(
286 id,
287 news.actuationId,
288 output?.actuationId,
289 );
290 const location = normalizeLocation(
291 news.location ?? output?.location,
292 env.region,
293 );
294 const deployment = expandNamed(
295 news.deployment,
296 env.project,
297 location,
298 "deployments",
299 );
300 const name = resourceName(deployment, actuationId);
301
302 let current = yield* getByName(output?.name ?? name);
303
304 if (current === undefined) {
305 const created = yield* workloadmanager
306 .createProjectsLocationsDeploymentsActuations({
307 parent: deployment,
308 body: { name },
309 })
310 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
311 if (created !== undefined) {
312 // ALREADY_EXISTS (6): a concurrent create won the race.
313 yield* waitForOperation(created).pipe(
314 Effect.catchTag("GCP.OperationFailed", (error) =>
315 error.code === 6 ? Effect.void : Effect.fail(error),
316 ),
317 );
318 const fromOp = resourceNameFromOperation(created);
319 current = yield* waitUntilExists(
320 getByName(fromOp ?? name),
321 fromOp ?? name,
322 );
323 } else {
324 current = yield* waitUntilExists(getByName(name), name);
325 }
326 }
327
328 if (current === undefined) {
329 return yield* new ResourceNotResolved({ name });
330 }
331
332 return toAttrs(current, env.project, env.region);
333 }),
334
335 delete: Effect.fn(function* ({ output }) {
336 const operation = yield* workloadmanager
337 .deleteProjectsLocationsDeploymentsActuations({
338 name: output.name,
339 })
340 .pipe(
341 Effect.retry({
342 while: (error) => error._tag === "Conflict",
343 times: 8,
344 schedule: Schedule.spaced("2 seconds"),
345 }),
346 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
347 );
348 if (operation !== undefined) {
349 // NOT_FOUND (5): already gone.
350 yield* waitForOperation(operation).pipe(
351 Effect.catchTag("GCP.OperationFailed", (error) =>
352 error.code === 5 ? Effect.void : Effect.fail(error),
353 ),
354 );
355 }
356 yield* waitUntilGone(getByName(output.name), output.name);
357 }),
358 });