1import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
2import * as acm from "@distilled.cloud/aws/acm";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { toWireDays } from "../../Util/Duration.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface AccountConfigurationProps {
12 /**
13 * How long before certificate expiration ACM starts emitting daily
14 * `ACM Certificate Approaching Expiration` EventBridge events for each
15 * certificate in the account.
16 *
17 * The wire unit is whole days and AWS accepts between 1 and 45 days.
18 * Accepts any `Duration.Input` (e.g. `"30 days"`); the provider converts to
19 * whole days.
20 *
21 * @default "45 days" (the AWS account default)
22 */
23 daysBeforeExpiry?: Duration.Input;
24}
25
26export interface AccountConfiguration extends Resource<
27 "AWS.ACM.AccountConfiguration",
28 AccountConfigurationProps,
29 {
30 /**
31 * Days before certificate expiration when ACM starts emitting expiry
32 * events, as currently configured on the account.
33 */
34 daysBeforeExpiry: number;
35 },
36 never,
37 Providers
38> {}
39
40/**
41 * Account-level ACM configuration (`AWS::CertificateManager::Account`).
42 *
43 * ACM emits one `ACM Certificate Approaching Expiration` EventBridge event
44 * per day per certificate starting `daysBeforeExpiry` days before each
45 * certificate expires. This account-global singleton manages that threshold
46 * via `acm:PutAccountConfiguration`. Deleting the resource resets the
47 * threshold to the AWS default of 45 days.
48 *
49 * Like the {@link Certificate} resource, the provider pins its API calls to
50 * `us-east-1`.
51 *
52 * ### Configuring Expiry Events
53 * **Example:** Start Expiry Events 30 Days Before Expiration
54 * ```typescript
55 * const config = yield* AccountConfiguration("AcmAccount", {
56 * daysBeforeExpiry: "30 days",
57 * });
58 * ```
59 *
60 * **Example:** Consume the Expiry Events
61 * ```typescript
62 * // The events arrive on the default EventBridge bus with source "aws.acm".
63 * yield* AWS.ACM.consumeExpiryEvents({}, (events) =>
64 * Stream.runForEach(events, (event) =>
65 * Effect.log(
66 * `${event.detail.CommonName} expires in ${event.detail.DaysToExpiry} days`,
67 * ),
68 * ),
69 * );
70 * ```
71 *
72 * @resource
73 */
74export const AccountConfiguration = Resource<AccountConfiguration>(
75 "AWS.ACM.AccountConfiguration",
76);
77
78/** The AWS account default for `DaysBeforeExpiry`. */
79const DEFAULT_DAYS_BEFORE_EXPIRY = 45;
80
81const ACM_REGION = "us-east-1" as const;
82
83const withAcmRegion = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
84 // `AwsRegion`'s service value is an `Effect<RegionName>`, so it must be
85 // provided as an effect, not a bare string.
86 effect.pipe(Effect.provideService(AwsRegion, Effect.succeed(ACM_REGION)));
87
88/**
89 * ACM treats a repeated `IdempotencyToken` within one hour as the SAME
90 * request: replaying a token with different parameters is a typed
91 * `ConflictException`, and replaying it with the same parameters returns the
92 * cached original result WITHOUT re-applying it. So the token must vary with
93 * the desired value AND the attempt — the seed is truncated first so the
94 * discriminating suffix always survives.
95 */
96const idempotencyToken = (seed: string, desiredDays: number, attempt: number) =>
97 `${seed.replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, 20)}d${desiredDays}a${attempt}`;
98
99export const AccountConfigurationProvider = () =>
100 Provider.effect(
101 AccountConfiguration,
102 Effect.gen(function* () {
103 const observe = withAcmRegion(
104 acm.getAccountConfiguration({}).pipe(
105 Effect.map((response) => ({
106 daysBeforeExpiry:
107 response.ExpiryEvents?.DaysBeforeExpiry ??
108 DEFAULT_DAYS_BEFORE_EXPIRY,
109 })),
110 ),
111 );
112
113 // Converge the account threshold onto `desiredDays`, verifying the
114 // observed value after each put. The verify loop covers the exotic
115 // idempotency-cache case where ACM accepts a replayed token but does
116 // not re-apply it (see `idempotencyToken`): the next attempt salts a
117 // fresh token. Throttling is retried bounded.
118 const put = Effect.fn(function* (desiredDays: number, tokenSeed: string) {
119 for (let attempt = 0; attempt < 3; attempt++) {
120 yield* withAcmRegion(
121 acm
122 .putAccountConfiguration({
123 ExpiryEvents: { DaysBeforeExpiry: desiredDays },
124 IdempotencyToken: idempotencyToken(
125 tokenSeed,
126 desiredDays,
127 attempt,
128 ),
129 })
130 .pipe(
131 Effect.retry({
132 while: (e): boolean => e._tag === "ThrottlingException",
133 schedule: Schedule.max([
134 Schedule.exponential("1 second"),
135 Schedule.recurs(5),
136 ]),
137 }),
138 ),
139 );
140 const observed = yield* observe;
141 if (observed.daysBeforeExpiry === desiredDays) {
142 return observed;
143 }
144 }
145 return yield* Effect.fail(
146 new Error(
147 `ACM account configuration did not converge to ${desiredDays} days before expiry`,
148 ),
149 );
150 });
151
152 return {
153 // Account-global singleton setting: nuke must not reset it.
154 nuke: { singleton: true },
155 stables: [],
156 read: Effect.fn(function* () {
157 return yield* observe;
158 }),
159 reconcile: Effect.fn(function* ({ instanceId, news, session }) {
160 const desiredDays =
161 toWireDays(news.daysBeforeExpiry) ?? DEFAULT_DAYS_BEFORE_EXPIRY;
162
163 // Observe the live threshold; only call the API on a real delta.
164 const observed = yield* observe;
165 if (observed.daysBeforeExpiry !== desiredDays) {
166 yield* put(desiredDays, instanceId);
167 }
168
169 yield* session.note(`${desiredDays} days before expiry`);
170 return { daysBeforeExpiry: desiredDays };
171 }),
172 // Account-global singleton: the configuration always exists.
173 list: () => observe.pipe(Effect.map((observed) => [observed])),
174 // Deleting stops managing the threshold and restores the AWS
175 // account default (45 days). Idempotent: a no-op when the account
176 // is already at the default.
177 delete: Effect.fn(function* ({ instanceId }) {
178 const observed = yield* observe;
179 if (observed.daysBeforeExpiry !== DEFAULT_DAYS_BEFORE_EXPIRY) {
180 yield* put(DEFAULT_DAYS_BEFORE_EXPIRY, `${instanceId}reset`);
181 }
182 }),
183 };
184 }),
185 );