1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 deployedConfig,
11 environmentIdOf,
12 environmentNameOf,
13 lastSegment,
14 listProjectEnvironments,
15 missingToUndefined,
16 organizationIdOf,
17 parseOrgEnv,
18 segmentAfter,
19 stripRevision,
20 toResourceId,
21} from "./common.ts";
22
23const MAX_NAME_LENGTH = 255;
24const DEFAULT_FORMAT = "selfsignedcert";
25
26export type AliasSubject = {
27 /** X.509 common name. */
28 commonName?: string;
29 /** ISO country code. */
30 countryCode?: string;
31 /** Email address. */
32 email?: string;
33 /** Locality. */
34 locality?: string;
35 /** Organization. */
36 org?: string;
37 /** Organizational unit. */
38 orgUnit?: string;
39 /** State or province. */
40 state?: string;
41};
42
43export type EnvironmentsKeystoresAliasProps = {
44 /**
45 * Apigee organization id or `organizations/{org}`. Defaults to the
46 * current GCP project id. Immutable — changing it replaces the alias.
47 */
48 organization?: string;
49 /**
50 * Environment id or `organizations/{org}/environments/{env}`. Immutable —
51 * changing it replaces the alias.
52 */
53 environment: string;
54 /**
55 * Parent keystore id or
56 * `organizations/{org}/environments/{env}/keystores/{keystore}`.
57 * Immutable — changing it replaces the alias.
58 */
59 keystore: string;
60 /**
61 * Alias id. If omitted, a unique name is generated from the stack,
62 * stage, and logical id. Must match `[\w\s-.]{1,255}`. Immutable —
63 * changing it replaces the alias.
64 */
65 aliasId?: string;
66 /**
67 * Upload format: `selfsignedcert`, `keycertfile`, or `pkcs12`.
68 * @default "selfsignedcert"
69 */
70 format?: "selfsignedcert" | "keycertfile" | "pkcs12" | (string & {});
71 /**
72 * Skip newline validation between certificates in a chain.
73 * @default false
74 */
75 ignoreNewlineValidation?: boolean;
76 /**
77 * Skip certificate expiry validation.
78 * @default false
79 */
80 ignoreExpiryValidation?: boolean;
81 /**
82 * Private key password when `format` is `keycertfile` or `pkcs12`.
83 */
84 password?: string;
85 /**
86 * RSA key size for `selfsignedcert` (`1024`, `2048`, `4096`).
87 * @default "2048"
88 */
89 keySize?: string;
90 /**
91 * Signature algorithm for `selfsignedcert`.
92 * @default "SHA256withRSA"
93 */
94 sigAlg?: string;
95 /**
96 * Certificate validity in days for `selfsignedcert`.
97 * @default 365
98 */
99 certValidityInDays?: number;
100 /**
101 * X.509 subject for `selfsignedcert`.
102 */
103 subject?: AliasSubject;
104 /**
105 * Subject alternative names for `selfsignedcert`.
106 */
107 subjectAlternativeNames?: string[];
108 /**
109 * PEM certificate (and optional chain) when `format` is `keycertfile`.
110 */
111 certPem?: string;
112 /**
113 * PEM private key when `format` is `keycertfile`.
114 */
115 keyPem?: string;
116};
117
118export type EnvironmentsKeystoresAlias = Resource<
119 "GCP.Apigee.EnvironmentsKeystoresAlias",
120 EnvironmentsKeystoresAliasProps,
121 {
122 /** Full resource name `organizations/{org}/environments/{env}/keystores/{keystore}/aliases/{alias}`. */
123 name: string;
124 /** Alias id. */
125 aliasId: string;
126 /** Parent keystore id. */
127 keystoreId: string;
128 /** Apigee organization id. */
129 organizationId: string;
130 /** Environment id. */
131 environmentId: string;
132 /** Alias type (`CERT` or `KEY_CERT`). */
133 type: string | undefined;
134 /** Subject of the leaf certificate, if returned. */
135 subject: string | undefined;
136 /** Certificate expiry in milliseconds since epoch, if returned. */
137 expiryDate: string | undefined;
138 },
139 never,
140 Providers
141>;
142
143/**
144 * An alias in an Apigee environment keystore — a certificate or
145 * key/certificate pair used for TLS.
146 *
147 * Aliases have no labels or description, so `list` enumerates every alias
148 * in keystores of Apigee environments mapped to this GCP project.
149 * Name, keystore, organization, and environment are identity. Certificate
150 * material can be rotated with an in-place update.
151 *
152 * ### Creating a Self-Signed Alias
153 * **Example:** Generated self-signed certificate
154 * ```typescript
155 * const keystore = yield* GCP.Apigee.EnvironmentsKeystore("Tls", {
156 * environment: "eval",
157 * });
158 * const alias = yield* GCP.Apigee.EnvironmentsKeystoresAlias("Server", {
159 * environment: "eval",
160 * keystore: keystore.keystoreId,
161 * subject: { commonName: "api.example.com" },
162 * });
163 * ```
164 *
165 * @resource
166 * @category Apigee
167 */
168export const EnvironmentsKeystoresAlias = Resource<EnvironmentsKeystoresAlias>(
169 "GCP.Apigee.EnvironmentsKeystoresAlias",
170);
171
172export class EnvironmentsKeystoresAliasNotResolved extends Data.TaggedError(
173 "GCP.Apigee.EnvironmentsKeystoresAliasNotResolved",
174)<{
175 name: string;
176}> {}
177
178const keystoreIdOf = (value: string) =>
179 segmentAfter(value, "keystores") ?? lastSegment(value);
180
181const resourceName = (
182 organizationId: string,
183 environmentId: string,
184 keystoreId: string,
185 aliasId: string,
186) =>
187 `${environmentNameOf(organizationId, environmentId)}/keystores/${keystoreId}/aliases/${aliasId}`;
188
189const keystoreName = (
190 organizationId: string,
191 environmentId: string,
192 keystoreId: string,
193) =>
194 `${environmentNameOf(organizationId, environmentId)}/keystores/${keystoreId}`;
195
196const toAttrs = (
197 alias: apigee.GoogleCloudApigeeV1Alias,
198 organizationId: string,
199 environmentId: string,
200 keystoreId: string,
201) => {
202 const aliasId = alias.alias ?? lastSegment(alias.alias ?? "");
203 const cert = alias.certsInfo?.certInfo?.[0];
204 const name = resourceName(
205 organizationId,
206 environmentId,
207 keystoreId,
208 aliasId || "alias",
209 );
210 return {
211 name,
212 aliasId: aliasId || lastSegment(name),
213 keystoreId,
214 organizationId,
215 environmentId,
216 type: alias.type,
217 subject: cert?.subject,
218 expiryDate: cert?.expiryDate,
219 };
220};
221
222const getByName = (name: string) =>
223 missingToUndefined(
224 apigee.getOrganizationsEnvironmentsKeystoresAliases({ name }),
225 );
226
227const selfSignedBody = (
228 aliasId: string,
229 news: EnvironmentsKeystoresAliasProps,
230): apigee.GoogleApiHttpBody =>
231 ({
232 alias: aliasId,
233 keySize: news.keySize ?? "2048",
234 sigAlg: news.sigAlg ?? "SHA256withRSA",
235 certValidityInDays: news.certValidityInDays ?? 365,
236 subject: {
237 commonName:
238 news.subject?.commonName ??
239 news.subjectAlternativeNames?.[0] ??
240 aliasId,
241 countryCode: news.subject?.countryCode,
242 email: news.subject?.email,
243 locality: news.subject?.locality,
244 org: news.subject?.org,
245 orgUnit: news.subject?.orgUnit,
246 state: news.subject?.state,
247 },
248 subjectAlternativeNames: news.subjectAlternativeNames,
249 }) as apigee.GoogleApiHttpBody;
250
251const keyCertBody = (
252 news: EnvironmentsKeystoresAliasProps,
253): apigee.GoogleApiHttpBody => ({
254 contentType: "application/x-pem-file",
255 data: news.certPem,
256});
257
258const createBody = (
259 aliasId: string,
260 news: EnvironmentsKeystoresAliasProps,
261): apigee.GoogleApiHttpBody => {
262 const format = news.format ?? DEFAULT_FORMAT;
263 if (format === "selfsignedcert") return selfSignedBody(aliasId, news);
264 if (news.certPem !== undefined) return keyCertBody(news);
265 return selfSignedBody(aliasId, news);
266};
267
268export const EnvironmentsKeystoresAliasProvider = () =>
269 Provider.succeed(EnvironmentsKeystoresAlias, {
270 stables: [
271 "name",
272 "aliasId",
273 "keystoreId",
274 "organizationId",
275 "environmentId",
276 ],
277
278 diff: Effect.fn(function* ({ news, olds, output }) {
279 if (!isResolved(news)) return undefined;
280 const previousId = olds?.aliasId ?? output?.aliasId;
281 const previousKeystore = olds?.keystore ?? output?.keystoreId;
282 const previousOrg = olds?.organization ?? output?.organizationId;
283 const previousEnv = olds?.environment ?? output?.environmentId;
284 const idChanged =
285 previousId !== undefined &&
286 news.aliasId !== undefined &&
287 news.aliasId !== previousId;
288 const keystoreChanged =
289 previousKeystore !== undefined &&
290 keystoreIdOf(news.keystore) !== keystoreIdOf(previousKeystore);
291 const orgChanged =
292 previousOrg !== undefined &&
293 news.organization !== undefined &&
294 organizationIdOf(news.organization, "") !==
295 organizationIdOf(previousOrg, "");
296 const envChanged =
297 previousEnv !== undefined &&
298 environmentIdOf(news.environment) !== environmentIdOf(previousEnv);
299 if (idChanged || keystoreChanged || orgChanged || envChanged) {
300 return { action: "replace" as const, deleteFirst: false };
301 }
302 return undefined;
303 }),
304
305 read: Effect.fn(function* ({ id, olds, output }) {
306 const { project } = yield* GcpEnvironment.current;
307 const organizationId = organizationIdOf(
308 olds?.organization ?? output?.organizationId,
309 project,
310 );
311 const environmentId = environmentIdOf(
312 olds?.environment ?? output?.environmentId ?? "",
313 );
314 const keystoreId = keystoreIdOf(
315 olds?.keystore ?? output?.keystoreId ?? "",
316 );
317 const aliasId = yield* toResourceId(id, olds?.aliasId, output?.aliasId, {
318 maxLength: MAX_NAME_LENGTH,
319 });
320 const name =
321 output?.name ??
322 resourceName(organizationId, environmentId, keystoreId, aliasId);
323 const existing = yield* getByName(name);
324 if (existing === undefined) return undefined;
325 return toAttrs(existing, organizationId, environmentId, keystoreId);
326 }),
327
328 list: () =>
329 Effect.gen(function* () {
330 const environments = yield* listProjectEnvironments();
331 const found: EnvironmentsKeystoresAlias["Attributes"][] = [];
332 for (const item of environments) {
333 const config = yield* deployedConfig(item.parent);
334 for (const keystore of config?.keystores ?? []) {
335 const keystoreId = keystoreIdOf(keystore.name ?? "");
336 for (const aliasConfig of keystore.aliases ?? []) {
337 const aliasId = lastSegment(
338 stripRevision(aliasConfig.name ?? ""),
339 );
340 if (aliasId.length === 0) continue;
341 const name = resourceName(
342 item.organizationId,
343 item.environmentId,
344 keystoreId,
345 aliasId,
346 );
347 const alias = yield* getByName(name);
348 found.push(
349 toAttrs(
350 alias ?? { alias: aliasId },
351 item.organizationId,
352 item.environmentId,
353 keystoreId,
354 ),
355 );
356 }
357 }
358 }
359 return found;
360 }),
361
362 reconcile: Effect.fn(function* ({ id, news, output }) {
363 const { project } = yield* GcpEnvironment.current;
364 const organizationId = organizationIdOf(news.organization, project);
365 const environmentId = environmentIdOf(news.environment);
366 const keystoreId = keystoreIdOf(news.keystore);
367 const aliasId = yield* toResourceId(id, news.aliasId, output?.aliasId, {
368 maxLength: MAX_NAME_LENGTH,
369 });
370 const parent = keystoreName(organizationId, environmentId, keystoreId);
371 const name = resourceName(
372 organizationId,
373 environmentId,
374 keystoreId,
375 aliasId,
376 );
377
378 let current = yield* getByName(output?.name ?? name);
379
380 if (current === undefined) {
381 const created = yield* apigee
382 .createOrganizationsEnvironmentsKeystoresAliases({
383 parent,
384 alias: aliasId,
385 format: news.format ?? DEFAULT_FORMAT,
386 ignoreNewlineValidation: news.ignoreNewlineValidation,
387 ignoreExpiryValidation: news.ignoreExpiryValidation,
388 _password: news.password,
389 body: createBody(aliasId, news),
390 })
391 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
392 current = created ?? undefined;
393 } else if (news.certPem !== undefined) {
394 current = yield* apigee.updateOrganizationsEnvironmentsKeystoresAliases(
395 {
396 name,
397 ignoreNewlineValidation: news.ignoreNewlineValidation,
398 ignoreExpiryValidation: news.ignoreExpiryValidation ?? true,
399 body: keyCertBody(news),
400 },
401 );
402 }
403
404 if (current === undefined) {
405 return yield* new EnvironmentsKeystoresAliasNotResolved({ name });
406 }
407
408 return toAttrs(current, organizationId, environmentId, keystoreId);
409 }),
410
411 delete: Effect.fn(function* ({ output }) {
412 yield* apigee
413 .deleteOrganizationsEnvironmentsKeystoresAliases({
414 name: output.name,
415 })
416 .pipe(
417 Effect.catchTag(
418 ["NotFound", "ApigeeResourceNotFound"],
419 () => Effect.void,
420 ),
421 );
422 }),
423 });