1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import type { ClientVpnEndpointId } from "./ClientVpnEndpoint.ts";
10import { retryClientVpn } from "./ClientVpnWait.ts";
11import type { SubnetId } from "./Subnet.ts";
12import type { VpcId } from "./Vpc.ts";
13
14/** The identifier of a Client VPN target network association. */
15export type ClientVpnTargetNetworkAssociationId = `cvpn-assoc-${string}`;
16
17/** Immutable settings for a VPC-based Client VPN target network association. */
18export interface ClientVpnTargetNetworkAssociationProps {
19 /** The Client VPN endpoint to associate. Changing this replaces the association. */
20 clientVpnEndpointId: ClientVpnEndpointId;
21 /**
22 * The target subnet. All targets must belong to the endpoint's VPC, with at
23 * most one subnet per Availability Zone. Changing this replaces the association.
24 */
25 subnetId: SubnetId;
26}
27
28/** A subnet associated with a Client VPN endpoint. */
29export interface ClientVpnTargetNetworkAssociation extends Resource<
30 "AWS.EC2.ClientVpnTargetNetworkAssociation",
31 ClientVpnTargetNetworkAssociationProps,
32 {
33 /** The AWS-generated association identifier. */
34 associationId: ClientVpnTargetNetworkAssociationId;
35 /** The associated Client VPN endpoint. */
36 clientVpnEndpointId: ClientVpnEndpointId;
37 /** The associated subnet; reference this output from ClientVpnRoute for ordering. */
38 subnetId: SubnetId;
39 /** The VPC containing the target subnet. */
40 vpcId: VpcId;
41 /** The observed association state. */
42 status: ec2.AssociationStatusCode;
43 /** Additional information about the association state. */
44 statusMessage: string | undefined;
45 },
46 never,
47 Providers
48> {}
49
50/**
51 * Associates a VPC subnet with a Client VPN endpoint and waits for it to become
52 * associated. AWS automatically adds the VPC's local route; do not manage that
53 * automatic route with ClientVpnRoute. Security groups are endpoint-wide settings.
54 * Association and disassociation can take several minutes. The provider waits
55 * for AWS to finish, fails on association errors, and supports cancellation.
56 *
57 * All property changes replace the association. Replacement within the same
58 * endpoint deletes first because AWS permits only one subnet per Availability
59 * Zone. Removing the last association disconnects clients. This resource manages
60 * the declared endpoint/subnet pair, including an existing association discovered
61 * without cached state. Associations have no independent ownership markers.
62 *
63 * Readiness waits default to 30 minutes. Set `AWS_CLIENT_VPN_TIMEOUT` to a
64 * positive finite duration, such as `45 minutes`, to override this deadline.
65 *
66 * ### Associating a Target Network
67 * **Example:** Associate a subnet and order an internet route after it
68 * ```typescript
69 * const target = yield* AWS.EC2.ClientVpnTargetNetworkAssociation("VpnTarget", {
70 * clientVpnEndpointId: endpoint.clientVpnEndpointId,
71 * subnetId: privateSubnet.subnetId,
72 * });
73 * const route = yield* AWS.EC2.ClientVpnRoute("VpnInternet", {
74 * clientVpnEndpointId: target.clientVpnEndpointId,
75 * targetVpcSubnetId: target.subnetId,
76 * destinationCidrBlock: "0.0.0.0/0",
77 * });
78 * ```
79 *
80 * @resource
81 */
82export const ClientVpnTargetNetworkAssociation =
83 Resource<ClientVpnTargetNetworkAssociation>(
84 "AWS.EC2.ClientVpnTargetNetworkAssociation",
85 );
86
87class ClientVpnAssociationPending extends Data.TaggedError(
88 "ClientVpnAssociationPending",
89)<{ message: string }> {}
90
91class ClientVpnAssociationFailed extends Data.TaggedError(
92 "ClientVpnAssociationFailed",
93)<{ message: string }> {}
94
95const networks = (clientVpnEndpointId: ClientVpnEndpointId) =>
96 ec2.describeClientVpnTargetNetworks
97 .items({ ClientVpnEndpointId: clientVpnEndpointId })
98 .pipe(
99 Stream.runCollect,
100 Effect.catchTag("InvalidClientVpnEndpointId.NotFound", () =>
101 Effect.succeed([] as ec2.TargetNetwork[]),
102 ),
103 );
104
105const findNetwork = (
106 props: ClientVpnTargetNetworkAssociationProps,
107 associationId?: string,
108) =>
109 networks(props.clientVpnEndpointId).pipe(
110 Effect.map((items) =>
111 items.find(
112 (item) =>
113 item.Status?.Code !== "disassociated" &&
114 (associationId
115 ? item.AssociationId === associationId
116 : item.TargetNetworkId === props.subnetId),
117 ),
118 ),
119 );
120
121const toAttributes = (
122 clientVpnEndpointId: ClientVpnEndpointId,
123 network: ec2.TargetNetwork,
124): ClientVpnTargetNetworkAssociation["Attributes"] => ({
125 associationId: network.AssociationId as ClientVpnTargetNetworkAssociationId,
126 clientVpnEndpointId,
127 subnetId: network.TargetNetworkId as SubnetId,
128 vpcId: network.VpcId as VpcId,
129 status: network.Status?.Code ?? "associating",
130 statusMessage: network.Status?.Message,
131});
132
133const waitForNetwork = (
134 props: ClientVpnTargetNetworkAssociationProps,
135 associationId: string | undefined,
136 deleted: boolean,
137) =>
138 Effect.gen(function* () {
139 const network = yield* findNetwork(props, associationId);
140 if (deleted && !network) return undefined;
141 if (!deleted && network?.Status?.Code === "associated") return network;
142 if (!deleted && network?.Status?.Code === "association-failed") {
143 return yield* new ClientVpnAssociationFailed({
144 message:
145 network.Status.Message ?? "Client VPN target association failed",
146 });
147 }
148 return yield* new ClientVpnAssociationPending({
149 message: `Client VPN association ${associationId ?? props.subnetId} is ${network?.Status?.Code ?? "not visible"}; waiting for ${deleted ? "disassociation" : "associated"}`,
150 });
151 }).pipe((effect) =>
152 retryClientVpn(
153 effect,
154 (error) => error._tag === "ClientVpnAssociationPending",
155 ),
156 );
157
158/** Live AWS provider for ClientVpnTargetNetworkAssociation. */
159export const ClientVpnTargetNetworkAssociationProvider = () =>
160 Provider.effect(
161 ClientVpnTargetNetworkAssociation,
162 Effect.gen(function* () {
163 return {
164 stables: ["associationId", "clientVpnEndpointId", "subnetId", "vpcId"],
165 nuke: {
166 dependsOn: [
167 "AWS.EC2.ClientVpnEndpoint",
168 "AWS.EC2.Subnet",
169 "AWS.EC2.SecurityGroup",
170 ],
171 },
172 list: Effect.fn(function* () {
173 const endpoints = yield* ec2.describeClientVpnEndpoints
174 .items({})
175 .pipe(Stream.runCollect);
176 const items = yield* Effect.forEach(endpoints, (endpoint) =>
177 networks(endpoint.ClientVpnEndpointId as ClientVpnEndpointId).pipe(
178 Effect.map((items) =>
179 items
180 .filter(
181 (item) =>
182 item.AssociationId &&
183 item.TargetNetworkId?.startsWith("subnet-") &&
184 item.Status?.Code !== "disassociated",
185 )
186 .map((item) =>
187 toAttributes(
188 endpoint.ClientVpnEndpointId as ClientVpnEndpointId,
189 item,
190 ),
191 ),
192 ),
193 ),
194 );
195 return items.flat();
196 }),
197 read: Effect.fn(function* ({ olds, output }) {
198 const props = output ?? olds;
199 const network = yield* findNetwork(props, output?.associationId);
200 if (!network) return undefined;
201 return toAttributes(props.clientVpnEndpointId, network);
202 }),
203 diff: Effect.fn(function* ({ olds, news, output }) {
204 if (!isResolved(news)) {
205 return { action: "replace", deleteFirst: true };
206 }
207 if (
208 news.clientVpnEndpointId !== olds.clientVpnEndpointId ||
209 news.subnetId !== olds.subnetId
210 ) {
211 return {
212 action: "replace",
213 deleteFirst:
214 news.clientVpnEndpointId === olds.clientVpnEndpointId,
215 };
216 }
217 const network = yield* findNetwork(news, output?.associationId);
218 if (network?.Status?.Code === "association-failed") {
219 return { action: "replace", deleteFirst: true };
220 }
221 if (!network || network.Status?.Code !== "associated") {
222 return {
223 action: "update",
224 stables: ["clientVpnEndpointId", "subnetId", "vpcId"],
225 };
226 }
227 }),
228 reconcile: Effect.fn(function* ({ news }) {
229 let network = yield* findNetwork(news);
230 if (network?.Status?.Code === "disassociating") {
231 yield* waitForNetwork(news, network.AssociationId, true);
232 network = undefined;
233 }
234 let associationId = network?.AssociationId;
235 if (!network) {
236 // Do not replay a completed create token after out-of-band deletion.
237 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
238 const created = yield* ec2
239 .associateClientVpnTargetNetwork({
240 ClientVpnEndpointId: news.clientVpnEndpointId,
241 SubnetId: news.subnetId,
242 ClientToken: clientToken,
243 })
244 .pipe(
245 Effect.catchTag(
246 [
247 "ClientVpnEndpointAssociationExists",
248 "InvalidClientVpnDuplicateAssociationException",
249 ],
250 () => Effect.succeed(undefined),
251 ),
252 (effect) =>
253 retryClientVpn(
254 effect,
255 (error) => error._tag === "IncorrectState",
256 ),
257 );
258 associationId = created?.AssociationId;
259 }
260 const associated = yield* waitForNetwork(news, associationId, false);
261 if (!associated?.AssociationId) {
262 return yield* new ClientVpnAssociationFailed({
263 message:
264 "AWS did not return a Client VPN target association identifier.",
265 });
266 }
267 return toAttributes(news.clientVpnEndpointId, associated);
268 }),
269 delete: Effect.fn(function* ({ output }) {
270 const network = yield* findNetwork(output, output.associationId);
271 if (!network) return;
272 if (network.Status?.Code !== "disassociating") {
273 yield* ec2
274 .disassociateClientVpnTargetNetwork({
275 ClientVpnEndpointId: output.clientVpnEndpointId,
276 AssociationId: output.associationId,
277 })
278 .pipe(
279 Effect.catchTag(
280 [
281 "InvalidClientVpnEndpointId.NotFound",
282 "InvalidClientVpnAssociationIdNotFound",
283 ],
284 () => Effect.void,
285 ),
286 (effect) =>
287 retryClientVpn(
288 effect,
289 (error) => error._tag === "IncorrectState",
290 ),
291 );
292 }
293 yield* waitForNetwork(output, output.associationId, true);
294 }),
295 };
296 }),
297 );