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