1import * as apprunner from "@distilled.cloud/aws/apprunner";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 isActiveStatus,
15 readAppRunnerTags,
16 syncAppRunnerTags,
17 toWireTags,
18} from "./internal.ts";
19
20export interface VpcConnectorProps {
21 /**
22 * Name of the VPC connector. Must be 4-40 characters. If omitted, a
23 * deterministic physical name is generated. Changing the name replaces
24 * the connector.
25 */
26 vpcConnectorName?: string;
27 /**
28 * IDs of the VPC subnets App Runner uses for outgoing (egress) traffic.
29 * All subnets must belong to the same VPC. VPC connectors are
30 * immutable — changing subnets replaces the connector.
31 */
32 subnets: string[];
33 /**
34 * IDs of the security groups applied to the connector's network
35 * interfaces. Changing security groups replaces the connector.
36 * @default the VPC's default security group
37 */
38 securityGroups?: string[];
39 /**
40 * User-defined tags for the connector.
41 */
42 tags?: Record<string, string>;
43}
44
45export interface VpcConnector extends Resource<
46 "AWS.AppRunner.VpcConnector",
47 VpcConnectorProps,
48 {
49 /**
50 * Name of the VPC connector.
51 */
52 vpcConnectorName: string;
53 /**
54 * ARN of this VPC connector revision.
55 */
56 vpcConnectorArn: string;
57 /**
58 * Revision number of the connector (revisions are immutable).
59 */
60 vpcConnectorRevision: number;
61 /**
62 * Subnets the connector attaches to.
63 */
64 subnets: string[];
65 /**
66 * Security groups applied to outbound traffic.
67 */
68 securityGroups: string[];
69 /**
70 * Current status of the connector (e.g. `ACTIVE`).
71 */
72 status: string;
73 },
74 never,
75 Providers
76> {}
77
78/**
79 * An AWS App Runner VPC connector. Associating a connector with an App
80 * Runner service routes the service's outbound traffic through your VPC
81 * (e.g. to reach an RDS database in private subnets).
82 *
83 * VPC connectors are immutable: any change to subnets or security groups
84 * replaces the connector.
85 * ### Creating a VPC Connector
86 * **Example:** Connector over Two Subnets
87 * ```typescript
88 * const connector = yield* AppRunner.VpcConnector("Egress", {
89 * subnets: [subnetA.subnetId, subnetB.subnetId],
90 * securityGroups: [egressSecurityGroup.securityGroupId],
91 * });
92 * ```
93 *
94 * ### Routing a Service through the VPC
95 * **Example:** Service with VPC Egress
96 * ```typescript
97 * const service = yield* AppRunner.Service("Api", {
98 * imageRepository: {
99 * imageIdentifier: image.imageUri,
100 * imageRepositoryType: "ECR",
101 * port: "8080",
102 * },
103 * accessRoleArn: accessRole.roleArn,
104 * networkConfiguration: {
105 * egressType: "VPC",
106 * vpcConnectorArn: connector.vpcConnectorArn,
107 * },
108 * });
109 * ```
110 *
111 * @resource
112 */
113export const VpcConnector = Resource<VpcConnector>(
114 "AWS.AppRunner.VpcConnector",
115);
116
117const sameStringSet = (
118 a: readonly string[] | undefined,
119 b: readonly string[] | undefined,
120): boolean => {
121 const left = [...(a ?? [])].sort();
122 const right = [...(b ?? [])].sort();
123 return left.length === right.length && left.every((v, i) => v === right[i]);
124};
125
126class VpcConnectorStillActive extends Data.TaggedError(
127 "VpcConnectorStillActive",
128)<{
129 readonly vpcConnectorArn: string;
130 readonly status: string;
131}> {}
132
133export const VpcConnectorProvider = () =>
134 Provider.effect(
135 VpcConnector,
136 Effect.gen(function* () {
137 const toName = (id: string, props: Partial<VpcConnectorProps>) =>
138 props.vpcConnectorName
139 ? Effect.succeed(props.vpcConnectorName)
140 : createPhysicalName({ id, maxLength: 40 });
141
142 const listConnectors = () =>
143 apprunner.listVpcConnectors.pages({}).pipe(
144 Stream.runCollect,
145 Effect.map((chunk) =>
146 Array.from(chunk).flatMap((page) => page.VpcConnectors ?? []),
147 ),
148 );
149
150 /**
151 * Find the latest ACTIVE revision of a connector by name. The list
152 * API has no name filter, so enumerate and filter client-side.
153 */
154 const findConnector = Effect.fn(function* (name: string) {
155 const connectors = yield* listConnectors();
156 return connectors
157 .filter(
158 (c) => c.VpcConnectorName === name && isActiveStatus(c.Status),
159 )
160 .sort(
161 (a, b) =>
162 (b.VpcConnectorRevision ?? 0) - (a.VpcConnectorRevision ?? 0),
163 )[0];
164 });
165
166 const toAttrs = Effect.fn(function* (connector: apprunner.VpcConnector) {
167 if (
168 !connector.VpcConnectorName ||
169 !connector.VpcConnectorArn ||
170 connector.VpcConnectorRevision === undefined
171 ) {
172 return yield* Effect.fail(
173 new Error(
174 "App Runner VPC connector is missing its name, ARN, or revision",
175 ),
176 );
177 }
178 return {
179 vpcConnectorName: connector.VpcConnectorName,
180 vpcConnectorArn: connector.VpcConnectorArn,
181 vpcConnectorRevision: connector.VpcConnectorRevision,
182 subnets: [...(connector.Subnets ?? [])],
183 securityGroups: [...(connector.SecurityGroups ?? [])],
184 status: connector.Status ?? "ACTIVE",
185 };
186 });
187
188 return {
189 stables: ["vpcConnectorName"],
190
191 diff: Effect.fn(function* ({ id, olds, news }) {
192 if (!isResolved(news)) return undefined;
193 if (
194 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
195 ) {
196 return { action: "replace" } as const;
197 }
198 // VPC connectors are immutable — subnet or security group
199 // changes force a replacement.
200 if (
201 news?.subnets !== undefined &&
202 olds?.subnets !== undefined &&
203 !sameStringSet(news.subnets, olds.subnets)
204 ) {
205 return { action: "replace" } as const;
206 }
207 if (
208 news?.securityGroups !== undefined &&
209 olds?.securityGroups !== undefined &&
210 !sameStringSet(news.securityGroups, olds.securityGroups)
211 ) {
212 return { action: "replace" } as const;
213 }
214 }),
215
216 read: Effect.fn(function* ({ id, olds, output }) {
217 const name =
218 output?.vpcConnectorName ?? (yield* toName(id, olds ?? {}));
219 const connector = yield* findConnector(name);
220 if (connector === undefined) return undefined;
221 const attrs = yield* toAttrs(connector);
222 const tags = yield* readAppRunnerTags(attrs.vpcConnectorArn);
223 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
224 }),
225
226 reconcile: Effect.fn(function* ({ id, news, output, session }) {
227 const name =
228 output?.vpcConnectorName ?? (yield* toName(id, news ?? {}));
229 const internalTags = yield* createInternalTags(id);
230 const desiredTags = { ...internalTags, ...news?.tags };
231
232 // 1. Observe — cloud state is authoritative.
233 let observed = yield* findConnector(name);
234
235 // 2. Ensure — the connector is immutable, so reconcile only
236 // ensures existence (diff handles replacement on any change).
237 if (observed === undefined) {
238 const created = yield* apprunner.createVpcConnector({
239 VpcConnectorName: name,
240 Subnets: news?.subnets ?? [],
241 SecurityGroups: news?.securityGroups,
242 Tags: toWireTags(desiredTags),
243 });
244 observed = created.VpcConnector;
245 }
246
247 // 3. Sync tags — diff against OBSERVED cloud tags.
248 if (observed.VpcConnectorArn) {
249 yield* syncAppRunnerTags(observed.VpcConnectorArn, desiredTags);
250 }
251
252 // 4. Return fresh attributes.
253 yield* session.note(name);
254 return yield* toAttrs(observed);
255 }),
256
257 delete: Effect.fn(function* ({ output }) {
258 // A connector still associated with a service (App Runner
259 // releases the association asynchronously after the service
260 // finishes deleting) rejects with InvalidRequestException —
261 // retry through that window (bounded).
262 yield* apprunner
263 .deleteVpcConnector({
264 VpcConnectorArn: output.vpcConnectorArn,
265 })
266 .pipe(
267 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
268 Effect.retry({
269 while: (e) =>
270 e._tag === "InvalidRequestException" ||
271 e._tag === "InternalServiceErrorException",
272 schedule: Schedule.fixed("5 seconds"),
273 times: 8,
274 }),
275 );
276
277 // App Runner retains deleted connector revisions as INACTIVE, so
278 // INACTIVE is the service's terminal deleted state. Do not release
279 // Alchemy state while the connector is still ACTIVE: App Runner's
280 // managed ENIs would keep stack-owned subnets and security groups in
281 // use and make their bottom-up teardown flaky.
282 yield* apprunner
283 .describeVpcConnector({
284 VpcConnectorArn: output.vpcConnectorArn,
285 })
286 .pipe(
287 Effect.flatMap(({ VpcConnector }) => {
288 const status = (VpcConnector.Status ?? "ACTIVE").toUpperCase();
289 return status === "INACTIVE"
290 ? Effect.void
291 : Effect.fail(
292 new VpcConnectorStillActive({
293 vpcConnectorArn: output.vpcConnectorArn,
294 status,
295 }),
296 );
297 }),
298 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
299 Effect.retry({
300 while: (e) =>
301 e._tag === "VpcConnectorStillActive" ||
302 e._tag === "InternalServiceErrorException",
303 schedule: Schedule.fixed("3 seconds"),
304 times: 10,
305 }),
306 );
307 }),
308
309 list: () =>
310 listConnectors().pipe(
311 Effect.map((connectors) =>
312 connectors.filter((c) => isActiveStatus(c.Status)),
313 ),
314 Effect.flatMap(
315 Effect.forEach((connector) => toAttrs(connector), {
316 concurrency: 4,
317 }),
318 ),
319 ),
320 };
321 }),
322 );