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 ServiceProperties,
14 type ServiceReference,
15 encodeOwnership,
16 expandApplication,
17 fieldMask,
18 listNestedOwned,
19 listOwnedPages,
20 locationParent,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseName,
24 parseOwnership,
25 replaceOnIdentity,
26 resolveDiscoveredService,
27 ResourceNotResolved,
28 sameJson,
29 sameText,
30 toPhysicalId,
31 waitForOperation,
32 waitUntilExists,
33 waitUntilGone,
34 waitUntilReady,
35} from "./internal.ts";
36
37export type ApplicationsServiceProps = {
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 service.
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 * Service id (the `{service}` segment). Must be RFC1035 (63 chars).
53 * If omitted, a unique name is generated. Immutable — changing it
54 * replaces the service.
55 */
56 serviceId?: string;
57 /**
58 * Resource name of the original discovered service, or the underlying
59 * resource URI (looked up via `discoveredServices:lookup`). Immutable
60 * — changing it replaces the service.
61 */
62 discoveredService: string;
63 /**
64 * User-defined display name. Maximum length is 63 characters. Defaults
65 * to the service id.
66 */
67 displayName?: string;
68 /**
69 * User-defined description. Maximum length is 2048 characters.
70 * Services 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 ApplicationsService = Resource<
81 "GCP.AppHub.ApplicationsService",
82 ApplicationsServiceProps,
83 {
84 /** Full resource name. */
85 name: string;
86 /** Service id (last path segment). */
87 serviceId: string;
88 /** Parent Application resource name. */
89 application: string;
90 /** Project id. */
91 project: string;
92 /** Location id. */
93 location: string;
94 /** Discovered service resource name. */
95 discoveredService: 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 networking resource. */
103 serviceReference: ServiceReference | undefined;
104 /** Properties of the underlying compute resource. */
105 serviceProperties: ServiceProperties | 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 service registered onto an Application. A service is a
121 * discovered network or API interface (forwarding rule, URL map, Cloud
122 * Run service, …) that exposes functionality over the network.
123 *
124 * Services have no labels field, so Alchemy stamps ownership into the
125 * description for `list` / nuke. Application, service id, and
126 * `discoveredService` are immutable. Display name, description, and
127 * attributes update in place.
128 *
129 * ### Creating a Service
130 * **Example:** Register a discovered service
131 * ```typescript
132 * const service = yield* GCP.AppHub.ApplicationsService("Frontend", {
133 * application: app.name,
134 * discoveredService: discovered.name,
135 * });
136 * ```
137 *
138 * **Example:** Lookup from an underlying resource URI
139 * ```typescript
140 * const service = yield* GCP.AppHub.ApplicationsService("Frontend", {
141 * application: app.name,
142 * discoveredService: forwardingRule.selfLink,
143 * displayName: "frontend",
144 * description: "https load balancer",
145 * });
146 * ```
147 *
148 * ### Updating a Service
149 * Change props on the same logical id; the engine keeps the physical id.
150 *
151 * **Example:** Display name and attributes
152 * ```typescript
153 * const service = yield* GCP.AppHub.ApplicationsService("Frontend", {
154 * application: app.name,
155 * discoveredService: discovered.name,
156 * displayName: "frontend-v2",
157 * attributes: { criticality: { type: "HIGH" } },
158 * });
159 * ```
160 *
161 * @resource
162 * @category AppHub
163 */
164export const ApplicationsService = Resource<ApplicationsService>(
165 "GCP.AppHub.ApplicationsService",
166);
167
168const resourceName = (application: string, serviceId: string) =>
169 `${application}/services/${serviceId}`;
170
171const toAttrs = (item: apphub.Service, project: string, region: string) => {
172 const name = item.name ?? "";
173 const parsed = parseName(name, "services", region);
174 const ownership = parseOwnership(item.description);
175 return {
176 name,
177 serviceId: parsed.id,
178 application: parsed.application,
179 project: parsed.project || project,
180 location: parsed.location,
181 discoveredService: item.discoveredService,
182 displayName: item.displayName,
183 description: ownership.text,
184 attributes: item.attributes,
185 serviceReference: item.serviceReference,
186 serviceProperties: item.serviceProperties,
187 state: item.state,
188 uid: item.uid,
189 createTime: item.createTime,
190 updateTime: item.updateTime,
191 };
192};
193
194const getByName = (name: string) =>
195 name.length === 0
196 ? Effect.succeed(undefined)
197 : apphub
198 .getProjectsLocationsApplicationsServices({ name })
199 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
200
201const listOwned = (project: string, region: string) =>
202 listNestedOwned(project, region, (application) =>
203 listOwnedPages(
204 apphub.listProjectsLocationsApplicationsServices.pages({
205 parent: application,
206 pageSize: 1000,
207 }),
208 (page) => page.services,
209 (item) => item.description,
210 ),
211 );
212
213export const ApplicationsServiceProvider = () =>
214 Provider.succeed(ApplicationsService, {
215 stables: [
216 "name",
217 "serviceId",
218 "application",
219 "project",
220 "location",
221 "discoveredService",
222 "uid",
223 "createTime",
224 ],
225
226 diff: Effect.fn(function* ({ news, olds, output }) {
227 if (!isResolved(news)) return undefined;
228 const env = yield* GcpEnvironment.current;
229 const previousDiscovered =
230 olds?.discoveredService ?? output?.discoveredService;
231 return replaceOnIdentity({
232 previousId: olds?.serviceId ?? output?.serviceId,
233 nextId: news.serviceId ?? olds?.serviceId ?? output?.serviceId,
234 previousLocation: normalizeLocation(
235 olds?.location ?? output?.location,
236 env.region,
237 ),
238 nextLocation: normalizeLocation(
239 news.location ?? olds?.location ?? output?.location,
240 env.region,
241 ),
242 previousParent: olds?.application ?? output?.application,
243 nextParent: news.application,
244 extra:
245 previousDiscovered !== undefined &&
246 news.discoveredService !== undefined &&
247 previousDiscovered !== news.discoveredService &&
248 !previousDiscovered.endsWith(`/${news.discoveredService}`) &&
249 !news.discoveredService.endsWith(`/${previousDiscovered}`),
250 });
251 }),
252
253 read: Effect.fn(function* ({ id, olds, output }) {
254 const env = yield* GcpEnvironment.current;
255 const serviceId = yield* toPhysicalId(
256 id,
257 olds?.serviceId,
258 output?.serviceId,
259 "service",
260 );
261 const location = normalizeLocation(
262 olds?.location ?? output?.location,
263 env.region,
264 );
265 const application = expandApplication(
266 olds?.application ?? output?.application ?? "",
267 env.project,
268 location,
269 );
270 const name = output?.name ?? resourceName(application, serviceId);
271 const existing = yield* getByName(name);
272 if (existing === undefined) return undefined;
273 const attrs = toAttrs(existing, env.project, env.region);
274 return (yield* ownedByAlchemy(id, existing.description))
275 ? attrs
276 : Unowned(attrs);
277 }),
278
279 list: () =>
280 Effect.gen(function* () {
281 const env = yield* GcpEnvironment.current;
282 const items = yield* listOwned(env.project, env.region);
283 return items.map((item) => toAttrs(item, env.project, env.region));
284 }),
285
286 reconcile: Effect.fn(function* ({ id, news, output }) {
287 const env = yield* GcpEnvironment.current;
288 const serviceId = yield* toPhysicalId(
289 id,
290 news.serviceId,
291 output?.serviceId,
292 "service",
293 );
294 const location = normalizeLocation(
295 news.location ?? output?.location,
296 env.region,
297 );
298 const application = expandApplication(
299 news.application,
300 env.project,
301 location,
302 );
303 const name = resourceName(application, serviceId);
304 const ownership = yield* createInternalLabels(id);
305 const description = encodeOwnership(ownership, news.description);
306 const displayName = news.displayName ?? serviceId;
307 const discoveredService = yield* resolveDiscoveredService(
308 locationParent(env.project, location),
309 news.discoveredService,
310 env.project,
311 location,
312 );
313
314 let current = yield* getByName(output?.name ?? name);
315
316 if (current === undefined) {
317 const created = yield* apphub
318 .createProjectsLocationsApplicationsServices({
319 parent: application,
320 serviceId,
321 body: {
322 discoveredService,
323 displayName,
324 description,
325 attributes: news.attributes,
326 },
327 })
328 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
329 if (created !== undefined) {
330 yield* waitForOperation(created);
331 }
332 current = yield* waitUntilExists(getByName(name), name);
333 }
334
335 if (current === undefined) {
336 return yield* new ResourceNotResolved({ name });
337 }
338
339 current = yield* waitUntilReady(
340 getByName(current.name ?? name),
341 current.name ?? name,
342 (item) => item.state,
343 );
344
345 const mask = fieldMask([
346 !sameText(current.description, description) && "description",
347 !sameText(current.displayName, displayName) && "displayName",
348 news.attributes !== undefined &&
349 !sameJson(current.attributes, news.attributes) &&
350 "attributes",
351 ]);
352
353 if (mask.length > 0) {
354 const operation =
355 yield* apphub.patchProjectsLocationsApplicationsServices({
356 name: current.name ?? name,
357 updateMask: mask,
358 body: {
359 displayName,
360 description,
361 attributes: news.attributes,
362 },
363 });
364 yield* waitForOperation(operation);
365 current = yield* waitUntilReady(
366 getByName(current.name ?? name),
367 current.name ?? name,
368 (item) => item.state,
369 );
370 }
371
372 return toAttrs(current, env.project, env.region);
373 }),
374
375 delete: Effect.fn(function* ({ output }) {
376 const operation = yield* apphub
377 .deleteProjectsLocationsApplicationsServices({ name: output.name })
378 .pipe(
379 Effect.retry({
380 while: (error) => error._tag === "Conflict",
381 times: 8,
382 schedule: Schedule.spaced("2 seconds"),
383 }),
384 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
385 );
386 if (operation !== undefined) {
387 yield* waitForOperation(operation, { notFoundOk: true });
388 }
389 yield* waitUntilGone(getByName(output.name), output.name);
390 }),
391 });