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