1import * as apphub from "@distilled.cloud/gcp/apphub_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 type Attributes,
13 type WorkloadProperties,
14 type WorkloadReference,
15 encodeOwnership,
16 expandApplication,
17 fieldMask,
18 listNestedOwned,
19 listOwnedPages,
20 locationParent,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseName,
24 parseOwnership,
25 replaceOnIdentity,
26 resolveDiscoveredWorkload,
27 ResourceNotResolved,
28 sameJson,
29 sameText,
30 toPhysicalId,
31 waitForOperation,
32 waitUntilExists,
33 waitUntilGone,
34 waitUntilReady,
35} from "./internal.ts";
36
37export type ApplicationsWorkloadProps = {
38 /**
39 * Parent Application. Full name
40 * `projects/{project}/locations/{location}/applications/{application}`
41 * or the application id (combined with `location`). Immutable —
42 * changing it replaces the workload.
43 */
44 application: string;
45 /**
46 * Region used when `application` is a bare id.
47 * @default the stack's GCP region (`GCP.Region`, else the profile
48 * region, else `us-central1`)
49 */
50 location?: string;
51 /**
52 * Workload id (the `{workload}` segment). Must be RFC1035 (63 chars).
53 * If omitted, a unique name is generated. Immutable — changing it
54 * replaces the workload.
55 */
56 workloadId?: string;
57 /**
58 * Resource name of the original discovered workload, or the underlying
59 * resource URI (looked up via `discoveredWorkloads:lookup`). Immutable
60 * — changing it replaces the workload.
61 */
62 discoveredWorkload: string;
63 /**
64 * User-defined display name. Maximum length is 63 characters. Defaults
65 * to the workload id.
66 */
67 displayName?: string;
68 /**
69 * User-defined description. Maximum length is 2048 characters.
70 * Workloads have no labels field, so Alchemy stamps ownership into a
71 * `[alchemy …]` prefix and strips it from attributes.
72 */
73 description?: string;
74 /**
75 * Consumer-provided attributes (criticality, environment, owners).
76 */
77 attributes?: Attributes;
78};
79
80export type ApplicationsWorkload = Resource<
81 "GCP.AppHub.ApplicationsWorkload",
82 ApplicationsWorkloadProps,
83 {
84 /** Full resource name. */
85 name: string;
86 /** Workload id (last path segment). */
87 workloadId: string;
88 /** Parent Application resource name. */
89 application: string;
90 /** Project id. */
91 project: string;
92 /** Location id. */
93 location: string;
94 /** Discovered workload resource name. */
95 discoveredWorkload: string | undefined;
96 /** User-defined display name. */
97 displayName: string | undefined;
98 /** User description with the Alchemy ownership prefix stripped. */
99 description: string | undefined;
100 /** Consumer-provided attributes. */
101 attributes: Attributes | undefined;
102 /** Reference to the underlying compute resource. */
103 workloadReference: WorkloadReference | undefined;
104 /** Properties of the underlying compute resource. */
105 workloadProperties: WorkloadProperties | undefined;
106 /** Server-reported state. */
107 state: string | undefined;
108 /** Server-generated resource uid. */
109 uid: string | undefined;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 /** RFC3339 last-update timestamp. */
113 updateTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * An App Hub workload registered onto an Application. A workload is a
121 * discovered binary deployment (managed instance group, GKE deployment,
122 * …) that performs the smallest logical subset of business
123 * functionality.
124 *
125 * Workloads have no labels field, so Alchemy stamps ownership into the
126 * description for `list` / nuke. Application, workload id, and
127 * `discoveredWorkload` are immutable. Display name, description, and
128 * attributes update in place.
129 *
130 * ### Creating a Workload
131 * **Example:** Register a discovered workload
132 * ```typescript
133 * const workload = yield* GCP.AppHub.ApplicationsWorkload("Api", {
134 * application: app.name,
135 * discoveredWorkload: discovered.name,
136 * });
137 * ```
138 *
139 * **Example:** Lookup from an underlying resource URI
140 * ```typescript
141 * const workload = yield* GCP.AppHub.ApplicationsWorkload("Api", {
142 * application: app.name,
143 * discoveredWorkload: instanceGroup.instanceGroup,
144 * displayName: "api",
145 * description: "checkout mig",
146 * });
147 * ```
148 *
149 * ### Updating a Workload
150 * Change props on the same logical id; the engine keeps the physical id.
151 *
152 * **Example:** Display name and attributes
153 * ```typescript
154 * const workload = yield* GCP.AppHub.ApplicationsWorkload("Api", {
155 * application: app.name,
156 * discoveredWorkload: discovered.name,
157 * displayName: "api-v2",
158 * attributes: { criticality: { type: "HIGH" } },
159 * });
160 * ```
161 *
162 * @resource
163 * @category AppHub
164 */
165export const ApplicationsWorkload = Resource<ApplicationsWorkload>(
166 "GCP.AppHub.ApplicationsWorkload",
167);
168
169const resourceName = (application: string, workloadId: string) =>
170 `${application}/workloads/${workloadId}`;
171
172const toAttrs = (item: apphub.Workload, project: string, region: string) => {
173 const name = item.name ?? "";
174 const parsed = parseName(name, "workloads", region);
175 const ownership = parseOwnership(item.description);
176 return {
177 name,
178 workloadId: parsed.id,
179 application: parsed.application,
180 project: parsed.project || project,
181 location: parsed.location,
182 discoveredWorkload: item.discoveredWorkload,
183 displayName: item.displayName,
184 description: ownership.text,
185 attributes: item.attributes,
186 workloadReference: item.workloadReference,
187 workloadProperties: item.workloadProperties,
188 state: item.state,
189 uid: item.uid,
190 createTime: item.createTime,
191 updateTime: item.updateTime,
192 };
193};
194
195const getByName = (name: string) =>
196 name.length === 0
197 ? Effect.succeed(undefined)
198 : apphub
199 .getProjectsLocationsApplicationsWorkloads({ name })
200 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
201
202const listOwned = (project: string, region: string) =>
203 listNestedOwned(project, region, (application) =>
204 listOwnedPages(
205 apphub.listProjectsLocationsApplicationsWorkloads.pages({
206 parent: application,
207 pageSize: 1000,
208 }),
209 (page) => page.workloads,
210 (item) => item.description,
211 ),
212 );
213
214export const ApplicationsWorkloadProvider = () =>
215 Provider.succeed(ApplicationsWorkload, {
216 stables: [
217 "name",
218 "workloadId",
219 "application",
220 "project",
221 "location",
222 "discoveredWorkload",
223 "uid",
224 "createTime",
225 ],
226
227 diff: Effect.fn(function* ({ news, olds, output }) {
228 if (!isResolved(news)) return undefined;
229 const env = yield* GcpEnvironment.current;
230 const previousDiscovered =
231 olds?.discoveredWorkload ?? output?.discoveredWorkload;
232 return replaceOnIdentity({
233 previousId: olds?.workloadId ?? output?.workloadId,
234 nextId: news.workloadId ?? olds?.workloadId ?? output?.workloadId,
235 previousLocation: normalizeLocation(
236 olds?.location ?? output?.location,
237 env.region,
238 ),
239 nextLocation: normalizeLocation(
240 news.location ?? olds?.location ?? output?.location,
241 env.region,
242 ),
243 previousParent: olds?.application ?? output?.application,
244 nextParent: news.application,
245 extra:
246 previousDiscovered !== undefined &&
247 news.discoveredWorkload !== undefined &&
248 previousDiscovered !== news.discoveredWorkload &&
249 !previousDiscovered.endsWith(`/${news.discoveredWorkload}`) &&
250 !news.discoveredWorkload.endsWith(`/${previousDiscovered}`),
251 });
252 }),
253
254 read: Effect.fn(function* ({ id, olds, output }) {
255 const env = yield* GcpEnvironment.current;
256 const workloadId = yield* toPhysicalId(
257 id,
258 olds?.workloadId,
259 output?.workloadId,
260 "workload",
261 );
262 const location = normalizeLocation(
263 olds?.location ?? output?.location,
264 env.region,
265 );
266 const application = expandApplication(
267 olds?.application ?? output?.application ?? "",
268 env.project,
269 location,
270 );
271 const name = output?.name ?? resourceName(application, workloadId);
272 const existing = yield* getByName(name);
273 if (existing === undefined) return undefined;
274 const attrs = toAttrs(existing, env.project, env.region);
275 return (yield* ownedByAlchemy(id, existing.description))
276 ? attrs
277 : Unowned(attrs);
278 }),
279
280 list: () =>
281 Effect.gen(function* () {
282 const env = yield* GcpEnvironment.current;
283 const items = yield* listOwned(env.project, env.region);
284 return items.map((item) => toAttrs(item, env.project, env.region));
285 }),
286
287 reconcile: Effect.fn(function* ({ id, news, output }) {
288 const env = yield* GcpEnvironment.current;
289 const workloadId = yield* toPhysicalId(
290 id,
291 news.workloadId,
292 output?.workloadId,
293 "workload",
294 );
295 const location = normalizeLocation(
296 news.location ?? output?.location,
297 env.region,
298 );
299 const application = expandApplication(
300 news.application,
301 env.project,
302 location,
303 );
304 const name = resourceName(application, workloadId);
305 const ownership = yield* createInternalLabels(id);
306 const description = encodeOwnership(ownership, news.description);
307 const displayName = news.displayName ?? workloadId;
308 const discoveredWorkload = yield* resolveDiscoveredWorkload(
309 locationParent(env.project, location),
310 news.discoveredWorkload,
311 env.project,
312 location,
313 );
314
315 let current = yield* getByName(output?.name ?? name);
316
317 if (current === undefined) {
318 const created = yield* apphub
319 .createProjectsLocationsApplicationsWorkloads({
320 parent: application,
321 workloadId,
322 body: {
323 discoveredWorkload,
324 displayName,
325 description,
326 attributes: news.attributes,
327 },
328 })
329 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
330 if (created !== undefined) {
331 yield* waitForOperation(created);
332 }
333 current = yield* waitUntilExists(getByName(name), name);
334 }
335
336 if (current === undefined) {
337 return yield* new ResourceNotResolved({ name });
338 }
339
340 current = yield* waitUntilReady(
341 getByName(current.name ?? name),
342 current.name ?? name,
343 (item) => item.state,
344 );
345
346 const mask = fieldMask([
347 !sameText(current.description, description) && "description",
348 !sameText(current.displayName, displayName) && "displayName",
349 news.attributes !== undefined &&
350 !sameJson(current.attributes, news.attributes) &&
351 "attributes",
352 ]);
353
354 if (mask.length > 0) {
355 const operation =
356 yield* apphub.patchProjectsLocationsApplicationsWorkloads({
357 name: current.name ?? name,
358 updateMask: mask,
359 body: {
360 displayName,
361 description,
362 attributes: news.attributes,
363 },
364 });
365 yield* waitForOperation(operation);
366 current = yield* waitUntilReady(
367 getByName(current.name ?? name),
368 current.name ?? name,
369 (item) => item.state,
370 );
371 }
372
373 return toAttrs(current, env.project, env.region);
374 }),
375
376 delete: Effect.fn(function* ({ output }) {
377 const operation = yield* apphub
378 .deleteProjectsLocationsApplicationsWorkloads({ name: output.name })
379 .pipe(
380 Effect.retry({
381 while: (error) => error._tag === "Conflict",
382 times: 8,
383 schedule: Schedule.spaced("2 seconds"),
384 }),
385 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
386 );
387 if (operation !== undefined) {
388 yield* waitForOperation(operation, { notFoundOk: true });
389 }
390 yield* waitUntilGone(getByName(output.name), output.name);
391 }),
392 });