1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type { VpcId } from "./Vpc.ts";
12
13export type VpcPeeringConnectionId<ID extends string = string> = `pcx-${ID}`;
14export const VpcPeeringConnectionId = <ID extends string>(
15 id: ID,
16): ID & VpcPeeringConnectionId<ID> =>
17 `pcx-${id}` as ID & VpcPeeringConnectionId<ID>;
18
19export type VpcPeeringConnectionStatus =
20 | "initiating-request"
21 | "pending-acceptance"
22 | "active"
23 | "deleted"
24 | "rejected"
25 | "failed"
26 | "expired"
27 | "provisioning"
28 | "deleting";
29
30// Terminal states from which a peering connection cannot recover — observing
31// one of these means the cached connection is dead and must be recreated.
32const DEAD_STATES = new Set<string>([
33 "deleted",
34 "deleting",
35 "rejected",
36 "failed",
37 "expired",
38]);
39
40export interface VpcPeeringConnectionProps {
41 /**
42 * The ID of the requester VPC (the VPC you own that initiates the peering).
43 * Immutable — changing it replaces the connection.
44 */
45 vpcId: VpcId;
46
47 /**
48 * The ID of the accepter VPC with which to create the connection. May belong
49 * to another account (`peerOwnerId`) or Region (`peerRegion`). Immutable.
50 */
51 peerVpcId: VpcId;
52
53 /**
54 * The Region of the accepter VPC for an inter-Region peering. Defaults to the
55 * requester's Region (same-Region peering). Immutable.
56 */
57 peerRegion?: string;
58
59 /**
60 * The AWS account ID of the accepter VPC owner. Defaults to the requester's
61 * account (same-account peering). Immutable.
62 */
63 peerOwnerId?: string;
64
65 /**
66 * Whether to automatically accept the peering request. Only possible for
67 * same-account, same-Region peering (the accepter side must be reachable
68 * with the same credentials). Defaults to `true` when the peer is in the same
69 * account and Region, `false` otherwise (a cross-account/Region request stays
70 * in `pending-acceptance` until the peer accepts it out of band).
71 */
72 autoAccept?: boolean;
73
74 /**
75 * Tags to assign to the peering connection.
76 */
77 tags?: Record<string, string>;
78}
79
80export interface VpcPeeringConnection extends Resource<
81 "AWS.EC2.VpcPeeringConnection",
82 VpcPeeringConnectionProps,
83 {
84 /**
85 * The ID of the VPC peering connection (prefixed `pcx-`).
86 */
87 vpcPeeringConnectionId: VpcPeeringConnectionId;
88
89 /**
90 * The current status code of the peering connection.
91 */
92 status: VpcPeeringConnectionStatus;
93
94 /**
95 * The ID of the requester VPC.
96 */
97 requesterVpcId: VpcId;
98
99 /**
100 * The ID of the accepter VPC.
101 */
102 accepterVpcId: VpcId;
103
104 /**
105 * The AWS account ID of the accepter VPC owner.
106 */
107 accepterOwnerId: string;
108 },
109 never,
110 Providers
111> {}
112
113/**
114 * A VPC peering connection links two VPCs so resources in each can communicate
115 * using private IP addresses, as if they were on the same network. The two VPCs
116 * can be in the same account or different accounts, and the same Region or
117 * different Regions. Their CIDR blocks must not overlap.
118 *
119 * A peering connection is a two-sided handshake: a *requester* VPC creates the
120 * request and an *accepter* VPC accepts it. For same-account, same-Region
121 * peering alchemy accepts the request for you automatically (`autoAccept`
122 * defaults to `true`); for cross-account or cross-Region peering the connection
123 * is left in `pending-acceptance` for the peer to accept out of band. Once
124 * `active`, add {@link Route}s on both sides pointing the peer CIDR at the
125 * connection to actually carry traffic.
126 *
127 * ### Creating a Peering Connection
128 * **Example:** Same-Account Peering (auto-accepted)
129 * ```typescript
130 * const vpcA = yield* AWS.EC2.Vpc("VpcA", { cidrBlock: "10.0.0.0/16" });
131 * const vpcB = yield* AWS.EC2.Vpc("VpcB", { cidrBlock: "10.1.0.0/16" });
132 *
133 * const peering = yield* AWS.EC2.VpcPeeringConnection("Peering", {
134 * vpcId: vpcA.vpcId,
135 * peerVpcId: vpcB.vpcId,
136 * });
137 * ```
138 * Because both VPCs are in the same account and Region, the request is accepted
139 * automatically and the connection reaches the `active` state.
140 *
141 * **Example:** Cross-Account Peering (accepted out of band)
142 * ```typescript
143 * const peering = yield* AWS.EC2.VpcPeeringConnection("Peering", {
144 * vpcId: myVpc.vpcId,
145 * peerVpcId: "vpc-0abc123",
146 * peerOwnerId: "123456789012",
147 * });
148 * ```
149 * With a different `peerOwnerId` the connection stays in `pending-acceptance`
150 * until the peer account accepts it.
151 *
152 * ### Routing Traffic Across the Peering
153 * **Example:** Route the Peer CIDR at the Connection
154 * ```typescript
155 * const peering = yield* AWS.EC2.VpcPeeringConnection("Peering", {
156 * vpcId: vpcA.vpcId,
157 * peerVpcId: vpcB.vpcId,
158 * });
159 *
160 * const routeAtoB = yield* AWS.EC2.Route("RouteAtoB", {
161 * routeTableId: vpcARouteTable.routeTableId,
162 * destinationCidrBlock: "10.1.0.0/16",
163 * vpcPeeringConnectionId: peering.vpcPeeringConnectionId,
164 * });
165 * ```
166 * Each side needs a route pointing the other VPC's CIDR at the peering
167 * connection; only then can instances reach each other over private IPs.
168 *
169 * @resource
170 */
171export const VpcPeeringConnection = Resource<VpcPeeringConnection>(
172 "AWS.EC2.VpcPeeringConnection",
173);
174
175class PeeringNotSettled {
176 readonly _tag = "PeeringNotSettled";
177}
178
179export const VpcPeeringConnectionProvider = () =>
180 Provider.effect(
181 VpcPeeringConnection,
182 Effect.gen(function* () {
183 const createTags = Effect.fn(function* (
184 id: string,
185 tags?: Record<string, string>,
186 ) {
187 return {
188 Name: id,
189 ...(yield* createInternalTags(id)),
190 ...tags,
191 };
192 });
193
194 const describePeering = (pcxId: string) =>
195 ec2
196 .describeVpcPeeringConnections({ VpcPeeringConnectionIds: [pcxId] })
197 .pipe(
198 Effect.map((r) => r.VpcPeeringConnections?.[0]),
199 Effect.catchTag("InvalidVpcPeeringConnectionID.NotFound", () =>
200 Effect.succeed(undefined),
201 ),
202 Effect.catchTag("InvalidVpcPeeringConnectionId.NotFound", () =>
203 Effect.succeed(undefined),
204 ),
205 );
206
207 // Poll until the connection reaches a settled state (out of the transient
208 // "initiating-request"/"provisioning" states) or becomes `target`.
209 const waitFor = (pcxId: string, target: VpcPeeringConnectionStatus) =>
210 describePeering(pcxId).pipe(
211 Effect.flatMap((pcx) => {
212 const code = pcx?.Status?.Code;
213 if (code === target || (code && DEAD_STATES.has(code))) {
214 return Effect.succeed(pcx);
215 }
216 return Effect.fail(new PeeringNotSettled());
217 }),
218 Effect.retry({
219 while: (e) => e instanceof PeeringNotSettled,
220 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(30)]),
221 }),
222 );
223
224 const toAttrs = (pcx: ec2.VpcPeeringConnection) => ({
225 vpcPeeringConnectionId:
226 pcx.VpcPeeringConnectionId as VpcPeeringConnectionId,
227 status: (pcx.Status?.Code ??
228 "initiating-request") as VpcPeeringConnectionStatus,
229 requesterVpcId: pcx.RequesterVpcInfo?.VpcId as VpcId,
230 accepterVpcId: pcx.AccepterVpcInfo?.VpcId as VpcId,
231 accepterOwnerId: pcx.AccepterVpcInfo?.OwnerId ?? "",
232 });
233
234 return {
235 stables: ["vpcPeeringConnectionId"],
236
237 list: () =>
238 Effect.gen(function* () {
239 const items = yield* ec2.describeVpcPeeringConnections
240 .pages({})
241 .pipe(
242 Stream.runCollect,
243 Effect.map((chunk) =>
244 Array.from(chunk).flatMap((page) =>
245 (page.VpcPeeringConnections ?? [])
246 .filter(
247 (
248 pcx,
249 ): pcx is ec2.VpcPeeringConnection & {
250 VpcPeeringConnectionId: string;
251 } =>
252 pcx.VpcPeeringConnectionId != null &&
253 !DEAD_STATES.has(pcx.Status?.Code ?? ""),
254 )
255 .map(toAttrs),
256 ),
257 ),
258 );
259 return items satisfies VpcPeeringConnection["Attributes"][];
260 }),
261
262 read: Effect.fn(function* ({ output }) {
263 if (!output) return undefined;
264 const pcx = yield* describePeering(output.vpcPeeringConnectionId);
265 if (!pcx || DEAD_STATES.has(pcx.Status?.Code ?? "")) return undefined;
266 return toAttrs(pcx);
267 }),
268
269 diff: Effect.fn(function* ({ news, olds }) {
270 if (!isResolved(news)) return;
271 if (
272 news.vpcId !== olds.vpcId ||
273 news.peerVpcId !== olds.peerVpcId ||
274 news.peerRegion !== olds.peerRegion ||
275 news.peerOwnerId !== olds.peerOwnerId
276 ) {
277 return { action: "replace" };
278 }
279 // Only tags are mutable in place.
280 }),
281
282 reconcile: Effect.fn(function* ({ id, news, output, session }) {
283 const { accountId, region } = yield* AWSEnvironment.current;
284 const desiredTags = yield* createTags(id, news.tags);
285
286 const sameAccount =
287 news.peerOwnerId === undefined || news.peerOwnerId === accountId;
288 const sameRegion =
289 news.peerRegion === undefined || news.peerRegion === region;
290 const autoAccept = news.autoAccept ?? (sameAccount && sameRegion);
291
292 // Observe — the cached connection may have been deleted or moved to a
293 // terminal state, in which case we recreate.
294 let pcx: ec2.VpcPeeringConnection | undefined;
295 if (output?.vpcPeeringConnectionId) {
296 pcx = yield* describePeering(output.vpcPeeringConnectionId);
297 if (pcx && DEAD_STATES.has(pcx.Status?.Code ?? "")) {
298 pcx = undefined;
299 }
300 }
301
302 // Ensure — create the connection if missing.
303 if (pcx === undefined) {
304 yield* session.note("Creating VPC peering connection...");
305 const result = yield* ec2.createVpcPeeringConnection({
306 VpcId: news.vpcId as string,
307 PeerVpcId: news.peerVpcId as string,
308 PeerRegion: news.peerRegion,
309 PeerOwnerId: news.peerOwnerId,
310 TagSpecifications: [
311 {
312 ResourceType: "vpc-peering-connection",
313 Tags: createTagsList(desiredTags),
314 },
315 ],
316 });
317 pcx = result.VpcPeeringConnection!;
318 yield* session.note(
319 `VPC peering connection created: ${pcx.VpcPeeringConnectionId}`,
320 );
321 }
322
323 const pcxId = pcx.VpcPeeringConnectionId!;
324
325 // Accept — for same-account/same-Region peering, wait for the request
326 // to settle into pending-acceptance, then accept it.
327 if (autoAccept) {
328 const settled = yield* waitFor(pcxId, "pending-acceptance");
329 if (settled?.Status?.Code === "pending-acceptance") {
330 yield* session.note("Accepting VPC peering connection...");
331 yield* ec2
332 .acceptVpcPeeringConnection({ VpcPeeringConnectionId: pcxId })
333 .pipe(
334 // A concurrent reconcile may have accepted it already.
335 Effect.catchTag(
336 "InvalidVpcPeeringConnectionID.NotFound",
337 () => Effect.succeed(undefined),
338 ),
339 Effect.catchTag(
340 "InvalidVpcPeeringConnectionId.NotFound",
341 () => Effect.succeed(undefined),
342 ),
343 );
344 yield* waitFor(pcxId, "active");
345 }
346 }
347
348 // Sync tags — observed cloud tags vs desired.
349 const currentTags =
350 (yield* ec2
351 .describeTags({
352 Filters: [
353 { Name: "resource-id", Values: [pcxId] },
354 {
355 Name: "resource-type",
356 Values: ["vpc-peering-connection"],
357 },
358 ],
359 })
360 .pipe(
361 Effect.map(
362 (r) =>
363 Object.fromEntries(
364 r.Tags?.map((t) => [t.Key!, t.Value!]) ?? [],
365 ) as Record<string, string>,
366 ),
367 )) ?? {};
368 const { removed, upsert } = diffTags(currentTags, desiredTags);
369 if (removed.length > 0) {
370 yield* ec2.deleteTags({
371 Resources: [pcxId],
372 Tags: removed.map((key) => ({ Key: key })),
373 });
374 }
375 if (upsert.length > 0) {
376 yield* ec2.createTags({ Resources: [pcxId], Tags: upsert });
377 }
378
379 const final = yield* describePeering(pcxId);
380 return toAttrs(final ?? pcx);
381 }),
382
383 delete: Effect.fn(function* ({ output, session }) {
384 const pcxId = output.vpcPeeringConnectionId;
385
386 // A connection already in a terminal state cannot (and need not) be
387 // deleted — AWS rejects deleting failed/rejected connections.
388 const observed = yield* describePeering(pcxId);
389 if (!observed || DEAD_STATES.has(observed.Status?.Code ?? "")) {
390 return;
391 }
392
393 yield* session.note(`Deleting VPC peering connection: ${pcxId}`);
394 yield* ec2
395 .deleteVpcPeeringConnection({ VpcPeeringConnectionId: pcxId })
396 .pipe(
397 Effect.catchTag(
398 "InvalidVpcPeeringConnectionID.NotFound",
399 () => Effect.void,
400 ),
401 Effect.catchTag(
402 "InvalidVpcPeeringConnectionId.NotFound",
403 () => Effect.void,
404 ),
405 );
406 }),
407 };
408 }),
409 );