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 encodeOwnership,
14 findOwnedCertificate,
15 hasOwnershipMarker,
16 ignoreMissing,
17 listApplicationsAuthorizedCertificates,
18 MAX_DISPLAY_NAME_LENGTH,
19 ownedByAlchemy,
20 ownershipLabels,
21 parseCertificateName,
22 parseOwnership,
23 resolveAppsId,
24 resolveLocation,
25 sameText,
26 toDisplayName,
27 updateMaskOf,
28} from "./internal.ts";
29
30export type ApplicationsAuthorizedCertificateRawData = {
31 /** PEM-encoded x.509 public certificate, including header and footer. */
32 publicCertificate?: string;
33 /**
34 * Unencrypted PEM-encoded RSA private key (2048 bits or fewer), including
35 * header and footer. Input-only.
36 */
37 privateKey?: string;
38};
39
40export type ApplicationsAuthorizedCertificateProps = {
41 /**
42 * GCP project id. Defaults to the current project. Immutable —
43 * changing it replaces the certificate.
44 */
45 project?: string;
46 /**
47 * App Engine location (`us-central`, `us-east1`, …). Defaults to the
48 * application's `locationId`. Immutable — changing it replaces the
49 * certificate.
50 */
51 location?: string;
52 /**
53 * Application id (usually the project id). Defaults to the current
54 * project. Immutable — changing it replaces the certificate.
55 */
56 applicationsId?: string;
57 /**
58 * Server-assigned certificate id. Immutable — changing it replaces
59 * the certificate.
60 */
61 certificateId?: string;
62 /**
63 * User-specified display name. App Engine certificates have no labels
64 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix and
65 * stripped from attributes.
66 */
67 displayName?: string;
68 /**
69 * PEM-encoded x.509 public certificate. Input-only. Updating it renews
70 * the certificate in place for the same domains.
71 */
72 publicCertificate?: string;
73 /**
74 * Unencrypted PEM-encoded RSA private key. Input-only.
75 */
76 privateKey?: string;
77 /**
78 * Nested certificate payload. Top-level `publicCertificate` /
79 * `privateKey` take precedence when both are set.
80 */
81 certificateRawData?: ApplicationsAuthorizedCertificateRawData;
82};
83
84export type ApplicationsAuthorizedCertificate = Resource<
85 "GCP.AppEngine.ApplicationsAuthorizedCertificate",
86 ApplicationsAuthorizedCertificateProps,
87 {
88 /** Full resource name. */
89 name: string;
90 /** Server-assigned certificate id. */
91 certificateId: string;
92 /** Project id. */
93 project: string;
94 /** App Engine location id. */
95 location: string;
96 /** Application id. */
97 applicationsId: string;
98 /** Display name with the Alchemy ownership prefix stripped. */
99 displayName: string | undefined;
100 /** Topmost domains this certificate applies to. */
101 domainNames: string[];
102 /** RFC3339 expiry. */
103 expireTime: string | undefined;
104 /** Count of domain mappings using this certificate. */
105 domainMappingsCount: number | undefined;
106 /** Managed-certificate status, if App Engine manages this cert. */
107 managedStatus: string | undefined;
108 /** Last managed-certificate renewal time. */
109 lastRenewalTime: string | undefined;
110 /** Domain mapping resource names visible to the caller. */
111 visibleDomainMappings: string[];
112 },
113 never,
114 Providers
115>;
116
117/**
118 * An SSL certificate uploaded to an App Engine application via the
119 * `projects.locations.applications.authorizedCertificates` API.
120 *
121 * App Engine certificates have no labels field, so Alchemy stamps
122 * ownership into `displayName` for `list` / nuke. Project, location,
123 * application id, and server-assigned certificate id are identity.
124 * Display name and PEM data update in place.
125 *
126 * ### Creating a Certificate
127 * **Example:** Upload a self-managed certificate
128 * ```typescript
129 * const cert = yield* GCP.AppEngine.ApplicationsAuthorizedCertificate(
130 * "FrontendTls",
131 * {
132 * displayName: "frontend",
133 * publicCertificate,
134 * privateKey,
135 * },
136 * );
137 * ```
138 *
139 * ### Updating a Certificate
140 * **Example:** Rename
141 * ```typescript
142 * const cert = yield* GCP.AppEngine.ApplicationsAuthorizedCertificate(
143 * "FrontendTls",
144 * {
145 * displayName: "frontend-prod",
146 * publicCertificate,
147 * privateKey,
148 * },
149 * );
150 * ```
151 *
152 * @resource
153 * @category AppEngine
154 */
155export const ApplicationsAuthorizedCertificate =
156 Resource<ApplicationsAuthorizedCertificate>(
157 "GCP.AppEngine.ApplicationsAuthorizedCertificate",
158 );
159
160export class ApplicationsAuthorizedCertificateNotResolved extends Data.TaggedError(
161 "GCP.AppEngine.ApplicationsAuthorizedCertificateNotResolved",
162)<{
163 name: string;
164}> {}
165
166const rawDataOf = (props: ApplicationsAuthorizedCertificateProps) => {
167 const publicCertificate =
168 props.publicCertificate ?? props.certificateRawData?.publicCertificate;
169 const privateKey = props.privateKey ?? props.certificateRawData?.privateKey;
170 if (publicCertificate === undefined && privateKey === undefined) {
171 return undefined;
172 }
173 return { publicCertificate, privateKey };
174};
175
176const toAttrs = (
177 certificate: appengine.AuthorizedCertificate,
178 project: string,
179 location: string,
180 applicationsId: string,
181) => {
182 const parsed = parseCertificateName(certificate.name ?? "");
183 return {
184 name:
185 certificate.name ??
186 `projects/${project}/locations/${location}/applications/${applicationsId}/authorizedCertificates/${certificate.id ?? ""}`,
187 certificateId: certificate.id ?? parsed.certificateId,
188 project,
189 location: parsed.location ?? location,
190 applicationsId: parsed.applicationsId ?? applicationsId,
191 displayName: parseOwnership(certificate.displayName).text,
192 domainNames: [...(certificate.domainNames ?? [])],
193 expireTime: certificate.expireTime,
194 domainMappingsCount: certificate.domainMappingsCount,
195 managedStatus: certificate.managedCertificate?.status,
196 lastRenewalTime: certificate.managedCertificate?.lastRenewalTime,
197 visibleDomainMappings: [...(certificate.visibleDomainMappings ?? [])],
198 };
199};
200
201const getById = (
202 projectsId: string,
203 locationsId: string,
204 applicationsId: string,
205 certificateId: string,
206) =>
207 certificateId.length === 0
208 ? Effect.succeed(undefined)
209 : catchMissing(
210 appengine.getProjectsLocationsApplicationsAuthorizedCertificates({
211 projectsId,
212 locationsId,
213 applicationsId,
214 authorizedCertificatesId: certificateId,
215 }),
216 );
217
218export const ApplicationsAuthorizedCertificateProvider = () =>
219 Provider.succeed(ApplicationsAuthorizedCertificate, {
220 stables: ["name", "certificateId", "project", "location", "applicationsId"],
221
222 diff: Effect.fn(function* ({ news, olds, output }) {
223 if (!isResolved(news)) return undefined;
224 const previousProject = olds?.project ?? output?.project;
225 const nextProject = news.project ?? previousProject;
226 if (
227 previousProject !== undefined &&
228 nextProject !== undefined &&
229 nextProject !== previousProject
230 ) {
231 return { action: "replace" as const, deleteFirst: false };
232 }
233 const previousLocation = olds?.location ?? output?.location;
234 const nextLocation = news.location ?? previousLocation;
235 if (
236 previousLocation !== undefined &&
237 nextLocation !== undefined &&
238 nextLocation !== previousLocation
239 ) {
240 return { action: "replace" as const, deleteFirst: false };
241 }
242 const previousApp = olds?.applicationsId ?? output?.applicationsId;
243 const nextApp = news.applicationsId ?? previousApp;
244 if (
245 previousApp !== undefined &&
246 nextApp !== undefined &&
247 nextApp !== previousApp
248 ) {
249 return { action: "replace" as const, deleteFirst: false };
250 }
251 const previousId = olds?.certificateId ?? output?.certificateId;
252 if (
253 previousId !== undefined &&
254 news.certificateId !== undefined &&
255 news.certificateId !== previousId
256 ) {
257 return { action: "replace" as const, deleteFirst: false };
258 }
259 return undefined;
260 }),
261
262 read: Effect.fn(function* ({ id, olds, output }) {
263 const env = yield* GcpEnvironment.current;
264 const applicationsId = yield* resolveAppsId(
265 olds?.applicationsId ?? olds?.project,
266 output?.applicationsId ?? output?.project,
267 );
268 const project = olds?.project ?? output?.project ?? env.project;
269 const location = yield* resolveLocation(
270 olds?.location,
271 output?.location,
272 applicationsId,
273 );
274 let existing = yield* getById(
275 project,
276 location,
277 applicationsId,
278 olds?.certificateId ?? output?.certificateId ?? "",
279 );
280 if (existing === undefined) {
281 existing = yield* findOwnedCertificate(
282 id,
283 yield* listApplicationsAuthorizedCertificates(
284 project,
285 location,
286 applicationsId,
287 ),
288 );
289 }
290 if (existing === undefined) return undefined;
291 const attrs = toAttrs(existing, project, location, applicationsId);
292 return (yield* ownedByAlchemy(id, existing.displayName))
293 ? attrs
294 : Unowned(attrs);
295 }),
296
297 list: () =>
298 Effect.gen(function* () {
299 const env = yield* GcpEnvironment.current;
300 const location = yield* resolveLocation(
301 undefined,
302 undefined,
303 env.project,
304 );
305 const certificates = yield* listApplicationsAuthorizedCertificates(
306 env.project,
307 location,
308 env.project,
309 );
310 return certificates
311 .filter((certificate) => hasOwnershipMarker(certificate.displayName))
312 .map((certificate) =>
313 toAttrs(certificate, env.project, location, env.project),
314 );
315 }),
316
317 reconcile: Effect.fn(function* ({ id, news, output }) {
318 const env = yield* GcpEnvironment.current;
319 const applicationsId = yield* resolveAppsId(
320 news.applicationsId ?? news.project,
321 output?.applicationsId ?? output?.project,
322 );
323 const project = news.project ?? output?.project ?? env.project;
324 const location = yield* resolveLocation(
325 news.location,
326 output?.location,
327 applicationsId,
328 );
329 const ownership = yield* ownershipLabels(id);
330 const displayName = encodeOwnership(
331 ownership,
332 yield* toDisplayName(id, news.displayName, output?.displayName),
333 MAX_DISPLAY_NAME_LENGTH,
334 );
335 const certificateRawData = rawDataOf(news);
336
337 let current = yield* getById(
338 project,
339 location,
340 applicationsId,
341 news.certificateId ?? output?.certificateId ?? "",
342 );
343 if (current === undefined) {
344 current = yield* findOwnedCertificate(
345 id,
346 yield* listApplicationsAuthorizedCertificates(
347 project,
348 location,
349 applicationsId,
350 ),
351 );
352 }
353
354 if (current === undefined) {
355 const created = yield* appengine
356 .createProjectsLocationsApplicationsAuthorizedCertificates({
357 projectsId: project,
358 locationsId: location,
359 applicationsId,
360 body: {
361 displayName,
362 certificateRawData,
363 },
364 })
365 .pipe(
366 Effect.catchTag("Conflict", () =>
367 Effect.gen(function* () {
368 return yield* findOwnedCertificate(
369 id,
370 yield* listApplicationsAuthorizedCertificates(
371 project,
372 location,
373 applicationsId,
374 ),
375 );
376 }),
377 ),
378 );
379 current = created ?? undefined;
380 }
381
382 if (current === undefined) {
383 return yield* new ApplicationsAuthorizedCertificateNotResolved({
384 name: `projects/${project}/locations/${location}/applications/${applicationsId}/authorizedCertificates/${news.certificateId ?? ""}`,
385 });
386 }
387
388 const certificateId = current.id ?? output?.certificateId ?? "";
389 const nameChanged = !sameText(current.displayName, displayName);
390 const pemChanged = certificateRawData !== undefined;
391 const updateMask = updateMaskOf(
392 nameChanged ? "displayName" : undefined,
393 pemChanged ? "certificateRawData" : undefined,
394 );
395 if (updateMask.length > 0) {
396 current =
397 yield* appengine.patchProjectsLocationsApplicationsAuthorizedCertificates(
398 {
399 projectsId: project,
400 locationsId: location,
401 applicationsId,
402 authorizedCertificatesId: certificateId,
403 updateMask,
404 body: {
405 displayName,
406 certificateRawData,
407 },
408 },
409 );
410 }
411
412 return toAttrs(current, project, location, applicationsId);
413 }),
414
415 delete: Effect.fn(function* ({ output }) {
416 if (output.certificateId.length === 0) return;
417 if (output.managedStatus !== undefined) return;
418 yield* ignoreMissing(
419 appengine.deleteProjectsLocationsApplicationsAuthorizedCertificates({
420 projectsId: output.project,
421 locationsId: output.location || DEFAULT_LOCATION,
422 applicationsId: output.applicationsId,
423 authorizedCertificatesId: output.certificateId,
424 }),
425 );
426 }),
427 });