1import * as appengine from "@distilled.cloud/gcp/appengine_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 type { Providers } from "../Providers.ts";
10import {
11 catchMissing,
12 DEFAULT_SERVICE,
13 envVariablesOf,
14 hasOwnershipMarker,
15 jsonEqual,
16 listServices,
17 listVersions,
18 MAX_VERSION_ID_LENGTH,
19 ownedByAlchemy,
20 ownershipLabels,
21 parseVersionName,
22 resolveAppsId,
23 sameText,
24 stampEnvVariables,
25 toResourceId,
26 updateMaskOf,
27 versionOwnershipText,
28} from "./internal.ts";
29import { waitForOperation } from "./operations.ts";
30
31export type AppsServicesVersionProps = {
32 /**
33 * App Engine application id. Defaults to the current GCP project.
34 * Immutable — changing it replaces the version.
35 */
36 appsId?: string;
37 /**
38 * Service id that owns this version.
39 * @default "default"
40 */
41 serviceId?: string;
42 /**
43 * Version id (lowercase letters, numbers, hyphens). Reserved names
44 * `default`, `latest`, and `ah-*` are rejected. If omitted, a unique
45 * id is generated. Immutable — changing it replaces the version.
46 */
47 versionId?: string;
48 /**
49 * Runtime (for example `python311`, `nodejs20`). Immutable — changing
50 * it replaces the version.
51 */
52 runtime?: string;
53 /**
54 * Execution environment (`standard` or `flexible`).
55 * @default "standard"
56 */
57 env?: string;
58 /**
59 * Serving status. Defaults to `SERVING`.
60 */
61 servingStatus?: appengine.VersionServingStatusEnum | (string & {});
62 /**
63 * Instance class (`F1`, `F2`, `B1`, …).
64 */
65 instanceClass?: string;
66 /**
67 * Application environment variables. App Engine versions have no
68 * labels field, so Alchemy stamps ownership into `ALCHEMY_OWNERSHIP`
69 * and strips it from attributes.
70 */
71 envVariables?: Record<string, string | undefined>;
72 /**
73 * Code and artifacts that make up this version. Immutable — changing
74 * it replaces the version.
75 */
76 deployment?: appengine.Deployment;
77 /**
78 * Application entrypoint.
79 */
80 entrypoint?: appengine.Entrypoint;
81 /**
82 * Automatic scaling configuration.
83 */
84 automaticScaling?: appengine.AutomaticScaling;
85 /**
86 * Basic scaling configuration.
87 */
88 basicScaling?: appengine.BasicScaling;
89 /**
90 * Manual scaling configuration.
91 */
92 manualScaling?: appengine.ManualScaling;
93 /**
94 * URL handlers.
95 */
96 handlers?: appengine.UrlMapList;
97 /**
98 * Whether the runtime is threadsafe.
99 */
100 threadsafe?: boolean;
101 /**
102 * Service account the version runs as.
103 */
104 serviceAccount?: string;
105 /**
106 * Inbound services the version receives.
107 */
108 inboundServices?: appengine.VersionInboundServicesItemEnumList;
109 /**
110 * VPC Access connector.
111 */
112 vpcAccessConnector?: appengine.VpcAccessConnector;
113 /**
114 * Allow second-generation runtimes to use bundled services.
115 */
116 appEngineApis?: boolean;
117 /**
118 * Runtime API version.
119 */
120 runtimeApiVersion?: string;
121};
122
123export type AppsServicesVersion = Resource<
124 "GCP.AppEngine.AppsServicesVersion",
125 AppsServicesVersionProps,
126 {
127 /** Full resource name `apps/{appsId}/services/{service}/versions/{id}`. */
128 name: string;
129 /** Version id. */
130 versionId: string;
131 /** Service id. */
132 serviceId: string;
133 /** App Engine application id. */
134 appsId: string;
135 /** Project id. */
136 project: string;
137 /** Runtime. */
138 runtime: string | undefined;
139 /** Execution environment. */
140 env: string | undefined;
141 /** Serving status. */
142 servingStatus: string | undefined;
143 /** Instance class. */
144 instanceClass: string | undefined;
145 /** User environment variables (ownership stamp stripped). */
146 envVariables: Record<string, string>;
147 /** Serving URL for this version. */
148 versionUrl: string | undefined;
149 /** RFC3339 creation timestamp. */
150 createTime: string | undefined;
151 /** Creator email. */
152 createdBy: string | undefined;
153 /** Disk usage in bytes. */
154 diskUsageBytes: string | undefined;
155 /** Automatic scaling, if configured. */
156 automaticScaling: appengine.AutomaticScaling | undefined;
157 /** Basic scaling, if configured. */
158 basicScaling: appengine.BasicScaling | undefined;
159 /** Manual scaling, if configured. */
160 manualScaling: appengine.ManualScaling | undefined;
161 },
162 never,
163 Providers
164>;
165
166/**
167 * An App Engine service version.
168 *
169 * App Engine versions have no labels field, so Alchemy stamps ownership
170 * into the `ALCHEMY_OWNERSHIP` environment variable for `list` / nuke.
171 * Application, service, version id, runtime, environment, and
172 * deployment are identity — changing any of them replaces the version.
173 * Serving status, instance class, and scaling update in place.
174 *
175 * ### Creating a Version
176 * **Example:** Python 3 standard environment
177 * ```typescript
178 * const version = yield* GCP.AppEngine.AppsServicesVersion("Api", {
179 * runtime: "python311",
180 * deployment: {
181 * zip: { sourceUrl: "https://storage.googleapis.com/bucket/app.zip" },
182 * },
183 * });
184 * ```
185 *
186 * **Example:** Named version with automatic scaling
187 * ```typescript
188 * const version = yield* GCP.AppEngine.AppsServicesVersion("Api", {
189 * versionId: "v1",
190 * serviceId: "default",
191 * runtime: "nodejs20",
192 * automaticScaling: {
193 * standardSchedulerSettings: { minInstances: 0, maxInstances: 1 },
194 * },
195 * deployment: {
196 * zip: { sourceUrl: "https://storage.googleapis.com/bucket/app.zip" },
197 * },
198 * });
199 * ```
200 *
201 * ### Updating a Version
202 * **Example:** Stop serving
203 * ```typescript
204 * const version = yield* GCP.AppEngine.AppsServicesVersion("Api", {
205 * servingStatus: "STOPPED",
206 * deployment: {
207 * zip: { sourceUrl: "https://storage.googleapis.com/bucket/app.zip" },
208 * },
209 * });
210 * ```
211 *
212 * @resource
213 * @category AppEngine
214 */
215export const AppsServicesVersion = Resource<AppsServicesVersion>(
216 "GCP.AppEngine.AppsServicesVersion",
217);
218
219export class AppsServicesVersionNotResolved extends Data.TaggedError(
220 "GCP.AppEngine.AppsServicesVersionNotResolved",
221)<{
222 name: string;
223}> {}
224
225const toAttrs = (
226 version: appengine.Version,
227 appsId: string,
228 serviceId: string,
229 project: string,
230) => {
231 const parsed = parseVersionName(version.name ?? "");
232 const versionId = version.id ?? parsed.versionsId ?? "";
233 return {
234 name:
235 version.name ??
236 `apps/${appsId}/services/${serviceId}/versions/${versionId}`,
237 versionId,
238 serviceId: parsed.servicesId ?? serviceId,
239 appsId: parsed.appsId ?? appsId,
240 project,
241 runtime: version.runtime,
242 env: version.env,
243 servingStatus: version.servingStatus,
244 instanceClass: version.instanceClass,
245 envVariables: envVariablesOf(version.envVariables),
246 versionUrl: version.versionUrl,
247 createTime: version.createTime,
248 createdBy: version.createdBy,
249 diskUsageBytes: version.diskUsageBytes,
250 automaticScaling: version.automaticScaling,
251 basicScaling: version.basicScaling,
252 manualScaling: version.manualScaling,
253 };
254};
255
256const getById = (appsId: string, servicesId: string, versionsId: string) =>
257 versionsId.length === 0
258 ? Effect.succeed(undefined)
259 : catchMissing(
260 appengine.getAppsServicesVersions({
261 appsId,
262 servicesId,
263 versionsId,
264 view: "FULL",
265 }),
266 );
267
268const findOwnedVersion = (id: string, versions: readonly appengine.Version[]) =>
269 Effect.gen(function* () {
270 for (const version of versions) {
271 if (yield* ownedByAlchemy(id, versionOwnershipText(version))) {
272 return version;
273 }
274 }
275 return undefined;
276 });
277
278const desiredBody = (
279 news: AppsServicesVersionProps,
280 versionId: string,
281 envVariables: Record<string, string | undefined>,
282): appengine.Version => ({
283 id: versionId,
284 runtime: news.runtime,
285 env: news.env,
286 servingStatus: news.servingStatus,
287 instanceClass: news.instanceClass,
288 envVariables,
289 deployment: news.deployment,
290 entrypoint: news.entrypoint,
291 automaticScaling: news.automaticScaling,
292 basicScaling: news.basicScaling,
293 manualScaling: news.manualScaling,
294 handlers: news.handlers,
295 threadsafe: news.threadsafe,
296 serviceAccount: news.serviceAccount,
297 inboundServices: news.inboundServices,
298 vpcAccessConnector: news.vpcAccessConnector,
299 appEngineApis: news.appEngineApis,
300 runtimeApiVersion: news.runtimeApiVersion,
301});
302
303export const AppsServicesVersionProvider = () =>
304 Provider.succeed(AppsServicesVersion, {
305 stables: [
306 "name",
307 "versionId",
308 "serviceId",
309 "appsId",
310 "project",
311 "createTime",
312 ],
313
314 diff: Effect.fn(function* ({ news, olds, output }) {
315 if (!isResolved(news)) return undefined;
316 const previousApp = olds?.appsId ?? output?.appsId;
317 const nextApp = news.appsId ?? previousApp;
318 if (
319 previousApp !== undefined &&
320 nextApp !== undefined &&
321 nextApp !== previousApp
322 ) {
323 return { action: "replace" as const, deleteFirst: false };
324 }
325 const previousService =
326 olds?.serviceId ?? output?.serviceId ?? DEFAULT_SERVICE;
327 const nextService = news.serviceId ?? previousService;
328 if (nextService !== previousService) {
329 return { action: "replace" as const, deleteFirst: false };
330 }
331 const previousId = olds?.versionId ?? output?.versionId;
332 if (
333 previousId !== undefined &&
334 news.versionId !== undefined &&
335 news.versionId !== previousId
336 ) {
337 return { action: "replace" as const, deleteFirst: false };
338 }
339 const previousRuntime = olds?.runtime ?? output?.runtime;
340 if (
341 previousRuntime !== undefined &&
342 news.runtime !== undefined &&
343 news.runtime !== previousRuntime
344 ) {
345 return { action: "replace" as const, deleteFirst: false };
346 }
347 const previousEnv = olds?.env ?? output?.env;
348 if (
349 previousEnv !== undefined &&
350 news.env !== undefined &&
351 news.env !== previousEnv
352 ) {
353 return { action: "replace" as const, deleteFirst: false };
354 }
355 if (
356 news.deployment !== undefined &&
357 olds?.deployment !== undefined &&
358 !jsonEqual(news.deployment, olds.deployment)
359 ) {
360 return { action: "replace" as const, deleteFirst: false };
361 }
362 return undefined;
363 }),
364
365 read: Effect.fn(function* ({ id, olds, output }) {
366 const env = yield* GcpEnvironment.current;
367 const appsId = yield* resolveAppsId(olds?.appsId, output?.appsId);
368 const serviceId = olds?.serviceId ?? output?.serviceId ?? DEFAULT_SERVICE;
369 const versionId = yield* toResourceId(
370 id,
371 olds?.versionId,
372 output?.versionId,
373 MAX_VERSION_ID_LENGTH,
374 );
375 let existing = yield* getById(appsId, serviceId, versionId);
376 if (existing === undefined) {
377 existing = yield* findOwnedVersion(
378 id,
379 yield* listVersions(appsId, serviceId),
380 );
381 }
382 if (existing === undefined) return undefined;
383 const attrs = toAttrs(existing, appsId, serviceId, env.project);
384 return (yield* ownedByAlchemy(id, versionOwnershipText(existing)))
385 ? attrs
386 : Unowned(attrs);
387 }),
388
389 list: () =>
390 Effect.gen(function* () {
391 const env = yield* GcpEnvironment.current;
392 const services = yield* listServices(env.project);
393 const collected: ReturnType<typeof toAttrs>[] = [];
394 for (const service of services) {
395 const parsed = parseVersionName(service.name ?? "");
396 const serviceId =
397 service.id ?? parsed.servicesId ?? lastServiceId(service.name);
398 const versions = yield* listVersions(env.project, serviceId);
399 for (const version of versions) {
400 if (hasOwnershipMarker(versionOwnershipText(version))) {
401 collected.push(
402 toAttrs(version, env.project, serviceId, env.project),
403 );
404 }
405 }
406 }
407 return collected;
408 }),
409
410 reconcile: Effect.fn(function* ({ id, news, output }) {
411 const env = yield* GcpEnvironment.current;
412 const appsId = yield* resolveAppsId(news.appsId, output?.appsId);
413 const serviceId = news.serviceId ?? output?.serviceId ?? DEFAULT_SERVICE;
414 const versionId = yield* toResourceId(
415 id,
416 news.versionId,
417 output?.versionId,
418 MAX_VERSION_ID_LENGTH,
419 );
420 const ownership = yield* ownershipLabels(id);
421 const envVariables = stampEnvVariables(ownership, news.envVariables);
422 const body = desiredBody(news, versionId, envVariables);
423
424 let current = yield* getById(appsId, serviceId, versionId);
425 if (current === undefined) {
426 current = yield* findOwnedVersion(
427 id,
428 yield* listVersions(appsId, serviceId),
429 );
430 }
431
432 if (current === undefined) {
433 const operation = yield* appengine
434 .createAppsServicesVersions({
435 appsId,
436 servicesId: serviceId,
437 body,
438 })
439 .pipe(
440 Effect.catchTag("Conflict", () =>
441 Effect.succeed<appengine.Operation>({ done: true }),
442 ),
443 );
444 if (operation.done !== true || operation.name !== undefined) {
445 yield* waitForOperation(operation, { appsId });
446 }
447 current = yield* getById(appsId, serviceId, versionId);
448 }
449
450 if (current === undefined) {
451 return yield* new AppsServicesVersionNotResolved({
452 name: `apps/${appsId}/services/${serviceId}/versions/${versionId}`,
453 });
454 }
455
456 const observedId = current.id ?? versionId;
457 const servingChanged =
458 news.servingStatus !== undefined &&
459 !sameText(current.servingStatus, news.servingStatus);
460 const classChanged =
461 news.instanceClass !== undefined &&
462 !sameText(current.instanceClass, news.instanceClass);
463 const automaticChanged =
464 news.automaticScaling !== undefined &&
465 !jsonEqual(current.automaticScaling, news.automaticScaling);
466 const basicChanged =
467 news.basicScaling !== undefined &&
468 !jsonEqual(current.basicScaling, news.basicScaling);
469 const manualChanged =
470 news.manualScaling !== undefined &&
471 !jsonEqual(current.manualScaling, news.manualScaling);
472 const updateMask = updateMaskOf(
473 servingChanged ? "servingStatus" : undefined,
474 classChanged ? "instanceClass" : undefined,
475 automaticChanged ? "automaticScaling" : undefined,
476 basicChanged ? "basicScaling" : undefined,
477 manualChanged ? "manualScaling" : undefined,
478 );
479 if (updateMask.length > 0) {
480 const operation = yield* appengine.patchAppsServicesVersions({
481 appsId,
482 servicesId: serviceId,
483 versionsId: observedId,
484 updateMask,
485 body: {
486 servingStatus: news.servingStatus,
487 instanceClass: news.instanceClass,
488 automaticScaling: news.automaticScaling,
489 basicScaling: news.basicScaling,
490 manualScaling: news.manualScaling,
491 },
492 });
493 yield* waitForOperation(operation, { appsId });
494 current = (yield* getById(appsId, serviceId, observedId)) ?? current;
495 }
496
497 return toAttrs(current, appsId, serviceId, env.project);
498 }),
499
500 delete: Effect.fn(function* ({ output }) {
501 if (output.versionId.length === 0) return;
502 const operation = yield* appengine
503 .deleteAppsServicesVersions({
504 appsId: output.appsId,
505 servicesId: output.serviceId || DEFAULT_SERVICE,
506 versionsId: output.versionId,
507 })
508 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
509 if (operation === undefined) return;
510 yield* waitForOperation(operation, {
511 appsId: output.appsId,
512 notFoundOk: true,
513 });
514 }),
515 });
516
517const lastServiceId = (name: string | undefined) => {
518 if (name === undefined) return DEFAULT_SERVICE;
519 const parts = name.split("/").filter((part) => part.length > 0);
520 const at = parts.lastIndexOf("services");
521 return at >= 0 ? (parts[at + 1] ?? DEFAULT_SERVICE) : DEFAULT_SERVICE;
522};