1import * as appengine from "@distilled.cloud/gcp/appengine_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import {
7 alchemyLabelKeys,
8 createInternalLabels,
9 hasAlchemyLabels,
10} from "../Labels.ts";
11import { GcpEnvironment } from "../Environment.ts";
12
13// App Engine location ids (`us-central`, `europe-west`) are not Compute regions, and the app's own locationId wins.
14export const DEFAULT_LOCATION = "us-central";
15export const DEFAULT_SERVICE = "default";
16export const DEFAULT_ACTION = "ALLOW";
17export const DEFAULT_SOURCE_RANGE = "*";
18export const DEFAULT_SSL_MANAGEMENT = "AUTOMATIC";
19export const MAX_DISPLAY_NAME_LENGTH = 100;
20export const MAX_DESCRIPTION_LENGTH = 400;
21export const MAX_VERSION_ID_LENGTH = 63;
22export const OWNERSHIP_ENV = "ALCHEMY_OWNERSHIP";
23export const CATCH_ALL_PRIORITY = 2147483647;
24
25export class ApplicationNotResolved extends Data.TaggedError(
26 "GCP.AppEngine.ApplicationNotResolved",
27)<{
28 appsId: string;
29}> {}
30
31export const lastSegment = (value: string) => {
32 const trimmed = value.replace(/\/+$/, "");
33 const parts = trimmed.split("/");
34 return parts[parts.length - 1] || trimmed;
35};
36
37export const jsonEqual = (left: unknown, right: unknown) =>
38 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
39
40export const sameText = (left: string | undefined, right: string | undefined) =>
41 (left ?? "") === (right ?? "");
42
43export const updateMaskOf = (...fields: Array<string | undefined>) =>
44 fields.filter((field): field is string => field !== undefined).join(",");
45
46const markerOf = (
47 _labels: Record<string, string>,
48 stack: string,
49 stage: string,
50 id: string,
51) =>
52 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
53
54const fitMarker = (labels: Record<string, string>, maxLength: number) => {
55 let stack = labels[alchemyLabelKeys.stack] ?? "x";
56 let stage = labels[alchemyLabelKeys.stage] ?? "x";
57 let id = labels[alchemyLabelKeys.id] ?? "x";
58 let marker = markerOf(labels, stack, stage, id);
59 while (
60 marker.length > maxLength &&
61 (stack.length > 1 || stage.length > 1 || id.length > 1)
62 ) {
63 if (stack.length >= stage.length && stack.length >= id.length) {
64 stack = stack.slice(0, -1);
65 } else if (stage.length >= id.length) {
66 stage = stage.slice(0, -1);
67 } else {
68 id = id.slice(0, -1);
69 }
70 marker = markerOf(labels, stack, stage, id);
71 }
72 return marker.slice(0, maxLength);
73};
74
75export const encodeOwnership = (
76 labels: Record<string, string>,
77 text: string | undefined,
78 maxLength = 8000,
79): string => {
80 const trimmed = text?.trim();
81 if (!trimmed) return fitMarker(labels, maxLength);
82 const minMarker = 24;
83 const reserved = Math.min(
84 trimmed.length + 1,
85 Math.max(0, maxLength - minMarker),
86 );
87 const marker = fitMarker(labels, maxLength - reserved);
88 return `${marker} ${trimmed}`.slice(0, maxLength);
89};
90
91export const parseOwnership = (
92 text: string | undefined,
93): {
94 labels: Record<string, string>;
95 text: string | undefined;
96} => {
97 if (!text?.startsWith("[alchemy ")) {
98 return { labels: {}, text };
99 }
100 const end = text.indexOf("]");
101 if (end < 0) return { labels: {}, text };
102 const labels: Record<string, string> = {};
103 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
104 const eq = part.indexOf("=");
105 if (eq > 0) {
106 labels[part.slice(0, eq)] = part.slice(eq + 1);
107 }
108 }
109 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
110 return { labels, text: rest.length > 0 ? rest : undefined };
111};
112
113export const hasOwnershipMarker = (text: string | undefined) =>
114 Object.keys(parseOwnership(text).labels).some((key) =>
115 key.startsWith("alchemy-"),
116 );
117
118const prefixMatch = (expected: string, observed: string) =>
119 expected === observed ||
120 expected.startsWith(observed) ||
121 observed.startsWith(expected);
122
123export const ownedByAlchemy = (id: string, text: string | undefined) =>
124 Effect.gen(function* () {
125 const expected = yield* createInternalLabels(id);
126 const { labels } = parseOwnership(text);
127 if (!hasOwnershipMarker(text)) return false;
128 const exact = yield* hasAlchemyLabels(id, labels);
129 if (exact) return true;
130 return (
131 prefixMatch(
132 expected[alchemyLabelKeys.stack] ?? "",
133 labels[alchemyLabelKeys.stack] ?? "",
134 ) &&
135 prefixMatch(
136 expected[alchemyLabelKeys.stage] ?? "",
137 labels[alchemyLabelKeys.stage] ?? "",
138 ) &&
139 prefixMatch(
140 expected[alchemyLabelKeys.id] ?? "",
141 labels[alchemyLabelKeys.id] ?? "",
142 )
143 );
144 });
145
146export const ownershipLabels = (id: string) => createInternalLabels(id);
147
148export const rfc1035 = (name: string, maxLength: number): string => {
149 let next = name
150 .toLowerCase()
151 .replace(/[^a-z0-9-]/g, "-")
152 .replace(/-+/g, "-")
153 .replace(/^-+/, "")
154 .replace(/-+$/, "");
155 if (!/^[a-z]/.test(next)) next = `v${next}`;
156 if (next.startsWith("ah-") || next === "default" || next === "latest") {
157 next = `v-${next}`;
158 }
159 next = next.slice(0, maxLength).replace(/-+$/, "");
160 return next.length > 0 ? next : "version";
161};
162
163export const toResourceId = (
164 id: string,
165 requested: string | undefined,
166 existing: string | undefined,
167 maxLength = MAX_VERSION_ID_LENGTH,
168) =>
169 Effect.gen(function* () {
170 if (requested !== undefined && requested.length > 0) return requested;
171 if (existing !== undefined && existing.length > 0) return existing;
172 return rfc1035(
173 yield* createPhysicalName({
174 id,
175 maxLength,
176 lowercase: true,
177 }),
178 maxLength,
179 );
180 });
181
182export const toDisplayName = (
183 id: string,
184 requested: string | undefined,
185 existing: string | undefined,
186) =>
187 Effect.gen(function* () {
188 if (requested !== undefined && requested.length > 0) {
189 return requested.slice(0, MAX_DISPLAY_NAME_LENGTH);
190 }
191 if (existing !== undefined && existing.length > 0) {
192 return existing.slice(0, MAX_DISPLAY_NAME_LENGTH);
193 }
194 return yield* toResourceId(id, undefined, undefined, 40);
195 });
196
197export const toPriority = (
198 id: string,
199 requested: number | undefined,
200 existing: number | undefined,
201) =>
202 Effect.gen(function* () {
203 if (requested !== undefined) return requested;
204 if (existing !== undefined) return existing;
205 const name = yield* createPhysicalName({
206 id,
207 maxLength: 32,
208 lowercase: true,
209 });
210 let hash = 2166136261;
211 for (const ch of name) {
212 hash ^= ch.charCodeAt(0);
213 hash = Math.imul(hash, 16777619);
214 }
215 const priority = 10000 + ((hash >>> 0) % 900000);
216 return priority === CATCH_ALL_PRIORITY ? priority - 1 : priority;
217 });
218
219export const resolveAppsId = (
220 requested: string | undefined,
221 existing: string | undefined,
222) =>
223 Effect.gen(function* () {
224 if (requested !== undefined && requested.length > 0) return requested;
225 if (existing !== undefined && existing.length > 0) return existing;
226 const env = yield* GcpEnvironment.current;
227 return env.project;
228 });
229
230export const getApplication = (appsId: string) =>
231 appsId.length === 0
232 ? Effect.succeed(undefined)
233 : appengine
234 .getApps({ appsId })
235 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
236
237export const resolveLocation = (
238 requested: string | undefined,
239 existing: string | undefined,
240 appsId: string,
241) =>
242 Effect.gen(function* () {
243 if (requested !== undefined && requested.length > 0) {
244 return lastSegment(requested);
245 }
246 if (existing !== undefined && existing.length > 0) {
247 return lastSegment(existing);
248 }
249 const app = yield* getApplication(appsId);
250 return app?.locationId ?? DEFAULT_LOCATION;
251 });
252
253export const catchMissing = <A, E extends { readonly _tag: string }, R>(
254 effect: Effect.Effect<A, E, R>,
255) =>
256 effect.pipe(
257 Effect.catchIf(
258 (error): error is E & { readonly _tag: "NotFound" } =>
259 error._tag === "NotFound",
260 () => Effect.succeed(undefined),
261 ),
262 );
263
264export const ignoreMissing = <E extends { readonly _tag: string }, R>(
265 effect: Effect.Effect<unknown, E, R>,
266) =>
267 effect.pipe(
268 Effect.catchIf(
269 (error): error is E & { readonly _tag: "NotFound" } =>
270 error._tag === "NotFound",
271 () => Effect.void,
272 ),
273 );
274
275const emptyList = <A>() => Effect.succeed([] as A[]);
276
277export const listAuthorizedCertificates = (appsId: string) =>
278 appsId.length === 0
279 ? emptyList<appengine.AuthorizedCertificate>()
280 : appengine.listAppsAuthorizedCertificates
281 .pages({ appsId, pageSize: 100, view: "BASIC_CERTIFICATE" })
282 .pipe(
283 Stream.flatMap((page) =>
284 Stream.fromIterable(page.certificates ?? []),
285 ),
286 Stream.runCollect,
287 Effect.map((chunk) => Array.from(chunk)),
288 Effect.catchTag("NotFound", () =>
289 emptyList<appengine.AuthorizedCertificate>(),
290 ),
291 );
292
293export const listApplicationsAuthorizedCertificates = (
294 projectsId: string,
295 locationsId: string,
296 applicationsId: string,
297) =>
298 projectsId.length === 0 || applicationsId.length === 0
299 ? emptyList<appengine.AuthorizedCertificate>()
300 : appengine.listProjectsLocationsApplicationsAuthorizedCertificates
301 .pages({
302 projectsId,
303 locationsId,
304 applicationsId,
305 pageSize: 100,
306 view: "BASIC_CERTIFICATE",
307 })
308 .pipe(
309 Stream.flatMap((page) =>
310 Stream.fromIterable(page.certificates ?? []),
311 ),
312 Stream.runCollect,
313 Effect.map((chunk) => Array.from(chunk)),
314 Effect.catchTag("NotFound", () =>
315 emptyList<appengine.AuthorizedCertificate>(),
316 ),
317 );
318
319export const listDomainMappings = (appsId: string) =>
320 appsId.length === 0
321 ? emptyList<appengine.DomainMapping>()
322 : appengine.listAppsDomainMappings.pages({ appsId, pageSize: 100 }).pipe(
323 Stream.flatMap((page) =>
324 Stream.fromIterable(page.domainMappings ?? []),
325 ),
326 Stream.runCollect,
327 Effect.map((chunk) => Array.from(chunk)),
328 Effect.catchTag("NotFound", () => emptyList<appengine.DomainMapping>()),
329 );
330
331export const listApplicationsDomainMappings = (
332 projectsId: string,
333 locationsId: string,
334 applicationsId: string,
335) =>
336 projectsId.length === 0 || applicationsId.length === 0
337 ? emptyList<appengine.DomainMapping>()
338 : appengine.listProjectsLocationsApplicationsDomainMappings
339 .pages({
340 projectsId,
341 locationsId,
342 applicationsId,
343 pageSize: 100,
344 })
345 .pipe(
346 Stream.flatMap((page) =>
347 Stream.fromIterable(page.domainMappings ?? []),
348 ),
349 Stream.runCollect,
350 Effect.map((chunk) => Array.from(chunk)),
351 Effect.catchTag("NotFound", () =>
352 emptyList<appengine.DomainMapping>(),
353 ),
354 );
355
356export const listFirewallRules = (appsId: string) =>
357 appsId.length === 0
358 ? emptyList<appengine.FirewallRule>()
359 : appengine.listAppsFirewallIngressRules
360 .pages({ appsId, pageSize: 100 })
361 .pipe(
362 Stream.flatMap((page) =>
363 Stream.fromIterable(page.ingressRules ?? []),
364 ),
365 Stream.runCollect,
366 Effect.map((chunk) => Array.from(chunk)),
367 Effect.catchTag("NotFound", () =>
368 emptyList<appengine.FirewallRule>(),
369 ),
370 );
371
372export const listServices = (appsId: string) =>
373 appsId.length === 0
374 ? emptyList<appengine.Service>()
375 : appengine.listAppsServices.pages({ appsId, pageSize: 100 }).pipe(
376 Stream.flatMap((page) => Stream.fromIterable(page.services ?? [])),
377 Stream.runCollect,
378 Effect.map((chunk) => Array.from(chunk)),
379 Effect.catchTag("NotFound", () => emptyList<appengine.Service>()),
380 );
381
382export const listVersions = (appsId: string, servicesId: string) =>
383 appsId.length === 0 || servicesId.length === 0
384 ? emptyList<appengine.Version>()
385 : appengine.listAppsServicesVersions
386 .pages({
387 appsId,
388 servicesId,
389 pageSize: 100,
390 view: "FULL",
391 })
392 .pipe(
393 Stream.flatMap((page) => Stream.fromIterable(page.versions ?? [])),
394 Stream.runCollect,
395 Effect.map((chunk) => Array.from(chunk)),
396 Effect.catchTag("NotFound", () => emptyList<appengine.Version>()),
397 );
398
399export const findOwnedCertificate = (
400 id: string,
401 certificates: readonly appengine.AuthorizedCertificate[],
402) =>
403 Effect.gen(function* () {
404 for (const certificate of certificates) {
405 if (yield* ownedByAlchemy(id, certificate.displayName)) {
406 return certificate;
407 }
408 }
409 return undefined;
410 });
411
412export const findOwnedFirewallRule = (
413 id: string,
414 rules: readonly appengine.FirewallRule[],
415) =>
416 Effect.gen(function* () {
417 for (const rule of rules) {
418 if (yield* ownedByAlchemy(id, rule.description)) {
419 return rule;
420 }
421 }
422 return undefined;
423 });
424
425export const envVariablesOf = (
426 env: appengine.StringMap | undefined,
427): Record<string, string> => {
428 const result: Record<string, string> = {};
429 for (const [key, value] of Object.entries(env ?? {})) {
430 if (key === OWNERSHIP_ENV) continue;
431 if (value !== undefined) result[key] = value;
432 }
433 return result;
434};
435
436export const stampEnvVariables = (
437 labels: Record<string, string>,
438 env: Record<string, string | undefined> | undefined,
439): Record<string, string | undefined> => ({
440 ...(env ?? {}),
441 [OWNERSHIP_ENV]: fitMarker(labels, 200),
442});
443
444export const versionOwnershipText = (version: appengine.Version) =>
445 version.envVariables?.[OWNERSHIP_ENV];
446
447export const domainIsOwned = (
448 domain: string | undefined,
449 ownedCertificateIds: ReadonlySet<string>,
450 mapping: appengine.DomainMapping,
451) => {
452 if (hasOwnershipMarker(domain)) return true;
453 const id = (mapping.id ?? domain ?? "").toLowerCase();
454 if (id.startsWith("alchemy-") || id.includes(".alchemy-")) return true;
455 const certificateId = mapping.sslSettings?.certificateId;
456 return certificateId !== undefined && ownedCertificateIds.has(certificateId);
457};
458
459export const ownedCertificateIds = (
460 certificates: readonly appengine.AuthorizedCertificate[],
461) =>
462 new Set(
463 certificates
464 .filter((certificate) => hasOwnershipMarker(certificate.displayName))
465 .map((certificate) => certificate.id)
466 .filter((id): id is string => id !== undefined && id.length > 0),
467 );
468
469export const parseCertificateName = (name: string) => {
470 const parts = name.split("/").filter((part) => part.length > 0);
471 const appsAt = parts.indexOf("apps");
472 const projectsAt = parts.indexOf("projects");
473 const locationsAt = parts.indexOf("locations");
474 const applicationsAt = parts.indexOf("applications");
475 const certsAt = parts.lastIndexOf("authorizedCertificates");
476 const certificateId =
477 certsAt >= 0 && parts[certsAt + 1]
478 ? parts[certsAt + 1]!
479 : lastSegment(name);
480 const appsId =
481 appsAt >= 0 && parts[appsAt + 1]
482 ? parts[appsAt + 1]
483 : applicationsAt >= 0
484 ? parts[applicationsAt + 1]
485 : undefined;
486 return {
487 appsId,
488 project: projectsAt >= 0 ? parts[projectsAt + 1] : appsId,
489 location: locationsAt >= 0 ? parts[locationsAt + 1] : undefined,
490 applicationsId: applicationsAt >= 0 ? parts[applicationsAt + 1] : appsId,
491 certificateId,
492 };
493};
494
495export const parseDomainMappingName = (name: string) => {
496 const parts = name.split("/").filter((part) => part.length > 0);
497 const appsAt = parts.indexOf("apps");
498 const projectsAt = parts.indexOf("projects");
499 const locationsAt = parts.indexOf("locations");
500 const applicationsAt = parts.indexOf("applications");
501 const mappingsAt = parts.lastIndexOf("domainMappings");
502 const domain =
503 mappingsAt >= 0 ? parts.slice(mappingsAt + 1).join("/") : lastSegment(name);
504 const appsId =
505 appsAt >= 0 && parts[appsAt + 1]
506 ? parts[appsAt + 1]
507 : applicationsAt >= 0
508 ? parts[applicationsAt + 1]
509 : undefined;
510 return {
511 appsId,
512 project: projectsAt >= 0 ? parts[projectsAt + 1] : appsId,
513 location: locationsAt >= 0 ? parts[locationsAt + 1] : undefined,
514 applicationsId: applicationsAt >= 0 ? parts[applicationsAt + 1] : appsId,
515 domain,
516 };
517};
518
519export const parseVersionName = (name: string) => {
520 const parts = name.split("/").filter((part) => part.length > 0);
521 const appsAt = parts.indexOf("apps");
522 const servicesAt = parts.indexOf("services");
523 const versionsAt = parts.indexOf("versions");
524 return {
525 appsId: appsAt >= 0 ? parts[appsAt + 1] : undefined,
526 servicesId: servicesAt >= 0 ? parts[servicesAt + 1] : undefined,
527 versionsId: versionsAt >= 0 ? parts[versionsAt + 1] : lastSegment(name),
528 };
529};
530
531export const stringMapOf = (
532 map: Record<string, string | undefined> | undefined,
533): Record<string, string> => {
534 const result: Record<string, string> = {};
535 for (const [key, value] of Object.entries(map ?? {})) {
536 if (value !== undefined) result[key] = value;
537 }
538 return result;
539};