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_LOCATION,
13 DEFAULT_SSL_MANAGEMENT,
14 domainIsOwned,
15 jsonEqual,
16 listApplicationsAuthorizedCertificates,
17 listApplicationsDomainMappings,
18 ownedCertificateIds,
19 parseDomainMappingName,
20 resolveAppsId,
21 resolveLocation,
22 sameText,
23 updateMaskOf,
24} from "./internal.ts";
25import { waitForOperation } from "./operations.ts";
26
27export type ApplicationsDomainMappingSslSettings = {
28 /**
29 * SSL management type. `AUTOMATIC` provisions a Google-managed
30 * certificate. `MANUAL` requires `certificateId`.
31 * @default "AUTOMATIC"
32 */
33 sslManagementType?:
34 | appengine.SslSettingsSslManagementTypeEnum
35 | (string & {});
36 /**
37 * AuthorizedCertificate id to serve for `MANUAL` SSL. Clearing it
38 * removes SSL support.
39 */
40 certificateId?: string;
41};
42
43export type ApplicationsDomainMappingResourceRecord = {
44 /** Resource record type (`A`, `AAAA`, `CNAME`). */
45 type?: appengine.ResourceRecordTypeEnum | (string & {});
46 /** Relative name of the object affected (CNAME only). */
47 name?: string;
48 /** Record data. */
49 rrdata?: string;
50};
51
52export type ApplicationsDomainMappingProps = {
53 /**
54 * GCP project id. Defaults to the current project. Immutable —
55 * changing it replaces the mapping.
56 */
57 project?: string;
58 /**
59 * App Engine location (`us-central`, `us-east1`, …). Defaults to the
60 * application's `locationId`. Immutable — changing it replaces the
61 * mapping.
62 */
63 location?: string;
64 /**
65 * Application id (usually the project id). Defaults to the current
66 * project. Immutable — changing it replaces the mapping.
67 */
68 applicationsId?: string;
69 /**
70 * Domain name served by the application (for example `www.example.com`).
71 * Immutable — changing it replaces the mapping. Domain mappings have
72 * no labels field; `list` / nuke discover owned mappings by joining
73 * `sslSettings.certificateId` to an Alchemy-owned AuthorizedCertificate,
74 * or a domain that starts with `alchemy-`.
75 */
76 domain: string;
77 /**
78 * SSL configuration. Defaults to Google-managed certificates.
79 */
80 sslSettings?: ApplicationsDomainMappingSslSettings;
81 /**
82 * Whether creating this mapping should override an existing mapping
83 * for the domain. By default overrides are rejected.
84 */
85 overrideStrategy?:
86 | appengine.CreateProjectsLocationsApplicationsDomainMappingsOverrideStrategyEnum
87 | (string & {});
88};
89
90export type ApplicationsDomainMapping = Resource<
91 "GCP.AppEngine.ApplicationsDomainMapping",
92 ApplicationsDomainMappingProps,
93 {
94 /** Full resource name. */
95 name: string;
96 /** Domain name. */
97 domain: string;
98 /** Project id. */
99 project: string;
100 /** App Engine location id. */
101 location: string;
102 /** Application id. */
103 applicationsId: string;
104 /** SSL management type. */
105 sslManagementType: string | undefined;
106 /** AuthorizedCertificate id currently serving SSL. */
107 certificateId: string | undefined;
108 /** Pending managed-certificate id, if provisioning. */
109 pendingManagedCertificateId: string | undefined;
110 /** DNS records that must be added at the domain registrar. */
111 resourceRecords: ApplicationsDomainMappingResourceRecord[];
112 },
113 never,
114 Providers
115>;
116
117/**
118 * A custom domain serving an App Engine application via the
119 * `projects.locations.applications.domainMappings` API.
120 *
121 * Domain mappings have no labels field. `list` / nuke discover owned
122 * mappings by joining `sslSettings.certificateId` to an Alchemy-owned
123 * AuthorizedCertificate, or a domain that starts with `alchemy-`.
124 * Project, location, application id, and domain are identity. SSL
125 * settings update in place.
126 *
127 * ### Creating a Domain Mapping
128 * **Example:** Automatic managed SSL
129 * ```typescript
130 * const mapping = yield* GCP.AppEngine.ApplicationsDomainMapping("Www", {
131 * domain: "www.example.com",
132 * sslSettings: { sslManagementType: "AUTOMATIC" },
133 * });
134 * ```
135 *
136 * ### Updating SSL
137 * **Example:** Point at a new certificate
138 * ```typescript
139 * const mapping = yield* GCP.AppEngine.ApplicationsDomainMapping("Www", {
140 * sslSettings: {
141 * sslManagementType: "MANUAL",
142 * certificateId: cert.certificateId,
143 * },
144 * });
145 * ```
146 *
147 * @resource
148 * @category AppEngine
149 */
150export const ApplicationsDomainMapping = Resource<ApplicationsDomainMapping>(
151 "GCP.AppEngine.ApplicationsDomainMapping",
152);
153
154export class ApplicationsDomainMappingNotResolved extends Data.TaggedError(
155 "GCP.AppEngine.ApplicationsDomainMappingNotResolved",
156)<{
157 name: string;
158}> {}
159
160const toAttrs = (
161 mapping: appengine.DomainMapping,
162 project: string,
163 location: string,
164 applicationsId: string,
165) => {
166 const parsed = parseDomainMappingName(mapping.name ?? "");
167 const domain = mapping.id ?? parsed.domain;
168 return {
169 name:
170 mapping.name ??
171 `projects/${project}/locations/${location}/applications/${applicationsId}/domainMappings/${domain}`,
172 domain,
173 project,
174 location: parsed.location ?? location,
175 applicationsId: parsed.applicationsId ?? applicationsId,
176 sslManagementType: mapping.sslSettings?.sslManagementType,
177 certificateId: mapping.sslSettings?.certificateId,
178 pendingManagedCertificateId:
179 mapping.sslSettings?.pendingManagedCertificateId,
180 resourceRecords: (mapping.resourceRecords ?? []).map((record) => ({
181 type: record.type,
182 name: record.name,
183 rrdata: record.rrdata,
184 })),
185 };
186};
187
188const getByDomain = (
189 projectsId: string,
190 locationsId: string,
191 applicationsId: string,
192 domain: string,
193) =>
194 domain.length === 0
195 ? Effect.succeed(undefined)
196 : catchMissing(
197 appengine.getProjectsLocationsApplicationsDomainMappings({
198 projectsId,
199 locationsId,
200 applicationsId,
201 domainMappingsId: domain,
202 }),
203 );
204
205const desiredSsl = (news: ApplicationsDomainMappingProps) => {
206 const ssl = news.sslSettings;
207 if (ssl === undefined) {
208 return {
209 sslManagementType: DEFAULT_SSL_MANAGEMENT,
210 } satisfies appengine.SslSettings;
211 }
212 return {
213 sslManagementType: ssl.sslManagementType ?? DEFAULT_SSL_MANAGEMENT,
214 certificateId: ssl.certificateId,
215 } satisfies appengine.SslSettings;
216};
217
218const mappingOwned = (
219 mapping: appengine.DomainMapping,
220 ownedIds: ReadonlySet<string>,
221) => domainIsOwned(mapping.id, ownedIds, mapping);
222
223export const ApplicationsDomainMappingProvider = () =>
224 Provider.succeed(ApplicationsDomainMapping, {
225 stables: ["name", "domain", "project", "location", "applicationsId"],
226
227 diff: Effect.fn(function* ({ news, olds, output }) {
228 if (!isResolved(news)) return undefined;
229 const previousProject = olds?.project ?? output?.project;
230 const nextProject = news.project ?? previousProject;
231 if (
232 previousProject !== undefined &&
233 nextProject !== undefined &&
234 nextProject !== previousProject
235 ) {
236 return { action: "replace" as const, deleteFirst: false };
237 }
238 const previousLocation = olds?.location ?? output?.location;
239 const nextLocation = news.location ?? previousLocation;
240 if (
241 previousLocation !== undefined &&
242 nextLocation !== undefined &&
243 nextLocation !== previousLocation
244 ) {
245 return { action: "replace" as const, deleteFirst: false };
246 }
247 const previousApp = olds?.applicationsId ?? output?.applicationsId;
248 const nextApp = news.applicationsId ?? previousApp;
249 if (
250 previousApp !== undefined &&
251 nextApp !== undefined &&
252 nextApp !== previousApp
253 ) {
254 return { action: "replace" as const, deleteFirst: false };
255 }
256 const previousDomain = olds?.domain ?? output?.domain;
257 if (previousDomain !== undefined && news.domain !== previousDomain) {
258 return { action: "replace" as const, deleteFirst: false };
259 }
260 return undefined;
261 }),
262
263 read: Effect.fn(function* ({ olds, output }) {
264 const env = yield* GcpEnvironment.current;
265 const applicationsId = yield* resolveAppsId(
266 olds?.applicationsId ?? olds?.project,
267 output?.applicationsId ?? output?.project,
268 );
269 const project = olds?.project ?? output?.project ?? env.project;
270 const location = yield* resolveLocation(
271 olds?.location,
272 output?.location,
273 applicationsId,
274 );
275 const domain = olds?.domain ?? output?.domain ?? "";
276 const existing = yield* getByDomain(
277 project,
278 location,
279 applicationsId,
280 domain,
281 );
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, project, location, applicationsId);
284 const ownedIds = ownedCertificateIds(
285 yield* listApplicationsAuthorizedCertificates(
286 project,
287 location,
288 applicationsId,
289 ),
290 );
291 return mappingOwned(existing, ownedIds) ? attrs : Unowned(attrs);
292 }),
293
294 list: () =>
295 Effect.gen(function* () {
296 const env = yield* GcpEnvironment.current;
297 const location = yield* resolveLocation(
298 undefined,
299 undefined,
300 env.project,
301 );
302 const ownedIds = ownedCertificateIds(
303 yield* listApplicationsAuthorizedCertificates(
304 env.project,
305 location,
306 env.project,
307 ),
308 );
309 const mappings = yield* listApplicationsDomainMappings(
310 env.project,
311 location,
312 env.project,
313 );
314 return mappings
315 .filter((mapping) => mappingOwned(mapping, ownedIds))
316 .map((mapping) =>
317 toAttrs(mapping, env.project, location, env.project),
318 );
319 }),
320
321 reconcile: Effect.fn(function* ({ news, output }) {
322 const env = yield* GcpEnvironment.current;
323 const applicationsId = yield* resolveAppsId(
324 news.applicationsId ?? news.project,
325 output?.applicationsId ?? output?.project,
326 );
327 const project = news.project ?? output?.project ?? env.project;
328 const location = yield* resolveLocation(
329 news.location,
330 output?.location,
331 applicationsId,
332 );
333 const domain = news.domain;
334 const sslSettings = desiredSsl(news);
335
336 let current = yield* getByDomain(
337 project,
338 location,
339 applicationsId,
340 domain,
341 );
342
343 if (current === undefined) {
344 const operation = yield* appengine
345 .createProjectsLocationsApplicationsDomainMappings({
346 projectsId: project,
347 locationsId: location,
348 applicationsId,
349 overrideStrategy: news.overrideStrategy,
350 body: {
351 id: domain,
352 sslSettings,
353 },
354 })
355 .pipe(
356 Effect.catchTag("Conflict", () =>
357 Effect.succeed<appengine.Operation>({ done: true }),
358 ),
359 );
360 if (operation.done !== true || operation.name !== undefined) {
361 yield* waitForOperation(operation, { appsId: applicationsId });
362 }
363 current = yield* getByDomain(project, location, applicationsId, domain);
364 }
365
366 if (current === undefined) {
367 return yield* new ApplicationsDomainMappingNotResolved({
368 name: `projects/${project}/locations/${location}/applications/${applicationsId}/domainMappings/${domain}`,
369 });
370 }
371
372 const observedSsl = current.sslSettings;
373 const typeChanged = !sameText(
374 observedSsl?.sslManagementType,
375 sslSettings.sslManagementType,
376 );
377 const certChanged = !sameText(
378 observedSsl?.certificateId,
379 sslSettings.certificateId,
380 );
381 if (
382 (typeChanged || certChanged) &&
383 !jsonEqual(observedSsl, sslSettings)
384 ) {
385 const operation =
386 yield* appengine.patchProjectsLocationsApplicationsDomainMappings({
387 projectsId: project,
388 locationsId: location,
389 applicationsId,
390 domainMappingsId: domain,
391 updateMask: updateMaskOf("sslSettings"),
392 body: { sslSettings },
393 });
394 yield* waitForOperation(operation, { appsId: applicationsId });
395 current =
396 (yield* getByDomain(project, location, applicationsId, domain)) ??
397 current;
398 }
399
400 return toAttrs(current, project, location, applicationsId);
401 }),
402
403 delete: Effect.fn(function* ({ output }) {
404 if (output.domain.length === 0) return;
405 const operation = yield* appengine
406 .deleteProjectsLocationsApplicationsDomainMappings({
407 projectsId: output.project,
408 locationsId: output.location || DEFAULT_LOCATION,
409 applicationsId: output.applicationsId,
410 domainMappingsId: output.domain,
411 })
412 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
413 if (operation === undefined) return;
414 yield* waitForOperation(operation, {
415 appsId: output.applicationsId,
416 notFoundOk: true,
417 });
418 }),
419 });