1import * as apprunner from "@distilled.cloud/aws/apprunner";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 isActiveStatus,
14 readAppRunnerTags,
15 syncAppRunnerTags,
16 toWireTags,
17} from "./internal.ts";
18
19/**
20 * Derive the revision-less "name partial" ARN
21 * (`...:autoscalingconfiguration/{name}`) from any configuration ARN.
22 * `DeleteAllRevisions` rejects revision-qualified ARNs (observed live:
23 * "You cannot specify full auto scaling configuration ARN and
24 * DeleteAllRevisions as true at same time").
25 */
26const toNamePartialArn = (arn: string): string =>
27 // "arn:...:autoscalingconfiguration/{name}/{revision}/{uuid}" -> keep
28 // everything up to and including {name}.
29 arn.split("/").slice(0, 2).join("/");
30
31export interface AutoScalingConfigurationProps {
32 /**
33 * Name of the auto scaling configuration. Must be 4-32 characters
34 * (letters, digits, hyphens, underscores). If omitted, a deterministic
35 * physical name is generated. Changing the name replaces the
36 * configuration (all revisions of the old name are deleted).
37 */
38 autoScalingConfigurationName?: string;
39 /**
40 * Maximum number of concurrent requests an instance processes before
41 * App Runner scales up.
42 * @default 100
43 */
44 maxConcurrency?: number;
45 /**
46 * Minimum number of provisioned (warm) instances. Higher values spread
47 * the service over more Availability Zones at a higher minimal cost.
48 * @default 1
49 */
50 minSize?: number;
51 /**
52 * Maximum number of instances the service scales up to.
53 * @default 25
54 */
55 maxSize?: number;
56 /**
57 * User-defined tags for the configuration.
58 */
59 tags?: Record<string, string>;
60}
61
62export interface AutoScalingConfiguration extends Resource<
63 "AWS.AppRunner.AutoScalingConfiguration",
64 AutoScalingConfigurationProps,
65 {
66 /**
67 * Name of the auto scaling configuration.
68 */
69 autoScalingConfigurationName: string;
70 /**
71 * ARN of this auto scaling configuration revision.
72 */
73 autoScalingConfigurationArn: string;
74 /**
75 * Revision number of the configuration (revisions are immutable).
76 */
77 autoScalingConfigurationRevision: number;
78 /**
79 * Maximum concurrent requests per instance before scaling out.
80 */
81 maxConcurrency: number | undefined;
82 /**
83 * Minimum number of provisioned instances.
84 */
85 minSize: number | undefined;
86 /**
87 * Maximum number of instances the service may scale out to.
88 */
89 maxSize: number | undefined;
90 },
91 never,
92 Providers
93> {}
94
95/**
96 * An AWS App Runner auto scaling configuration.
97 *
98 * Auto scaling configurations are immutable revisions: changing
99 * `maxConcurrency`, `minSize`, or `maxSize` creates a new revision under
100 * the same name (the ARN and revision attributes change). A configuration
101 * can be shared across multiple App Runner services.
102 * ### Creating an Auto Scaling Configuration
103 * **Example:** Basic Configuration
104 * ```typescript
105 * const scaling = yield* AppRunner.AutoScalingConfiguration("Scaling", {
106 * maxConcurrency: 50,
107 * minSize: 1,
108 * maxSize: 3,
109 * });
110 * ```
111 *
112 * ### Using with an App Runner Service
113 * **Example:** Attach to a Service
114 * ```typescript
115 * const service = yield* AppRunner.Service("Api", {
116 * imageRepository: {
117 * imageIdentifier: "public.ecr.aws/aws-containers/hello-app-runner:latest",
118 * imageRepositoryType: "ECR_PUBLIC",
119 * port: "8000",
120 * },
121 * autoScalingConfigurationArn: scaling.autoScalingConfigurationArn,
122 * });
123 * ```
124 *
125 * @resource
126 */
127export const AutoScalingConfiguration = Resource<AutoScalingConfiguration>(
128 "AWS.AppRunner.AutoScalingConfiguration",
129);
130
131export const AutoScalingConfigurationProvider = () =>
132 Provider.effect(
133 AutoScalingConfiguration,
134 Effect.gen(function* () {
135 const toName = (id: string, props: AutoScalingConfigurationProps) =>
136 props.autoScalingConfigurationName
137 ? Effect.succeed(props.autoScalingConfigurationName)
138 : createPhysicalName({ id, maxLength: 32 });
139
140 /** Find the latest ACTIVE revision of a configuration by name. */
141 const findLatest = Effect.fn(function* (name: string) {
142 const page = yield* apprunner.listAutoScalingConfigurations({
143 AutoScalingConfigurationName: name,
144 LatestOnly: true,
145 });
146 const summary = (page.AutoScalingConfigurationSummaryList ?? []).find(
147 (s) =>
148 s.AutoScalingConfigurationName === name &&
149 isActiveStatus(s.Status) &&
150 s.AutoScalingConfigurationArn !== undefined,
151 );
152 if (!summary?.AutoScalingConfigurationArn) return undefined;
153 const described = yield* apprunner
154 .describeAutoScalingConfiguration({
155 AutoScalingConfigurationArn: summary.AutoScalingConfigurationArn,
156 })
157 .pipe(
158 Effect.catchTag("ResourceNotFoundException", () =>
159 Effect.succeed(undefined),
160 ),
161 );
162 const config = described?.AutoScalingConfiguration;
163 return config !== undefined && isActiveStatus(config.Status)
164 ? config
165 : undefined;
166 });
167
168 const toAttrs = Effect.fn(function* (
169 config: apprunner.AutoScalingConfiguration,
170 ) {
171 if (
172 !config.AutoScalingConfigurationName ||
173 !config.AutoScalingConfigurationArn ||
174 config.AutoScalingConfigurationRevision === undefined
175 ) {
176 return yield* Effect.fail(
177 new Error(
178 "App Runner auto scaling configuration is missing its name, ARN, or revision",
179 ),
180 );
181 }
182 return {
183 autoScalingConfigurationName: config.AutoScalingConfigurationName,
184 autoScalingConfigurationArn: config.AutoScalingConfigurationArn,
185 autoScalingConfigurationRevision:
186 config.AutoScalingConfigurationRevision,
187 maxConcurrency: config.MaxConcurrency,
188 minSize: config.MinSize,
189 maxSize: config.MaxSize,
190 };
191 });
192
193 return {
194 stables: ["autoScalingConfigurationName"],
195
196 diff: Effect.fn(function* ({ id, olds, news }) {
197 if (!isResolved(news)) return undefined;
198 if (
199 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
200 ) {
201 return { action: "replace" } as const;
202 }
203 }),
204
205 read: Effect.fn(function* ({ id, olds, output }) {
206 const name =
207 output?.autoScalingConfigurationName ??
208 (yield* toName(id, olds ?? {}));
209 const config = yield* findLatest(name);
210 if (config === undefined) return undefined;
211 const attrs = yield* toAttrs(config);
212 const tags = yield* readAppRunnerTags(
213 attrs.autoScalingConfigurationArn,
214 );
215 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
216 }),
217
218 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
219 const name =
220 output?.autoScalingConfigurationName ?? (yield* toName(id, news));
221 const internalTags = yield* createInternalTags(id);
222 const desiredTags = { ...internalTags, ...news.tags };
223
224 // 1. Observe — the latest ACTIVE revision is authoritative.
225 let observed = yield* findLatest(name);
226
227 // 2/3. Ensure + sync — scaling settings live on immutable
228 // revisions, so a drift on any user-specified field is converged
229 // by creating a new revision under the same name. Only compare
230 // fields the user actually specified: the service fills in
231 // defaults that must not trigger spurious revisions.
232 const drifted =
233 observed !== undefined &&
234 ((news.maxConcurrency !== undefined &&
235 news.maxConcurrency !== observed.MaxConcurrency) ||
236 (news.minSize !== undefined &&
237 news.minSize !== observed.MinSize) ||
238 (news.maxSize !== undefined &&
239 news.maxSize !== observed.MaxSize));
240
241 if (observed === undefined || drifted) {
242 const created = yield* apprunner.createAutoScalingConfiguration({
243 AutoScalingConfigurationName: name,
244 MaxConcurrency: news.maxConcurrency,
245 MinSize: news.minSize,
246 MaxSize: news.maxSize,
247 Tags: toWireTags(desiredTags),
248 });
249 observed = created.AutoScalingConfiguration;
250 }
251
252 // 3b. Sync tags on the latest revision ARN — diff against
253 // OBSERVED cloud tags.
254 if (observed.AutoScalingConfigurationArn) {
255 yield* syncAppRunnerTags(
256 observed.AutoScalingConfigurationArn,
257 desiredTags,
258 );
259 }
260
261 // 4. Return fresh attributes.
262 yield* session.note(name);
263 return yield* toAttrs(observed);
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 // DeleteAllRevisions removes every revision that shares the
268 // configuration name; it requires the revision-less name partial
269 // ARN. A configuration still referenced by a service (App Runner
270 // releases the association asynchronously after the service
271 // finishes deleting) rejects with InvalidRequestException —
272 // retry through that window (bounded).
273 yield* apprunner
274 .deleteAutoScalingConfiguration({
275 AutoScalingConfigurationArn: toNamePartialArn(
276 output.autoScalingConfigurationArn,
277 ),
278 DeleteAllRevisions: true,
279 })
280 .pipe(
281 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
282 Effect.retry({
283 while: (e) => e._tag === "InvalidRequestException",
284 schedule: Schedule.max([
285 Schedule.fixed("5 seconds"),
286 Schedule.recurs(24),
287 ]),
288 }),
289 );
290 }),
291
292 list: () =>
293 apprunner.listAutoScalingConfigurations
294 .pages({ LatestOnly: true })
295 .pipe(
296 Stream.runCollect,
297 Effect.map((chunk) =>
298 Array.from(chunk).flatMap((page) =>
299 (page.AutoScalingConfigurationSummaryList ?? []).filter(
300 (
301 s,
302 ): s is apprunner.AutoScalingConfigurationSummary & {
303 AutoScalingConfigurationArn: string;
304 } =>
305 isActiveStatus(s.Status) &&
306 s.AutoScalingConfigurationArn !== undefined &&
307 // App Runner ships an AWS-managed `DefaultConfiguration`
308 // revision that always exists and can never be deleted
309 // — keep it out of enumeration for account-wide
310 // teardown (nuke).
311 s.AutoScalingConfigurationName !== "DefaultConfiguration",
312 ),
313 ),
314 ),
315 Effect.flatMap(
316 Effect.forEach(
317 (summary) =>
318 apprunner
319 .describeAutoScalingConfiguration({
320 AutoScalingConfigurationArn:
321 summary.AutoScalingConfigurationArn,
322 })
323 .pipe(
324 Effect.flatMap((r) =>
325 toAttrs(r.AutoScalingConfiguration),
326 ),
327 // Tolerate a delete race — drop the item.
328 Effect.catchTag("ResourceNotFoundException", () =>
329 Effect.succeed(undefined),
330 ),
331 ),
332 { concurrency: 4 },
333 ),
334 ),
335 Effect.map((items) => items.filter((item) => item !== undefined)),
336 ),
337 };
338 }),
339 );