1import type * as EC2 from "@distilled.cloud/aws/ec2";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import type { ScopedPlanStatusSession } from "../../Report.ts";
9import { isResolved } from "../../Diff.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
13import type { AccountID } from "../Environment.ts";
14import { AWSEnvironment } from "../Environment.ts";
15import type { Providers } from "../Providers.ts";
16import type { RegionID } from "../Region.ts";
17import type { RouteTableId } from "./RouteTable.ts";
18import type { SecurityGroupId } from "./SecurityGroup.ts";
19import type { SubnetId } from "./Subnet.ts";
20import type { VpcId } from "./Vpc.ts";
21
22export type VpcEndpointId<ID extends string = string> = `vpce-${ID}`;
23export const VpcEndpointId = <ID extends string>(
24 id: ID,
25): ID & VpcEndpointId<ID> => `vpce-${id}` as ID & VpcEndpointId<ID>;
26
27export type VpcEndpointArn =
28 `arn:aws:ec2:${RegionID}:${AccountID}:vpc-endpoint/${VpcEndpointId}`;
29
30export interface VpcEndpointProps {
31 /**
32 * The VPC to create the endpoint in.
33 */
34 vpcId: VpcId;
35
36 /**
37 * The service name.
38 * For AWS services, use the format: com.amazonaws.<region>.<service>
39 * @example "com.amazonaws.us-east-1.s3"
40 */
41 serviceName: string;
42
43 /**
44 * The type of endpoint.
45 * - Gateway: For S3 and DynamoDB (route table based)
46 * - Interface: For most other AWS services (ENI based)
47 * - GatewayLoadBalancer: For Gateway Load Balancer endpoints
48 * @default "Gateway"
49 */
50 vpcEndpointType?: EC2.VpcEndpointType;
51
52 /**
53 * The IDs of route tables for a Gateway endpoint.
54 * Required for Gateway endpoints.
55 */
56 routeTableIds?: RouteTableId[];
57
58 /**
59 * The IDs of subnets for an Interface endpoint.
60 * Required for Interface endpoints.
61 */
62 subnetIds?: SubnetId[];
63
64 /**
65 * The IDs of security groups for an Interface endpoint.
66 * Required for Interface endpoints.
67 */
68 securityGroupIds?: SecurityGroupId[];
69
70 /**
71 * Whether to associate a private hosted zone with the VPC.
72 * Only applicable for Interface endpoints.
73 * @default true
74 */
75 privateDnsEnabled?: boolean;
76
77 /**
78 * A policy to attach to the endpoint that controls access to the service.
79 * The policy document must be in JSON format.
80 */
81 policyDocument?: string;
82
83 /**
84 * The IP address type for the endpoint.
85 */
86 ipAddressType?: EC2.IpAddressType;
87
88 /**
89 * The DNS options for the endpoint.
90 */
91 dnsOptions?: {
92 dnsRecordIpType?: EC2.DnsRecordIpType;
93 privateDnsOnlyForInboundResolverEndpoint?: boolean;
94 };
95
96 /**
97 * Tags to assign to the VPC endpoint.
98 */
99 tags?: Record<string, string>;
100}
101
102export interface VpcEndpoint extends Resource<
103 "AWS.EC2.VpcEndpoint",
104 VpcEndpointProps,
105 {
106 /**
107 * The ID of the VPC endpoint.
108 */
109 vpcEndpointId: VpcEndpointId;
110
111 /**
112 * The Amazon Resource Name (ARN) of the VPC endpoint.
113 */
114 vpcEndpointArn: VpcEndpointArn;
115
116 /**
117 * The type of endpoint.
118 */
119 vpcEndpointType: EC2.VpcEndpointType;
120
121 /**
122 * The ID of the VPC.
123 */
124 vpcId: VpcId;
125
126 /**
127 * The service name.
128 */
129 serviceName: string;
130
131 /**
132 * The current state of the VPC endpoint.
133 */
134 state: EC2.State;
135
136 /**
137 * The policy document associated with the endpoint.
138 */
139 policyDocument?: string;
140
141 /**
142 * The IDs of the route tables associated with the endpoint.
143 */
144 routeTableIds?: string[];
145
146 /**
147 * The IDs of the subnets associated with the endpoint.
148 */
149 subnetIds?: string[];
150
151 /**
152 * Information about the security groups associated with the network interfaces.
153 */
154 groups?: Array<{
155 groupId: string;
156 groupName: string;
157 }>;
158
159 /**
160 * Whether private DNS is enabled.
161 */
162 privateDnsEnabled?: boolean;
163
164 /**
165 * Whether the VPC endpoint is being managed by its service.
166 */
167 requesterManaged?: boolean;
168
169 /**
170 * The IDs of the network interfaces for the endpoint.
171 */
172 networkInterfaceIds?: string[];
173
174 /**
175 * The DNS entries for the endpoint.
176 */
177 dnsEntries?: Array<{
178 dnsName?: string;
179 hostedZoneId?: string;
180 }>;
181
182 /**
183 * The date and time the VPC endpoint was created.
184 */
185 creationTimestamp?: string;
186
187 /**
188 * The ID of the AWS account that owns the VPC endpoint.
189 */
190 ownerId?: string;
191
192 /**
193 * The IP address type for the endpoint.
194 */
195 ipAddressType?: EC2.IpAddressType;
196
197 /**
198 * The DNS options for the endpoint.
199 */
200 dnsOptions?: {
201 dnsRecordIpType?: EC2.DnsRecordIpType;
202 privateDnsOnlyForInboundResolverEndpoint?: boolean;
203 };
204
205 /**
206 * The last error that occurred for VPC endpoint.
207 */
208 lastError?: {
209 code?: string;
210 message?: string;
211 };
212 },
213 never,
214 Providers
215> {}
216/**
217 * A VPC endpoint that connects your VPC privately to an AWS service (or a
218 * service behind a Gateway Load Balancer) without traversing the public
219 * internet, a NAT gateway, or an internet gateway.
220 *
221 * The `vpcEndpointType` selects how the connection is realized:
222 * - `"Gateway"` — for S3 and DynamoDB; traffic is directed by adding routes to
223 * the route tables in `routeTableIds` (no hourly cost).
224 * - `"Interface"` — for most other AWS services; provisions elastic network
225 * interfaces in `subnetIds`, guarded by `securityGroupIds`, and optionally
226 * resolves the service's public DNS name privately via `privateDnsEnabled`.
227 * - `"GatewayLoadBalancer"` — routes traffic through a third-party appliance
228 * fleet fronted by a Gateway Load Balancer.
229 *
230 * Changing `vpcId`, `serviceName`, or `vpcEndpointType` replaces the endpoint;
231 * route tables, subnets, security groups, DNS, and the policy update in place.
232 *
233 * ### Gateway Endpoints
234 * Gateway endpoints target S3 and DynamoDB and work by injecting a prefix-list
235 * route into each route table you list, so requests to the service stay on the
236 * AWS network.
237 * **Example:** S3 Gateway Endpoint
238 * ```typescript
239 * const s3Endpoint = yield* AWS.EC2.VpcEndpoint("S3Endpoint", {
240 * vpcId: vpc.vpcId,
241 * serviceName: "com.amazonaws.us-east-1.s3",
242 * vpcEndpointType: "Gateway",
243 * routeTableIds: [privateRouteTable.routeTableId],
244 * tags: { Name: "s3-endpoint" },
245 * });
246 * ```
247 * Listing the private subnets' route tables in `routeTableIds` lets those
248 * subnets reach S3 directly, removing NAT data-processing charges for S3 traffic
249 * and keeping it off the public internet.
250 *
251 * ### Interface Endpoints
252 * Interface endpoints place an ENI in each chosen subnet and are reached over
253 * private IPs; enabling private DNS lets existing SDK calls resolve to the
254 * endpoint transparently.
255 * **Example:** Secrets Manager Interface Endpoint
256 * ```typescript
257 * const secretsEndpoint = yield* AWS.EC2.VpcEndpoint("SecretsEndpoint", {
258 * vpcId: vpc.vpcId,
259 * serviceName: "com.amazonaws.us-east-1.secretsmanager",
260 * vpcEndpointType: "Interface",
261 * subnetIds: [privateSubnet.subnetId],
262 * securityGroupIds: [endpointSecurityGroup.groupId],
263 * privateDnsEnabled: true,
264 * ipAddressType: "ipv4",
265 * dnsOptions: {
266 * dnsRecordIpType: "ipv4",
267 * },
268 * });
269 * ```
270 * The endpoint gets an interface in each `subnetIds` entry, `securityGroupIds`
271 * controls who may reach those interfaces, and `privateDnsEnabled: true` makes
272 * the service's default DNS name resolve to the endpoint; `ipAddressType` and
273 * `dnsOptions` tune the IP family used for the interfaces and their DNS records.
274 *
275 * ### Restricting Access with a Policy
276 * **Example:** Endpoint Policy Limiting Access to One Bucket
277 * ```typescript
278 * const s3Endpoint = yield* AWS.EC2.VpcEndpoint("RestrictedS3Endpoint", {
279 * vpcId: vpc.vpcId,
280 * serviceName: "com.amazonaws.us-east-1.s3",
281 * vpcEndpointType: "Gateway",
282 * routeTableIds: [privateRouteTable.routeTableId],
283 * policyDocument: JSON.stringify({
284 * Version: "2012-10-17",
285 * Statement: [
286 * {
287 * Effect: "Allow",
288 * Principal: "*",
289 * Action: ["s3:GetObject"],
290 * Resource: ["arn:aws:s3:::my-bucket/*"],
291 * },
292 * ],
293 * }),
294 * });
295 * ```
296 * `policyDocument` attaches an endpoint policy (JSON) that constrains which
297 * service actions and resources can be reached through the endpoint; omit it to
298 * allow full access to the service.
299 *
300 * @resource
301 */
302export const VpcEndpoint = Resource<VpcEndpoint>("AWS.EC2.VpcEndpoint");
303
304export const VpcEndpointProvider = () =>
305 Provider.effect(
306 VpcEndpoint,
307 Effect.gen(function* () {
308 const createTags = Effect.fn(function* (
309 id: string,
310 tags?: Record<string, string>,
311 ) {
312 return {
313 Name: id,
314 ...(yield* createInternalTags(id)),
315 ...tags,
316 };
317 });
318
319 const describeVpcEndpoint = (vpcEndpointId: string) =>
320 ec2.describeVpcEndpoints({ VpcEndpointIds: [vpcEndpointId] }).pipe(
321 // describe-after-write eventual consistency: an endpoint we just
322 // created (or modified) may not be visible to describe yet. The id
323 // is known-valid at every call site, so a bounded retry converges.
324 Effect.retry({
325 while: (e) => e._tag === "InvalidVpcEndpointId.NotFound",
326 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(10)]),
327 }),
328 Effect.map((r) => r.VpcEndpoints?.[0]),
329 Effect.flatMap((ep) =>
330 ep
331 ? Effect.succeed(ep)
332 : Effect.fail(
333 new Error(`VPC Endpoint ${vpcEndpointId} not found`),
334 ),
335 ),
336 );
337
338 const toAttrs = Effect.fn(function* (ep: ec2.VpcEndpoint) {
339 const { accountId, region } = yield* AWSEnvironment.current;
340 return {
341 vpcEndpointId: ep.VpcEndpointId as VpcEndpointId,
342 vpcEndpointArn:
343 `arn:aws:ec2:${region}:${accountId}:vpc-endpoint/${ep.VpcEndpointId}` as VpcEndpointArn,
344 vpcEndpointType: ep.VpcEndpointType!,
345 vpcId: ep.VpcId as VpcId,
346 serviceName: ep.ServiceName!,
347 state: ep.State!,
348 policyDocument: ep.PolicyDocument,
349 routeTableIds: ep.RouteTableIds,
350 subnetIds: ep.SubnetIds,
351 groups: ep.Groups?.map((g) => ({
352 groupId: g.GroupId!,
353 groupName: g.GroupName!,
354 })),
355 privateDnsEnabled: ep.PrivateDnsEnabled,
356 requesterManaged: ep.RequesterManaged,
357 networkInterfaceIds: ep.NetworkInterfaceIds,
358 dnsEntries: ep.DnsEntries?.map((d) => ({
359 dnsName: d.DnsName,
360 hostedZoneId: d.HostedZoneId,
361 })),
362 creationTimestamp:
363 ep.CreationTimestamp instanceof Date
364 ? ep.CreationTimestamp.toISOString()
365 : (ep.CreationTimestamp as string | undefined),
366 ownerId: ep.OwnerId,
367 ipAddressType: ep.IpAddressType,
368 dnsOptions: ep.DnsOptions
369 ? {
370 dnsRecordIpType: ep.DnsOptions.DnsRecordIpType,
371 privateDnsOnlyForInboundResolverEndpoint:
372 ep.DnsOptions.PrivateDnsOnlyForInboundResolverEndpoint,
373 }
374 : undefined,
375 lastError: ep.LastError
376 ? {
377 code: ep.LastError.Code,
378 message: ep.LastError.Message,
379 }
380 : undefined,
381 } satisfies VpcEndpoint["Attributes"];
382 });
383
384 return {
385 stables: ["vpcEndpointId", "vpcEndpointArn", "ownerId"],
386
387 read: Effect.fn(function* ({ output }) {
388 if (!output) return undefined;
389 // No consistency retry here: an endpoint deleted out-of-band should
390 // read as missing immediately, not after a retry window.
391 const lookup = yield* ec2
392 .describeVpcEndpoints({ VpcEndpointIds: [output.vpcEndpointId] })
393 .pipe(
394 Effect.catchTag("InvalidVpcEndpointId.NotFound", () =>
395 Effect.succeed({ VpcEndpoints: [] }),
396 ),
397 );
398 const ep = lookup.VpcEndpoints?.[0];
399 if (!ep || ep.State === "deleted" || ep.State === "deleting") {
400 return undefined;
401 }
402 return yield* toAttrs(ep);
403 }),
404
405 list: () =>
406 Effect.gen(function* () {
407 const endpoints = yield* ec2.describeVpcEndpoints.pages({}).pipe(
408 Stream.runCollect,
409 Effect.map((chunk) =>
410 Array.from(chunk).flatMap((page) =>
411 (page.VpcEndpoints ?? []).filter(
412 (ep): ep is ec2.VpcEndpoint & { VpcEndpointId: string } =>
413 ep.VpcEndpointId != null,
414 ),
415 ),
416 ),
417 );
418 return yield* Effect.forEach(endpoints, (ep) => toAttrs(ep));
419 }),
420
421 diff: Effect.fn(function* ({ news, olds }) {
422 if (!isResolved(news)) return;
423 // Core properties require replacement
424 if (
425 news.vpcId !== olds.vpcId ||
426 news.serviceName !== olds.serviceName ||
427 news.vpcEndpointType !== olds.vpcEndpointType
428 ) {
429 return { action: "replace" };
430 }
431 // Other properties can be updated in-place
432 }),
433
434 reconcile: Effect.fn(function* ({ id, news, output, session }) {
435 const desiredTags = yield* createTags(id, news.tags);
436
437 // Observe — find the VPC endpoint via cached id, else fall through
438 // to create.
439 let ep: ec2.VpcEndpoint | undefined;
440 if (output?.vpcEndpointId) {
441 const lookup = yield* ec2
442 .describeVpcEndpoints({
443 VpcEndpointIds: [output.vpcEndpointId],
444 })
445 .pipe(
446 Effect.catchTag("InvalidVpcEndpointId.NotFound", () =>
447 Effect.succeed({ VpcEndpoints: [] }),
448 ),
449 );
450 ep = lookup.VpcEndpoints?.[0];
451 if (ep && (ep.State === "deleted" || ep.State === "deleting")) {
452 ep = undefined;
453 }
454 }
455
456 // Ensure — create the endpoint when missing.
457 if (ep === undefined) {
458 yield* session.note(
459 `Creating VPC Endpoint for ${news.serviceName}...`,
460 );
461 const result = yield* ec2.createVpcEndpoint({
462 VpcId: news.vpcId as string,
463 ServiceName: news.serviceName,
464 VpcEndpointType: news.vpcEndpointType ?? "Gateway",
465 RouteTableIds: news.routeTableIds as string[] | undefined,
466 SubnetIds: news.subnetIds as string[] | undefined,
467 SecurityGroupIds: news.securityGroupIds as string[] | undefined,
468 PrivateDnsEnabled: news.privateDnsEnabled,
469 PolicyDocument: news.policyDocument,
470 IpAddressType: news.ipAddressType,
471 DnsOptions: news.dnsOptions
472 ? {
473 DnsRecordIpType: news.dnsOptions.dnsRecordIpType,
474 PrivateDnsOnlyForInboundResolverEndpoint:
475 news.dnsOptions.privateDnsOnlyForInboundResolverEndpoint,
476 }
477 : undefined,
478 TagSpecifications: [
479 {
480 ResourceType: "vpc-endpoint",
481 Tags: createTagsList(desiredTags),
482 },
483 ],
484 DryRun: false,
485 });
486 const newEpId = result.VpcEndpoint!.VpcEndpointId!;
487 yield* session.note(`VPC Endpoint created: ${newEpId}`);
488 // Wait for every endpoint type: the wait loop also absorbs the
489 // describe-after-create eventual-consistency window during which
490 // the fresh id is not yet visible (Gateway endpoints go
491 // `available` almost immediately, so this costs nothing there).
492 ep = yield* waitForVpcEndpointAvailable(newEpId, session);
493 }
494
495 const vpcEndpointId = ep.VpcEndpointId!;
496 const observedType = ep.VpcEndpointType ?? news.vpcEndpointType;
497
498 // Sync mutable configuration — diff observed cloud state against
499 // desired, then issue a single modifyVpcEndpoint call carrying only
500 // the deltas.
501 const modifications: Parameters<typeof ec2.modifyVpcEndpoint>[0] = {
502 VpcEndpointId: vpcEndpointId,
503 DryRun: false,
504 };
505 let hasModifications = false;
506
507 if (observedType === "Gateway") {
508 const observedRtIds = new Set(ep.RouteTableIds ?? []);
509 const desiredRtIds = new Set(
510 (news.routeTableIds as string[] | undefined) ?? [],
511 );
512 const addRouteTableIds = [...desiredRtIds].filter(
513 (rt) => !observedRtIds.has(rt),
514 );
515 const removeRouteTableIds = [...observedRtIds].filter(
516 (rt) => !desiredRtIds.has(rt),
517 );
518 if (addRouteTableIds.length > 0) {
519 modifications.AddRouteTableIds = addRouteTableIds;
520 hasModifications = true;
521 }
522 if (removeRouteTableIds.length > 0) {
523 modifications.RemoveRouteTableIds = removeRouteTableIds;
524 hasModifications = true;
525 }
526 }
527
528 if (
529 observedType === "Interface" ||
530 observedType === "GatewayLoadBalancer"
531 ) {
532 const observedSubnetIds = new Set(ep.SubnetIds ?? []);
533 const desiredSubnetIds = new Set(
534 (news.subnetIds as string[] | undefined) ?? [],
535 );
536 const addSubnetIds = [...desiredSubnetIds].filter(
537 (s) => !observedSubnetIds.has(s),
538 );
539 const removeSubnetIds = [...observedSubnetIds].filter(
540 (s) => !desiredSubnetIds.has(s),
541 );
542 if (addSubnetIds.length > 0) {
543 modifications.AddSubnetIds = addSubnetIds;
544 hasModifications = true;
545 }
546 if (removeSubnetIds.length > 0) {
547 modifications.RemoveSubnetIds = removeSubnetIds;
548 hasModifications = true;
549 }
550
551 const observedSgIds = new Set(
552 (ep.Groups ?? [])
553 .map((g) => g.GroupId)
554 .filter((g): g is string => Boolean(g)),
555 );
556 const desiredSgIds = new Set(
557 (news.securityGroupIds as string[] | undefined) ?? [],
558 );
559 const addSecurityGroupIds = [...desiredSgIds].filter(
560 (g) => !observedSgIds.has(g),
561 );
562 const removeSecurityGroupIds = [...observedSgIds].filter(
563 (g) => !desiredSgIds.has(g),
564 );
565 if (addSecurityGroupIds.length > 0) {
566 modifications.AddSecurityGroupIds = addSecurityGroupIds;
567 hasModifications = true;
568 }
569 if (removeSecurityGroupIds.length > 0) {
570 modifications.RemoveSecurityGroupIds = removeSecurityGroupIds;
571 hasModifications = true;
572 }
573
574 if (
575 news.privateDnsEnabled !== undefined &&
576 ep.PrivateDnsEnabled !== news.privateDnsEnabled
577 ) {
578 modifications.PrivateDnsEnabled = news.privateDnsEnabled;
579 hasModifications = true;
580 }
581 }
582
583 const observedPolicy = ep.PolicyDocument ?? undefined;
584 if (news.policyDocument !== undefined) {
585 if (!policyDocumentEquals(observedPolicy, news.policyDocument)) {
586 // AWS rejects passing both a policy document and the reset flag
587 // in the same call — choose exactly one.
588 modifications.PolicyDocument = news.policyDocument;
589 hasModifications = true;
590 }
591 } else if (!isFullAccessEndpointPolicy(observedPolicy)) {
592 // An absent `policyDocument` means "the default full-access
593 // policy". AWS attaches exactly that default to every fresh
594 // endpoint, so only reset when a custom policy is actually
595 // present — issuing a ResetPolicy no-op immediately after create
596 // races the new endpoint id's propagation and previously failed
597 // the whole reconcile with InvalidVpcEndpointId.NotFound.
598 modifications.ResetPolicy = true;
599 hasModifications = true;
600 }
601
602 // An unspecified `ipAddressType`/`dnsOptions` means "AWS default" —
603 // the observed value on a fresh endpoint (e.g. "not-specified", or
604 // populated DNS defaults) must not be diffed against `undefined`,
605 // which previously caused a spurious modify on every reconcile.
606 if (
607 news.ipAddressType !== undefined &&
608 ep.IpAddressType !== news.ipAddressType
609 ) {
610 modifications.IpAddressType = news.ipAddressType;
611 hasModifications = true;
612 }
613
614 if (news.dnsOptions !== undefined) {
615 const observedDnsRecordIpType = ep.DnsOptions?.DnsRecordIpType;
616 const observedPrivateDnsOnly =
617 ep.DnsOptions?.PrivateDnsOnlyForInboundResolverEndpoint;
618 if (
619 (news.dnsOptions.dnsRecordIpType !== undefined &&
620 observedDnsRecordIpType !== news.dnsOptions.dnsRecordIpType) ||
621 (news.dnsOptions.privateDnsOnlyForInboundResolverEndpoint !==
622 undefined &&
623 observedPrivateDnsOnly !==
624 news.dnsOptions.privateDnsOnlyForInboundResolverEndpoint)
625 ) {
626 modifications.DnsOptions = {
627 DnsRecordIpType: news.dnsOptions.dnsRecordIpType,
628 PrivateDnsOnlyForInboundResolverEndpoint:
629 news.dnsOptions.privateDnsOnlyForInboundResolverEndpoint,
630 };
631 hasModifications = true;
632 }
633 }
634
635 if (hasModifications) {
636 yield* ec2.modifyVpcEndpoint(modifications).pipe(
637 // The endpoint id is known-valid here (we just observed or
638 // created it) — NotFound is describe/modify-plane propagation
639 // lag, not a missing endpoint.
640 Effect.retry({
641 while: (e) => e._tag === "InvalidVpcEndpointId.NotFound",
642 schedule: Schedule.max([
643 Schedule.fixed(2000),
644 Schedule.recurs(10),
645 ]),
646 }),
647 );
648 yield* session.note("Updated VPC Endpoint configuration");
649 if (
650 observedType === "Interface" ||
651 observedType === "GatewayLoadBalancer"
652 ) {
653 yield* waitForVpcEndpointAvailable(vpcEndpointId, session);
654 }
655 }
656
657 // Sync tags — observed cloud tags vs desired.
658 const currentTags = Object.fromEntries(
659 (ep.Tags ?? []).map((t) => [t.Key!, t.Value!]),
660 ) as Record<string, string>;
661 const { removed, upsert } = diffTags(currentTags, desiredTags);
662 if (removed.length > 0) {
663 yield* ec2.deleteTags({
664 Resources: [vpcEndpointId],
665 Tags: removed.map((key) => ({ Key: key })),
666 DryRun: false,
667 });
668 }
669 if (upsert.length > 0) {
670 yield* ec2.createTags({
671 Resources: [vpcEndpointId],
672 Tags: upsert,
673 DryRun: false,
674 });
675 }
676
677 // Re-read final state.
678 const final = yield* describeVpcEndpoint(vpcEndpointId);
679 return yield* toAttrs(final);
680 }),
681
682 delete: Effect.fn(function* ({ output, session }) {
683 const vpcEndpointId = output.vpcEndpointId;
684
685 yield* session.note(`Deleting VPC Endpoint: ${vpcEndpointId}`);
686
687 yield* ec2
688 .deleteVpcEndpoints({
689 VpcEndpointIds: [vpcEndpointId],
690 DryRun: false,
691 })
692 .pipe(
693 Effect.catchTag(
694 "InvalidVpcEndpointId.NotFound",
695 () => Effect.void,
696 ),
697 );
698
699 // Wait for deletion
700 yield* waitForVpcEndpointDeleted(vpcEndpointId, session);
701
702 // Interface/GatewayLoadBalancer endpoints release their network
703 // interfaces asynchronously after the endpoint itself reports
704 // deleted. The parent VPC (and subnets/security groups) cannot be
705 // deleted while those ENIs linger, so wait until they are gone.
706 const eniIds = output.networkInterfaceIds ?? [];
707 if (eniIds.length > 0) {
708 yield* waitForEndpointEnisReleased(eniIds, session);
709 }
710
711 yield* session.note(`VPC Endpoint ${vpcEndpointId} deleted`);
712 }),
713 };
714 }),
715 );
716
717// Retryable error: VPC Endpoint is still pending
718class VpcEndpointPending extends Data.TaggedError("VpcEndpointPending")<{
719 vpcEndpointId: string;
720 state: string;
721}> {}
722
723// Terminal error: VPC Endpoint creation failed
724class VpcEndpointFailed extends Data.TaggedError("VpcEndpointFailed")<{
725 vpcEndpointId: string;
726 errorCode?: string;
727 errorMessage?: string;
728}> {}
729
730// Retryable error: VPC Endpoint is still deleting
731class VpcEndpointDeleting extends Data.TaggedError("VpcEndpointDeleting")<{
732 vpcEndpointId: string;
733 state: string;
734}> {}
735
736// Retryable error: endpoint network interfaces are still being released
737class VpcEndpointEnisLingering extends Data.TaggedError(
738 "VpcEndpointEnisLingering",
739)<{
740 networkInterfaceIds: string[];
741}> {}
742
743/**
744 * Wait for VPC Endpoint to be in available state.
745 *
746 * Only ever called with an id we just created or observed, so a NotFound (or
747 * an empty describe result) is describe-after-create eventual consistency and
748 * is retried as "pending" rather than treated as terminal.
749 */
750const waitForVpcEndpointAvailable = (
751 vpcEndpointId: string,
752 session: ScopedPlanStatusSession,
753) =>
754 Effect.gen(function* () {
755 const result = yield* ec2
756 .describeVpcEndpoints({ VpcEndpointIds: [vpcEndpointId] })
757 .pipe(
758 Effect.catchTag("InvalidVpcEndpointId.NotFound", () =>
759 Effect.succeed({ VpcEndpoints: [] }),
760 ),
761 );
762 const ep = result.VpcEndpoints?.[0];
763
764 if (!ep) {
765 // Fresh id not visible to describe yet — retry.
766 return yield* new VpcEndpointPending({
767 vpcEndpointId,
768 state: "propagating",
769 });
770 }
771
772 if (ep.State === "available") {
773 return ep;
774 }
775
776 if (ep.State === "failed" || ep.State === "rejected") {
777 return yield* new VpcEndpointFailed({
778 vpcEndpointId,
779 errorCode: ep.LastError?.Code,
780 errorMessage: ep.LastError?.Message,
781 });
782 }
783
784 // Still pending - this is the only retryable case
785 return yield* new VpcEndpointPending({ vpcEndpointId, state: ep.State! });
786 }).pipe(
787 Effect.retry({
788 while: (e) => e._tag === "VpcEndpointPending",
789 schedule: Schedule.max([Schedule.fixed(3000), Schedule.recurs(60)]).pipe(
790 Schedule.tap(({ attempt }) =>
791 session.note(
792 `Waiting for VPC Endpoint to be available... (${attempt * 3}s)`,
793 ),
794 ),
795 ),
796 }),
797 );
798
799/**
800 * Wait for VPC Endpoint to be deleted
801 */
802const waitForVpcEndpointDeleted = (
803 vpcEndpointId: string,
804 session: ScopedPlanStatusSession,
805) =>
806 Effect.gen(function* () {
807 const result = yield* ec2
808 .describeVpcEndpoints({ VpcEndpointIds: [vpcEndpointId] })
809 .pipe(
810 Effect.catchTag("InvalidVpcEndpointId.NotFound", () =>
811 Effect.succeed({ VpcEndpoints: [] }),
812 ),
813 );
814
815 const ep = result.VpcEndpoints?.[0];
816
817 if (!ep || ep.State === "deleted") {
818 return; // Successfully deleted
819 }
820
821 // Still deleting - this is the only retryable case
822 return yield* new VpcEndpointDeleting({ vpcEndpointId, state: ep.State! });
823 }).pipe(
824 Effect.retry({
825 while: (e) => e._tag === "VpcEndpointDeleting",
826 schedule: Schedule.max([Schedule.fixed(3000), Schedule.recurs(60)]).pipe(
827 Schedule.tap(({ attempt }) =>
828 session.note(
829 `Waiting for VPC Endpoint deletion... (${attempt * 3}s)`,
830 ),
831 ),
832 ),
833 }),
834 );
835
836/**
837 * Wait for the endpoint's network interfaces to be released after deletion.
838 *
839 * Uses a filter (not `NetworkInterfaceIds`) so already-released interfaces
840 * simply drop out of the result instead of failing the whole describe with
841 * `InvalidNetworkInterfaceID.NotFound`.
842 */
843const waitForEndpointEnisReleased = (
844 networkInterfaceIds: string[],
845 session: ScopedPlanStatusSession,
846) =>
847 Effect.gen(function* () {
848 const remaining = yield* ec2.describeNetworkInterfaces
849 .items({
850 Filters: [
851 { Name: "network-interface-id", Values: networkInterfaceIds },
852 ],
853 })
854 .pipe(
855 Stream.map((eni) => eni.NetworkInterfaceId),
856 Stream.filter((id): id is string => Boolean(id)),
857 Stream.runCollect,
858 Effect.map((chunk) => Array.from(chunk)),
859 );
860 if (remaining.length > 0) {
861 return yield* new VpcEndpointEnisLingering({
862 networkInterfaceIds: remaining,
863 });
864 }
865 }).pipe(
866 Effect.retry({
867 while: (e) => e._tag === "VpcEndpointEnisLingering",
868 schedule: Schedule.max([Schedule.fixed(3000), Schedule.recurs(20)]).pipe(
869 Schedule.tap(({ attempt }) =>
870 session.note(
871 `Waiting for endpoint network interfaces to release... (${attempt * 3}s)`,
872 ),
873 ),
874 ),
875 }),
876 );
877
878/**
879 * Structural check for the default full-access endpoint policy that AWS
880 * attaches to every freshly created VPC endpoint. An absent `policyDocument`
881 * prop is equivalent to this default, so observing it must not trigger a
882 * `ResetPolicy` modification.
883 */
884const isFullAccessEndpointPolicy = (doc: string | undefined): boolean => {
885 if (!doc) return true;
886 try {
887 const parsed = JSON.parse(doc) as {
888 Statement?: Array<{
889 Effect?: unknown;
890 Principal?: unknown;
891 Action?: unknown;
892 Resource?: unknown;
893 Condition?: unknown;
894 }>;
895 };
896 const statements = parsed.Statement ?? [];
897 if (statements.length !== 1) return false;
898 const s = statements[0];
899 const isStar = (value: unknown) =>
900 value === "*" ||
901 (Array.isArray(value) && value.length === 1 && value[0] === "*") ||
902 (typeof value === "object" &&
903 value !== null &&
904 (value as { AWS?: unknown }).AWS === "*");
905 return (
906 s.Effect === "Allow" &&
907 isStar(s.Principal) &&
908 isStar(s.Action) &&
909 isStar(s.Resource) &&
910 s.Condition === undefined
911 );
912 } catch {
913 return false;
914 }
915};
916
917/**
918 * Compare two endpoint policy documents structurally (AWS normalizes the JSON
919 * it stores, so raw string comparison would report a perpetual diff).
920 */
921const policyDocumentEquals = (
922 a: string | undefined,
923 b: string | undefined,
924): boolean => {
925 if (a === b) return true;
926 if (a === undefined || b === undefined) return false;
927 try {
928 return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
929 } catch {
930 return a === b;
931 }
932};