1import * as aoss from "@distilled.cloud/aws/opensearchserverless";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { awaitVpcEndpointActive, retryWhileConflict } from "./internal.ts";
10
11export interface VpcEndpointProps {
12 /**
13 * Name of the VPC endpoint (3-32 characters, lowercase). Changing the name
14 * replaces the endpoint.
15 * @default a generated physical name
16 */
17 endpointName?: string;
18 /**
19 * The ID of the VPC from which the endpoint accesses OpenSearch Serverless.
20 * Changing the VPC replaces the endpoint.
21 */
22 vpcId: string;
23 /**
24 * The IDs of the subnets in which to create the interface endpoint's network
25 * interfaces.
26 */
27 subnetIds: string[];
28 /**
29 * The IDs of the security groups to attach to the endpoint's network
30 * interfaces.
31 */
32 securityGroupIds?: string[];
33}
34
35export interface VpcEndpoint extends Resource<
36 "AWS.OpenSearchServerless.VpcEndpoint",
37 VpcEndpointProps,
38 {
39 /**
40 * Unique identifier of the VPC endpoint.
41 */
42 vpcEndpointId: string;
43 /**
44 * Name of the VPC endpoint.
45 */
46 endpointName: string;
47 /**
48 * Endpoint status (e.g. `ACTIVE`, `PENDING`, `DELETING`).
49 */
50 status?: string;
51 },
52 {},
53 Providers
54> {}
55
56/**
57 * An Amazon OpenSearch Serverless-managed interface VPC endpoint. Creating one
58 * lets resources in a VPC reach a collection privately (without traversing the
59 * public internet), and lets a network {@link SecurityPolicy} restrict a
60 * collection's access to only that endpoint. Creation is asynchronous — the
61 * provider polls (bounded) until the endpoint reaches `ACTIVE`.
62 *
63 * ### Creating VPC Endpoints
64 * **Example:** Interface Endpoint in a VPC
65 * ```typescript
66 * import * as AWS from "alchemy/AWS";
67 *
68 * const endpoint = yield* AWS.OpenSearchServerless.VpcEndpoint("Endpoint", {
69 * endpointName: "my-endpoint",
70 * vpcId: vpc.vpcId,
71 * subnetIds: [subnet.subnetId],
72 * securityGroupIds: [securityGroup.groupId],
73 * });
74 * ```
75 *
76 * @resource
77 */
78export const VpcEndpoint = Resource<VpcEndpoint>(
79 "AWS.OpenSearchServerless.VpcEndpoint",
80);
81
82export const VpcEndpointProvider = () =>
83 Provider.effect(
84 VpcEndpoint,
85 Effect.gen(function* () {
86 const createName = Effect.fn(function* (
87 id: string,
88 props: { endpointName?: string | undefined },
89 ) {
90 return (
91 props.endpointName ??
92 (yield* createPhysicalName({ id, maxLength: 32, lowercase: true }))
93 );
94 });
95
96 const findByName = Effect.fn(function* (name: string) {
97 const pages = yield* aoss.listVpcEndpoints
98 .pages({})
99 .pipe(Stream.runCollect);
100 return Array.from(pages)
101 .flatMap((page) => page.vpcEndpointSummaries ?? [])
102 .find((s) => s.name === name);
103 });
104
105 const observe = Effect.fn(function* (
106 name: string,
107 vpcEndpointId: string | undefined,
108 ) {
109 if (vpcEndpointId !== undefined) {
110 const byId = yield* aoss
111 .batchGetVpcEndpoint({ ids: [vpcEndpointId] })
112 .pipe(Effect.map((r) => r.vpcEndpointDetails?.[0]));
113 if (byId?.id !== undefined && byId.status !== "DELETING") {
114 return byId;
115 }
116 }
117 const summary = yield* findByName(name);
118 if (summary?.id === undefined || summary.status === "DELETING") {
119 return undefined;
120 }
121 return yield* aoss
122 .batchGetVpcEndpoint({ ids: [summary.id] })
123 .pipe(Effect.map((r) => r.vpcEndpointDetails?.[0]));
124 });
125
126 return VpcEndpoint.Provider.of({
127 stables: ["vpcEndpointId", "endpointName"],
128
129 list: () =>
130 Effect.gen(function* () {
131 const pages = yield* aoss.listVpcEndpoints
132 .pages({})
133 .pipe(Stream.runCollect);
134 return Array.from(pages)
135 .flatMap((page) => page.vpcEndpointSummaries ?? [])
136 .filter((s) => s.id !== undefined && s.name !== undefined)
137 .map((s) => ({
138 vpcEndpointId: s.id!,
139 endpointName: s.name!,
140 status: s.status,
141 }));
142 }),
143
144 read: Effect.fn(function* ({ id, olds, output }) {
145 const name =
146 output?.endpointName ?? (yield* createName(id, olds ?? {}));
147 const detail = yield* observe(name, output?.vpcEndpointId);
148 if (detail?.id === undefined) {
149 return undefined;
150 }
151 // VPC endpoints carry no tags — an existing same-name endpoint is
152 // adopted.
153 return {
154 vpcEndpointId: detail.id,
155 endpointName: detail.name!,
156 status: detail.status,
157 };
158 }),
159
160 diff: Effect.fn(function* ({ id, news, olds }) {
161 if (!isResolved(news)) return undefined;
162 const oldName = yield* createName(id, olds);
163 const newName = yield* createName(id, news);
164 if (oldName !== newName) {
165 return { action: "replace" } as const;
166 }
167 if (olds.vpcId !== news.vpcId) {
168 return { action: "replace" } as const;
169 }
170 // subnetIds/securityGroupIds fall through to the default update path
171 }),
172
173 reconcile: Effect.fn(function* ({ id, news, output, session }) {
174 const name = output?.endpointName ?? (yield* createName(id, news));
175
176 // 1. OBSERVE
177 let detail = yield* observe(name, output?.vpcEndpointId);
178
179 // 2. ENSURE — create if missing; wait for ACTIVE (async provisioning)
180 if (detail === undefined) {
181 const created = yield* aoss
182 .createVpcEndpoint({
183 name,
184 vpcId: news.vpcId,
185 subnetIds: news.subnetIds,
186 securityGroupIds: news.securityGroupIds,
187 })
188 .pipe(
189 Effect.map((r) => r.createVpcEndpointDetail),
190 Effect.catchTag("ConflictException", () =>
191 Effect.succeed(undefined),
192 ),
193 );
194 const endpointId = created?.id ?? (yield* findByName(name))?.id;
195 if (endpointId === undefined) {
196 return yield* Effect.fail(
197 new aoss.ResourceNotFoundException({
198 message: `VPC endpoint ${name} not visible after create`,
199 }),
200 );
201 }
202 yield* session.note(`creating VPC endpoint ${name} (async)...`);
203 detail = yield* awaitVpcEndpointActive(endpointId);
204 } else if (detail.status !== "ACTIVE") {
205 detail = yield* awaitVpcEndpointActive(detail.id!);
206 }
207
208 // 3. SYNC — subnet/security-group membership deltas
209 const observedSubnets = new Set(detail.subnetIds ?? []);
210 const desiredSubnets = new Set(news.subnetIds);
211 const addSubnetIds = news.subnetIds.filter(
212 (s) => !observedSubnets.has(s),
213 );
214 const removeSubnetIds = (detail.subnetIds ?? []).filter(
215 (s) => !desiredSubnets.has(s),
216 );
217 const observedSgs = new Set(detail.securityGroupIds ?? []);
218 const desiredSgs = new Set(news.securityGroupIds ?? []);
219 const addSecurityGroupIds = (news.securityGroupIds ?? []).filter(
220 (s) => !observedSgs.has(s),
221 );
222 const removeSecurityGroupIds = (detail.securityGroupIds ?? []).filter(
223 (s) => !desiredSgs.has(s),
224 );
225 if (
226 addSubnetIds.length > 0 ||
227 removeSubnetIds.length > 0 ||
228 addSecurityGroupIds.length > 0 ||
229 removeSecurityGroupIds.length > 0
230 ) {
231 yield* aoss.updateVpcEndpoint({
232 id: detail.id!,
233 addSubnetIds: addSubnetIds.length > 0 ? addSubnetIds : undefined,
234 removeSubnetIds:
235 removeSubnetIds.length > 0 ? removeSubnetIds : undefined,
236 addSecurityGroupIds:
237 addSecurityGroupIds.length > 0
238 ? addSecurityGroupIds
239 : undefined,
240 removeSecurityGroupIds:
241 removeSecurityGroupIds.length > 0
242 ? removeSecurityGroupIds
243 : undefined,
244 });
245 }
246
247 yield* session.note(detail.id!);
248 return {
249 vpcEndpointId: detail.id!,
250 endpointName: detail.name!,
251 status: detail.status,
252 };
253 }),
254
255 delete: Effect.fn(function* ({ output }) {
256 yield* retryWhileConflict(
257 aoss.deleteVpcEndpoint({ id: output.vpcEndpointId }),
258 ).pipe(
259 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
260 );
261 }),
262 });
263 }),
264 );