1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 createInternalTags,
12 createTagsList,
13 diffTags,
14 hasTags,
15} from "../../Tags.ts";
16import { toTagRecord } from "./common.ts";
17
18export interface ServerCertificateProps {
19 /**
20 * Name of the server certificate. If omitted, a deterministic name is generated.
21 */
22 serverCertificateName?: string;
23 /**
24 * Optional IAM path prefix.
25 * @default "/"
26 */
27 path?: string;
28 /**
29 * PEM-encoded leaf certificate body.
30 */
31 certificateBody: string;
32 /**
33 * PEM-encoded private key. AWS never returns this after upload.
34 */
35 privateKey: Redacted.Redacted<string> | string;
36 /**
37 * Optional PEM-encoded certificate chain.
38 */
39 certificateChain?: string;
40 /**
41 * User-defined tags.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface ServerCertificate extends Resource<
47 "AWS.IAM.ServerCertificate",
48 ServerCertificateProps,
49 {
50 /** The ARN of the server certificate. */
51 serverCertificateArn: string;
52 /** The name of the server certificate. */
53 serverCertificateName: string;
54 /** The stable unique ID of the server certificate. */
55 serverCertificateId: string | undefined;
56 /** The IAM path of the server certificate. */
57 path: string | undefined;
58 /** The PEM-encoded certificate body. */
59 certificateBody: string;
60 /** The PEM-encoded certificate chain, if uploaded. */
61 certificateChain: string | undefined;
62 /** When the certificate was uploaded. */
63 uploadDate: Date | undefined;
64 /** When the certificate expires. */
65 expiration: Date | undefined;
66 /** The tags applied to the certificate. */
67 tags: Record<string, string>;
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * An IAM server certificate.
75 *
76 * `ServerCertificate` uploads and tracks a TLS certificate bundle for legacy
77 * IAM-integrated services. The private key is write-only and should be provided
78 * as a redacted value when possible.
79 * ### Uploading Server Certificates
80 * **Example:** Upload a TLS Certificate
81 * ```typescript
82 * const certificate = yield* ServerCertificate("ApiTlsCertificate", {
83 * certificateBody: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
84 * privateKey: Redacted.make(
85 * "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
86 * ),
87 * certificateChain: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
88 * });
89 * ```
90 *
91 * @resource
92 */
93export const ServerCertificate = Resource<ServerCertificate>(
94 "AWS.IAM.ServerCertificate",
95);
96
97export const ServerCertificateProvider = () =>
98 Provider.effect(
99 ServerCertificate,
100 Effect.gen(function* () {
101 const toName = (id: string, props: ServerCertificateProps) =>
102 props.serverCertificateName
103 ? Effect.succeed(props.serverCertificateName)
104 : createPhysicalName({ id, maxLength: 128 });
105
106 const readCertificate = Effect.fn(function* (name: string) {
107 const response = yield* iam
108 .getServerCertificate({
109 ServerCertificateName: name,
110 })
111 .pipe(
112 Effect.catchTag("NoSuchEntityException", () =>
113 Effect.succeed(undefined),
114 ),
115 );
116 return response?.ServerCertificate;
117 });
118
119 return {
120 stables: [
121 "serverCertificateArn",
122 "serverCertificateName",
123 "serverCertificateId",
124 ],
125 diff: Effect.fn(function* ({ id, olds, news }) {
126 if (!isResolved(news)) return;
127 if (
128 (yield* toName(id, olds ?? ({} as ServerCertificateProps))) !==
129 (yield* toName(id, news)) ||
130 (olds.path ?? "/") !== (news.path ?? "/") ||
131 olds.certificateBody !== news.certificateBody ||
132 olds.certificateChain !== news.certificateChain
133 ) {
134 return { action: "replace" } as const;
135 }
136 }),
137 read: Effect.fn(function* ({ id, olds, output }) {
138 const name =
139 output?.serverCertificateName ??
140 (yield* toName(id, olds ?? ({} as ServerCertificateProps)));
141 const cert = yield* readCertificate(name);
142 if (!cert?.ServerCertificateMetadata?.Arn) {
143 return undefined;
144 }
145 const tags = yield* iam.listServerCertificateTags({
146 ServerCertificateName: name,
147 });
148 return {
149 serverCertificateArn: cert.ServerCertificateMetadata.Arn,
150 serverCertificateName:
151 cert.ServerCertificateMetadata.ServerCertificateName,
152 serverCertificateId:
153 cert.ServerCertificateMetadata.ServerCertificateId,
154 path: cert.ServerCertificateMetadata.Path,
155 certificateBody: cert.CertificateBody,
156 certificateChain: cert.CertificateChain,
157 uploadDate: cert.ServerCertificateMetadata.UploadDate,
158 expiration: cert.ServerCertificateMetadata.Expiration,
159 tags: toTagRecord(tags.Tags),
160 };
161 }),
162 reconcile: Effect.fn(function* ({ id, news, output, session }) {
163 const name =
164 output?.serverCertificateName ?? (yield* toName(id, news));
165 const desiredTags = {
166 ...(yield* createInternalTags(id)),
167 ...news.tags,
168 };
169
170 // Observe — `getServerCertificate` returns the live cert (or
171 // absence). The cert body / chain are immutable (`diff`
172 // triggers replacement), so we never re-upload during sync.
173 let cert = yield* readCertificate(name);
174
175 // Ensure — upload when missing. On race, verify alchemy
176 // ownership tags; bail out otherwise.
177 if (!cert?.ServerCertificateMetadata?.Arn) {
178 const created = yield* iam
179 .uploadServerCertificate({
180 Path: news.path,
181 ServerCertificateName: name,
182 CertificateBody: news.certificateBody,
183 PrivateKey:
184 typeof news.privateKey === "string"
185 ? news.privateKey
186 : Redacted.value(news.privateKey),
187 CertificateChain: news.certificateChain,
188 Tags: createTagsList(desiredTags),
189 })
190 .pipe(
191 Effect.catchTag("EntityAlreadyExistsException", () =>
192 Effect.gen(function* () {
193 const existing = yield* readCertificate(name);
194 if (!existing?.ServerCertificateMetadata?.Arn) {
195 return yield* Effect.fail(
196 new Error(
197 `Server certificate '${name}' already exists but could not be described`,
198 ),
199 );
200 }
201 if (!hasTags(desiredTags, existing.Tags)) {
202 return yield* Effect.fail(
203 new Error(
204 `Server certificate '${name}' already exists and is not managed by alchemy`,
205 ),
206 );
207 }
208 return {
209 ServerCertificateMetadata:
210 existing.ServerCertificateMetadata,
211 };
212 }),
213 ),
214 );
215 if (!created.ServerCertificateMetadata?.Arn) {
216 return yield* Effect.fail(
217 new Error(`uploadServerCertificate returned no metadata`),
218 );
219 }
220 cert = yield* readCertificate(name);
221 }
222
223 // Sync tags against the cloud's actual tags.
224 const observedTags = toTagRecord(cert?.Tags);
225 const { removed, upsert } = diffTags(observedTags, desiredTags);
226 if (upsert.length > 0) {
227 yield* iam.tagServerCertificate({
228 ServerCertificateName: name,
229 Tags: upsert,
230 });
231 }
232 if (removed.length > 0) {
233 yield* iam.untagServerCertificate({
234 ServerCertificateName: name,
235 TagKeys: removed,
236 });
237 }
238
239 // Re-read for fresh metadata.
240 const fresh = yield* readCertificate(name);
241 const metadata = fresh?.ServerCertificateMetadata;
242 if (!metadata?.Arn || !metadata.ServerCertificateName) {
243 return yield* Effect.fail(
244 new Error(
245 `Server certificate '${name}' was not readable after sync`,
246 ),
247 );
248 }
249
250 yield* session.note(metadata.Arn);
251 return {
252 serverCertificateArn: metadata.Arn,
253 serverCertificateName: metadata.ServerCertificateName,
254 serverCertificateId: metadata.ServerCertificateId,
255 path: metadata.Path,
256 certificateBody: fresh?.CertificateBody ?? news.certificateBody,
257 certificateChain: fresh?.CertificateChain ?? news.certificateChain,
258 uploadDate: metadata.UploadDate,
259 expiration: metadata.Expiration,
260 tags: desiredTags,
261 };
262 }),
263 list: () =>
264 Effect.gen(function* () {
265 // IAM is global; `listServerCertificates` enumerates every cert in
266 // the account. Metadata lacks the body/chain/tags, so hydrate each
267 // entry to produce the full Attributes shape `read` returns.
268 const metadatas = yield* iam.listServerCertificates.pages({}).pipe(
269 Stream.runCollect,
270 Effect.map((chunk) =>
271 Array.from(chunk).flatMap(
272 (page) => page.ServerCertificateMetadataList ?? [],
273 ),
274 ),
275 );
276 const rows = yield* Effect.forEach(
277 metadatas,
278 (meta) =>
279 Effect.gen(function* () {
280 const cert = yield* readCertificate(
281 meta.ServerCertificateName,
282 );
283 // Raced delete between list and hydrate — skip it.
284 if (!cert?.ServerCertificateMetadata?.Arn) {
285 return undefined;
286 }
287 const tags = yield* iam.listServerCertificateTags({
288 ServerCertificateName: meta.ServerCertificateName,
289 });
290 return {
291 serverCertificateArn: cert.ServerCertificateMetadata.Arn,
292 serverCertificateName:
293 cert.ServerCertificateMetadata.ServerCertificateName,
294 serverCertificateId:
295 cert.ServerCertificateMetadata.ServerCertificateId,
296 path: cert.ServerCertificateMetadata.Path,
297 certificateBody: cert.CertificateBody,
298 certificateChain: cert.CertificateChain,
299 uploadDate: cert.ServerCertificateMetadata.UploadDate,
300 expiration: cert.ServerCertificateMetadata.Expiration,
301 tags: toTagRecord(tags.Tags),
302 };
303 }),
304 { concurrency: 10 },
305 );
306 return rows.filter((row) => row !== undefined);
307 }),
308 delete: Effect.fn(function* ({ output }) {
309 yield* iam
310 .deleteServerCertificate({
311 ServerCertificateName: output.serverCertificateName,
312 })
313 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
314 }),
315 };
316 }),
317 );