1import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 { createInternalTags, diffTags } from "../../Tags.ts";
10import type { AccountID } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import type { RegionID } from "../Region.ts";
13
14export type TrustStoreArn =
15 `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:truststore/${string}`;
16
17export interface TrustStoreProps {
18 /** The trust store name. If omitted, a unique name is generated. Changing it replaces the trust store. */
19 name?: string;
20 /** The S3 bucket holding the CA certificate bundle (PEM). */
21 caCertificatesBundleS3Bucket: string;
22 /** The S3 key of the CA certificate bundle. */
23 caCertificatesBundleS3Key: string;
24 /** The S3 object version of the CA certificate bundle. */
25 caCertificatesBundleS3ObjectVersion?: string;
26 /** Tags to apply to the trust store. */
27 tags?: Record<string, string>;
28}
29
30export interface TrustStore extends Resource<
31 "AWS.ELBv2.TrustStore",
32 TrustStoreProps,
33 {
34 /** The ARN of the trust store. */
35 trustStoreArn: TrustStoreArn;
36 /** The name of the trust store. */
37 name: string;
38 /** The status of the trust store (`ACTIVE` or `CREATING`). */
39 status: string;
40 /** The number of CA certificates in the trust store's bundle. */
41 numberOfCaCertificates: number;
42 /** The tags applied to the trust store. */
43 tags: Record<string, string>;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * An ELBv2 trust store. A trust store holds the CA certificate bundle used by
51 * an HTTPS listener configured for mutual TLS (mTLS) `verify` mode to validate
52 * client certificates.
53 * ### Creating a Trust Store
54 * **Example:** Basic trust store from an S3 CA bundle
55 * ```typescript
56 * const trustStore = yield* TrustStore("mtls", {
57 * caCertificatesBundleS3Bucket: "my-ca-bundles",
58 * caCertificatesBundleS3Key: "ca-bundle.pem",
59 * });
60 * ```
61 *
62 * **Example:** Using a trust store on an mTLS listener
63 * ```typescript
64 * const listener = yield* Listener("https", {
65 * loadBalancerArn: lb.loadBalancerArn,
66 * port: 443,
67 * protocol: "HTTPS",
68 * certificates: [certArn],
69 * mutualAuthentication: {
70 * mode: "verify",
71 * trustStoreArn: trustStore.trustStoreArn,
72 * },
73 * defaultActions: [
74 * { type: "forward", targetGroups: [{ targetGroupArn: tg.targetGroupArn }] },
75 * ],
76 * });
77 * ```
78 *
79 * @resource
80 */
81export const TrustStore = Resource<TrustStore>("AWS.ELBv2.TrustStore");
82
83export const TrustStoreProvider = () =>
84 Provider.effect(
85 TrustStore,
86 Effect.gen(function* () {
87 const toName = (id: string, props: { name?: string } = {}) =>
88 props.name
89 ? Effect.succeed(props.name)
90 : createPhysicalName({ id, maxLength: 32, lowercase: true });
91
92 const observedTags = (arn: string) =>
93 Effect.gen(function* () {
94 const tagDescriptions = yield* elbv2.describeTags({
95 ResourceArns: [arn],
96 });
97 return Object.fromEntries(
98 (tagDescriptions.TagDescriptions?.[0]?.Tags ?? [])
99 .filter(
100 (t): t is { Key: string; Value: string } =>
101 typeof t.Key === "string" && typeof t.Value === "string",
102 )
103 .map((t) => [t.Key, t.Value]),
104 );
105 });
106
107 return {
108 stables: ["trustStoreArn", "name"],
109 diff: Effect.fn(function* ({ id, olds, news }) {
110 if (!isResolved(news)) return;
111 if (
112 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
113 ) {
114 return { action: "replace" } as const;
115 }
116 }),
117 read: Effect.fn(function* ({ output }) {
118 if (!output) {
119 return undefined;
120 }
121 const described = yield* elbv2
122 .describeTrustStores({
123 TrustStoreArns: [output.trustStoreArn],
124 })
125 .pipe(
126 Effect.catchTag("TrustStoreNotFoundException", () =>
127 Effect.succeed(undefined),
128 ),
129 );
130 const trustStore = described?.TrustStores?.[0];
131 if (!trustStore?.TrustStoreArn) {
132 return undefined;
133 }
134 return {
135 ...output,
136 name: trustStore.Name!,
137 status: trustStore.Status!,
138 numberOfCaCertificates: trustStore.NumberOfCaCertificates ?? 0,
139 };
140 }),
141 list: () =>
142 Effect.gen(function* () {
143 const trustStores = yield* elbv2.describeTrustStores.pages({}).pipe(
144 Stream.runCollect,
145 Effect.map((chunk) =>
146 Array.from(chunk).flatMap((page) =>
147 (page.TrustStores ?? []).filter(
148 (ts): ts is elbv2.TrustStore & { TrustStoreArn: string } =>
149 ts.TrustStoreArn != null,
150 ),
151 ),
152 ),
153 );
154 return yield* Effect.forEach(
155 trustStores,
156 (ts) =>
157 Effect.gen(function* () {
158 const tags = yield* observedTags(ts.TrustStoreArn).pipe(
159 Effect.catchTag("TrustStoreNotFoundException", () =>
160 Effect.succeed({} as Record<string, string>),
161 ),
162 );
163 return {
164 trustStoreArn: ts.TrustStoreArn as TrustStoreArn,
165 name: ts.Name!,
166 status: ts.Status!,
167 numberOfCaCertificates: ts.NumberOfCaCertificates ?? 0,
168 tags,
169 };
170 }),
171 { concurrency: 10 },
172 );
173 }),
174 reconcile: Effect.fn(function* ({ id, news, session }) {
175 const name = yield* toName(id, news);
176 const desiredTags = {
177 ...(yield* createInternalTags(id)),
178 ...news.tags,
179 };
180
181 // Observe — look up by deterministic name.
182 const described = yield* elbv2
183 .describeTrustStores({ Names: [name] })
184 .pipe(
185 Effect.catchTag("TrustStoreNotFoundException", () =>
186 Effect.succeed(undefined),
187 ),
188 );
189 let trustStore = described?.TrustStores?.[0];
190
191 // Ensure — create if missing.
192 if (!trustStore?.TrustStoreArn) {
193 const created = yield* elbv2.createTrustStore({
194 Name: name,
195 CaCertificatesBundleS3Bucket: news.caCertificatesBundleS3Bucket,
196 CaCertificatesBundleS3Key: news.caCertificatesBundleS3Key,
197 CaCertificatesBundleS3ObjectVersion:
198 news.caCertificatesBundleS3ObjectVersion,
199 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
200 Key,
201 Value,
202 })),
203 });
204 trustStore = created.TrustStores?.[0];
205 if (!trustStore?.TrustStoreArn) {
206 return yield* Effect.die(
207 new Error("createTrustStore returned no trust store"),
208 );
209 }
210 } else {
211 // Sync the CA bundle in place.
212 const modified = yield* elbv2.modifyTrustStore({
213 TrustStoreArn: trustStore.TrustStoreArn,
214 CaCertificatesBundleS3Bucket: news.caCertificatesBundleS3Bucket,
215 CaCertificatesBundleS3Key: news.caCertificatesBundleS3Key,
216 CaCertificatesBundleS3ObjectVersion:
217 news.caCertificatesBundleS3ObjectVersion,
218 });
219 trustStore = modified.TrustStores?.[0] ?? trustStore;
220 }
221
222 const trustStoreArn = trustStore.TrustStoreArn as TrustStoreArn;
223
224 // Wait until the trust store is ACTIVE (bundle validation completes).
225 const active = yield* elbv2
226 .describeTrustStores({ TrustStoreArns: [trustStoreArn] })
227 .pipe(
228 Effect.map((res) => res.TrustStores?.[0]),
229 Effect.repeat({
230 schedule: Schedule.spaced("3 seconds"),
231 until: (ts) => ts?.Status === "ACTIVE",
232 times: 10,
233 }),
234 );
235
236 // Sync tags — diff observed cloud tags against desired.
237 const observed = yield* observedTags(trustStoreArn);
238 const { removed, upsert } = diffTags(observed, desiredTags);
239 if (upsert.length > 0) {
240 yield* elbv2.addTags({
241 ResourceArns: [trustStoreArn],
242 Tags: upsert,
243 });
244 }
245 if (removed.length > 0) {
246 yield* elbv2.removeTags({
247 ResourceArns: [trustStoreArn],
248 TagKeys: removed,
249 });
250 }
251
252 yield* session.note(trustStoreArn);
253 return {
254 trustStoreArn,
255 name: trustStore.Name!,
256 status: active?.Status ?? trustStore.Status!,
257 numberOfCaCertificates:
258 active?.NumberOfCaCertificates ??
259 trustStore.NumberOfCaCertificates ??
260 0,
261 tags: desiredTags,
262 };
263 }),
264 delete: Effect.fn(function* ({ output }) {
265 yield* elbv2
266 .deleteTrustStore({ TrustStoreArn: output.trustStoreArn })
267 .pipe(
268 // In-use trust stores must wait for the listener to detach; the
269 // engine deletes dependents first, but retry briefly for the
270 // eventual-consistency window.
271 Effect.retry({
272 while: (e) => e._tag === "TrustStoreInUseException",
273 schedule: Schedule.max([
274 Schedule.spaced("3 seconds"),
275 Schedule.recurs(8),
276 ]),
277 }),
278 Effect.catchTag("TrustStoreNotFoundException", () => Effect.void),
279 Effect.catchTag("TrustStoreInUseException", () => Effect.void),
280 );
281 }),
282 };
283 }),
284 );