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 Scope,
14 defaultScope,
15 encodeOwnership,
16 fieldMask,
17 listAtLocations,
18 listOwnedPages,
19 locationParent,
20 normalizeLocation,
21 ownedByAlchemy,
22 parseName,
23 parseOwnership,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 sameJson,
27 sameText,
28 toPhysicalId,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32 waitUntilReady,
33} from "./internal.ts";
34
35export type ApplicationProps = {
36 /**
37 * Application id (the `{application}` segment of
38 * `projects/{project}/locations/{location}/applications/{application}`).
39 * Must be lowercase letters, numbers, or hyphens (RFC1035, 63 chars).
40 * If omitted, a unique name is generated. Immutable — changing it
41 * replaces the application.
42 */
43 applicationId?: string;
44 /**
45 * Region (`us-central1`, …) or `global`. Immutable — changing it
46 * replaces the application. `US-CENTRAL1` is accepted and normalized
47 * to `us-central1`. Regional applications use `scope.type = REGIONAL`;
48 * global applications use `scope.type = GLOBAL`.
49 * @default the stack's GCP region (`GCP.Region`, else the profile
50 * region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * User-defined display name. Maximum length is 63 characters. Defaults
55 * to the application id.
56 */
57 displayName?: string;
58 /**
59 * User-defined description. Maximum length is 2048 characters.
60 * Applications have no labels field, so Alchemy stamps ownership into
61 * a `[alchemy …]` prefix and strips it from attributes.
62 */
63 description?: string;
64 /**
65 * Consumer-provided attributes (criticality, environment, owners).
66 */
67 attributes?: Attributes;
68 /**
69 * Immutable governance boundary. Limits which Services and Workloads
70 * can be registered. Defaults to `REGIONAL` (or `GLOBAL` when
71 * `location` is `global`). Changing it replaces the application.
72 */
73 scope?: Scope;
74};
75
76export type Application = Resource<
77 "GCP.AppHub.Application",
78 ApplicationProps,
79 {
80 /** Full resource name. */
81 name: string;
82 /** Application id (last path segment). */
83 applicationId: string;
84 /** Project id. */
85 project: string;
86 /** Location id. */
87 location: string;
88 /** User-defined display name. */
89 displayName: string | undefined;
90 /** User description with the Alchemy ownership prefix stripped. */
91 description: string | undefined;
92 /** Consumer-provided attributes. */
93 attributes: Attributes | undefined;
94 /** Immutable scope. */
95 scope: Scope | undefined;
96 /** Server-reported state. */
97 state: string | undefined;
98 /** Server-generated resource uid. */
99 uid: string | undefined;
100 /** RFC3339 creation timestamp. */
101 createTime: string | undefined;
102 /** RFC3339 last-update timestamp. */
103 updateTime: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * An App Hub application — the governance boundary for services and
111 * workloads that perform a logical end-to-end business function.
112 *
113 * Applications have no labels field, so Alchemy stamps ownership into
114 * the description for `list` / nuke. Id, location, and scope are
115 * immutable. Display name, description, and attributes update in place.
116 *
117 * ### Creating an Application
118 * **Example:** Regional application
119 * ```typescript
120 * const app = yield* GCP.AppHub.Application("Checkout", {
121 * location: "us-central1",
122 * displayName: "checkout",
123 * scope: { type: "REGIONAL" },
124 * });
125 * ```
126 *
127 * **Example:** Attributes and description
128 * ```typescript
129 * const app = yield* GCP.AppHub.Application("Checkout", {
130 * displayName: "checkout",
131 * description: "payments",
132 * attributes: {
133 * criticality: { type: "HIGH" },
134 * environment: { type: "PRODUCTION" },
135 * },
136 * });
137 * ```
138 *
139 * ### Updating an Application
140 * Change props on the same logical id; the engine keeps the physical id.
141 *
142 * **Example:** Display name and criticality
143 * ```typescript
144 * const app = yield* GCP.AppHub.Application("Checkout", {
145 * displayName: "checkout-v2",
146 * description: "payments v2",
147 * attributes: {
148 * criticality: { type: "MISSION_CRITICAL" },
149 * environment: { type: "PRODUCTION" },
150 * },
151 * });
152 * ```
153 *
154 * @resource
155 * @category AppHub
156 */
157export const Application = Resource<Application>("GCP.AppHub.Application");
158
159const resourceName = (
160 project: string,
161 location: string,
162 applicationId: string,
163) => `${locationParent(project, location)}/applications/${applicationId}`;
164
165const toAttrs = (item: apphub.Application, project: string, region: string) => {
166 const name = item.name ?? "";
167 const parsed = parseName(name, "applications", region);
168 const ownership = parseOwnership(item.description);
169 return {
170 name,
171 applicationId: parsed.id,
172 project: parsed.project || project,
173 location: parsed.location,
174 displayName: item.displayName,
175 description: ownership.text,
176 attributes: item.attributes,
177 scope: item.scope,
178 state: item.state,
179 uid: item.uid,
180 createTime: item.createTime,
181 updateTime: item.updateTime,
182 };
183};
184
185const getByName = (name: string) =>
186 name.length === 0
187 ? Effect.succeed(undefined)
188 : apphub
189 .getProjectsLocationsApplications({ name })
190 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
191
192const listOwned = (project: string, region: string) =>
193 listAtLocations(project, region, (parent) =>
194 listOwnedPages(
195 apphub.listProjectsLocationsApplications.pages({
196 parent,
197 pageSize: 1000,
198 }),
199 (page) => page.applications,
200 (item) => item.description,
201 ),
202 );
203
204export const ApplicationProvider = () =>
205 Provider.succeed(Application, {
206 stables: [
207 "name",
208 "applicationId",
209 "project",
210 "location",
211 "uid",
212 "createTime",
213 ],
214
215 diff: Effect.fn(function* ({ news, olds, output }) {
216 if (!isResolved(news)) return undefined;
217 const env = yield* GcpEnvironment.current;
218 const previousScope = olds?.scope?.type ?? output?.scope?.type;
219 const nextScope = news.scope?.type ?? previousScope;
220 return replaceOnIdentity({
221 previousId: olds?.applicationId ?? output?.applicationId,
222 nextId:
223 news.applicationId ?? olds?.applicationId ?? output?.applicationId,
224 previousLocation: normalizeLocation(
225 olds?.location ?? output?.location,
226 env.region,
227 ),
228 nextLocation: normalizeLocation(
229 news.location ?? olds?.location ?? output?.location,
230 env.region,
231 ),
232 extra:
233 previousScope !== undefined &&
234 nextScope !== undefined &&
235 previousScope !== nextScope,
236 });
237 }),
238
239 read: Effect.fn(function* ({ id, olds, output }) {
240 const env = yield* GcpEnvironment.current;
241 const applicationId = yield* toPhysicalId(
242 id,
243 olds?.applicationId,
244 output?.applicationId,
245 "app",
246 );
247 const location = normalizeLocation(
248 olds?.location ?? output?.location,
249 env.region,
250 );
251 const name =
252 output?.name ?? resourceName(env.project, location, applicationId);
253 const existing = yield* getByName(name);
254 if (existing === undefined) return undefined;
255 const attrs = toAttrs(existing, env.project, env.region);
256 return (yield* ownedByAlchemy(id, existing.description))
257 ? attrs
258 : Unowned(attrs);
259 }),
260
261 list: () =>
262 Effect.gen(function* () {
263 const env = yield* GcpEnvironment.current;
264 const items = yield* listOwned(env.project, env.region);
265 return items.map((item) => toAttrs(item, env.project, env.region));
266 }),
267
268 reconcile: Effect.fn(function* ({ id, news, output }) {
269 const env = yield* GcpEnvironment.current;
270 const applicationId = yield* toPhysicalId(
271 id,
272 news.applicationId,
273 output?.applicationId,
274 "app",
275 );
276 const location = normalizeLocation(
277 news.location ?? output?.location,
278 env.region,
279 );
280 const name = resourceName(env.project, location, applicationId);
281 const ownership = yield* createInternalLabels(id);
282 const description = encodeOwnership(ownership, news.description);
283 const displayName = news.displayName ?? applicationId;
284 const scope = news.scope ?? defaultScope(location);
285
286 let current = yield* getByName(output?.name ?? name);
287
288 if (current === undefined) {
289 const created = yield* apphub
290 .createProjectsLocationsApplications({
291 parent: locationParent(env.project, location),
292 applicationId,
293 body: {
294 displayName,
295 description,
296 attributes: news.attributes,
297 scope,
298 },
299 })
300 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
301 if (created !== undefined) {
302 yield* waitForOperation(created);
303 }
304 current = yield* waitUntilExists(getByName(name), name);
305 }
306
307 if (current === undefined) {
308 return yield* new ResourceNotResolved({ name });
309 }
310
311 current = yield* waitUntilReady(
312 getByName(current.name ?? name),
313 current.name ?? name,
314 (item) => item.state,
315 );
316
317 const mask = fieldMask([
318 !sameText(current.description, description) && "description",
319 !sameText(current.displayName, displayName) && "displayName",
320 news.attributes !== undefined &&
321 !sameJson(current.attributes, news.attributes) &&
322 "attributes",
323 ]);
324
325 if (mask.length > 0) {
326 const operation = yield* apphub.patchProjectsLocationsApplications({
327 name: current.name ?? name,
328 updateMask: mask,
329 body: {
330 displayName,
331 description,
332 attributes: news.attributes,
333 },
334 });
335 yield* waitForOperation(operation);
336 current = yield* waitUntilReady(
337 getByName(current.name ?? name),
338 current.name ?? name,
339 (item) => item.state,
340 );
341 }
342
343 return toAttrs(current, env.project, env.region);
344 }),
345
346 delete: Effect.fn(function* ({ output }) {
347 const operation = yield* apphub
348 .deleteProjectsLocationsApplications({ name: output.name })
349 .pipe(
350 Effect.retry({
351 while: (error) => error._tag === "Conflict",
352 times: 8,
353 schedule: Schedule.spaced("2 seconds"),
354 }),
355 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
356 );
357 if (operation !== undefined) {
358 yield* waitForOperation(operation, { notFoundOk: true });
359 }
360 yield* waitUntilGone(getByName(output.name), output.name);
361 }),
362 });