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 { Unowned } from "../../AdoptPolicy.ts";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createAlchemyTagFilters,
11 createInternalTags,
12 createTagsList,
13 diffTags,
14 hasAlchemyTags,
15 tagRecord,
16} from "../../Tags.ts";
17import { AWSEnvironment } from "../Environment.ts";
18import type { Providers } from "../Providers.ts";
19import { retryClientVpn } from "./ClientVpnWait.ts";
20
21export type ClientVpnEndpointId = `cvpn-endpoint-${string}`;
22
23export interface ClientVpnAuthenticationOptions {
24 /** The authentication method. Changing authentication replaces the endpoint. */
25 type:
26 | "certificate-authentication"
27 | "directory-service-authentication"
28 | "federated-authentication";
29 /** AWS Directory Service authentication settings. */
30 activeDirectory?: {
31 /** ID of the directory used to authenticate clients. */
32 directoryId: string;
33 };
34 /** Mutual TLS authentication settings. */
35 mutualAuthentication?: {
36 /** ACM ARN containing the client root certificate chain. */
37 clientRootCertificateChainArn: string;
38 };
39 /** SAML authentication settings. */
40 federatedAuthentication?: {
41 /** ARN of the IAM SAML identity provider. */
42 samlProviderArn: string;
43 /** ARN of the IAM SAML provider for the self-service portal. */
44 selfServiceSamlProviderArn?: string;
45 };
46}
47
48export interface ClientVpnEndpointProps {
49 /** IPv4 CIDR allocated to clients, with a /12 through /22 prefix. Required for IPv4 traffic; omit for IPv6-only traffic. Must not overlap target networks. Changing it replaces the endpoint. */
50 clientCidrBlock?: string;
51 /** ARN of the ACM server certificate in the endpoint's region. */
52 serverCertificateArn: string;
53 /** One or two authentication methods. Changing these replaces the endpoint. */
54 authenticationOptions: ClientVpnAuthenticationOptions[];
55 /** Connection logging configuration. Omission disables logging. */
56 connectionLogOptions?: {
57 /** Whether connection logging is enabled. */
58 enabled: boolean;
59 /** CloudWatch Logs group receiving connection logs. Required when enabled. */
60 cloudwatchLogGroup?: string;
61 /** Optional stream receiving connection logs. */
62 cloudwatchLogStream?: string;
63 };
64 /** Endpoint description. Omission clears the description. */
65 description?: string;
66 /** Up to two custom DNS servers. Omission or an empty array disables custom DNS. */
67 dnsServers?: string[];
68 /** Transport protocol. Changing it replaces the endpoint. @default "udp" */
69 transportProtocol?: "udp" | "tcp";
70 /** VPN port, either 443 or 1194. @default 443 */
71 vpnPort?: 443 | 1194;
72 /** Route only configured destinations through the VPN. @default false */
73 splitTunnel?: boolean;
74 /** VPC for security groups. Changing or removing it replaces the endpoint. */
75 vpcId?: string;
76 /** Security groups to apply. Omission restores the VPC's default security group. Requires a VPC. */
77 securityGroupIds?: string[];
78 /** Whether clients can download their configuration through the self-service portal. @default "disabled" */
79 selfServicePortal?: "enabled" | "disabled";
80 /** Maximum session duration in hours. @default 24 */
81 sessionTimeoutHours?: 8 | 10 | 12 | 24;
82 /** Disconnect clients when their session expires. @default false */
83 disconnectOnSessionTimeout?: boolean;
84 /** Lambda connection handler. Omission disables the handler. */
85 clientConnectOptions?: {
86 /** Whether to invoke the connection handler. */
87 enabled: boolean;
88 /** ARN of the Lambda connection handler. Required when enabled. */
89 lambdaFunctionArn?: string;
90 };
91 /** Client login banner. Omission disables the banner. */
92 clientLoginBannerOptions?: {
93 /** Whether the banner is enabled. */
94 enabled: boolean;
95 /** Text displayed to connecting clients. Required when enabled. */
96 bannerText?: string;
97 };
98 /** Enforce endpoint routes on clients. @default false */
99 clientRouteEnforcement?: boolean;
100 /** Endpoint address family. Changing it replaces the endpoint. @default "ipv4" */
101 endpointIpAddressType?: "ipv4" | "ipv6" | "dual-stack";
102 /** Traffic address family. Changing it replaces the endpoint. @default "ipv4" */
103 trafficIpAddressType?: "ipv4" | "ipv6" | "dual-stack";
104 /** User tags. Alchemy ownership tags cannot be overridden. */
105 tags?: Record<string, string>;
106}
107
108export interface ClientVpnEndpoint extends Resource<
109 "AWS.EC2.ClientVpnEndpoint",
110 ClientVpnEndpointProps,
111 {
112 /** AWS-assigned endpoint ID. */
113 clientVpnEndpointId: ClientVpnEndpointId;
114 /** Amazon Resource Name of the endpoint. */
115 clientVpnEndpointArn: string;
116 /** DNS name clients connect to. */
117 dnsName: string;
118 /** Current status. An endpoint without target networks is pending-associate. */
119 status: EC2.ClientVpnEndpointStatusCode;
120 /** Observed description. */
121 description: string;
122 /** Client IPv4 address range, absent for IPv6-only traffic. */
123 clientCidrBlock: string | undefined;
124 /** ACM server certificate ARN. */
125 serverCertificateArn: string;
126 /** Observed DNS servers. */
127 dnsServers: string[];
128 /** Whether split tunneling is enabled. */
129 splitTunnel: boolean;
130 /** Listening port. */
131 vpnPort: number;
132 /** Transport protocol. */
133 transportProtocol: string;
134 /** VPC associated with the endpoint. */
135 vpcId: string | undefined;
136 /** Applied security groups. */
137 securityGroupIds: string[];
138 /** Maximum session duration in hours. */
139 sessionTimeoutHours: number;
140 /** Whether clients disconnect at session expiry. */
141 disconnectOnSessionTimeout: boolean;
142 /** Self-service configuration download URL, when enabled. */
143 selfServicePortalUrl: string | undefined;
144 /** Observed connection logging settings. */
145 connectionLogOptions: NonNullable<
146 ClientVpnEndpointProps["connectionLogOptions"]
147 >;
148 /** Observed connection handler settings. */
149 clientConnectOptions: NonNullable<
150 ClientVpnEndpointProps["clientConnectOptions"]
151 >;
152 /** Observed login banner settings. */
153 clientLoginBannerOptions: NonNullable<
154 ClientVpnEndpointProps["clientLoginBannerOptions"]
155 >;
156 /** Observed tags, including ownership tags. */
157 tags: Record<string, string>;
158 },
159 never,
160 Providers
161> {}
162
163/**
164 * An AWS Client VPN endpoint for authenticated remote access to a VPC.
165 * Create target network associations, authorization rules, and routes separately.
166 * An endpoint is usable only after a target network has finished associating.
167 * Client address range, authentication, transport, address families, and VPC
168 * changes replace the endpoint. Other settings update in place; omitted optional
169 * settings restore the documented defaults rather than leaving drift unmanaged.
170 *
171 * Readiness waits default to 30 minutes. Set `AWS_CLIENT_VPN_TIMEOUT` to a
172 * positive finite duration, such as `45 minutes`, to override this deadline.
173 *
174 * ### Creating an Endpoint
175 * **Example:** Mutual TLS with split tunneling
176 * ```typescript
177 * const endpoint = yield* AWS.EC2.ClientVpnEndpoint("Vpn", {
178 * clientCidrBlock: "172.20.0.0/22",
179 * serverCertificateArn: certificate.certificateArn,
180 * authenticationOptions: [{
181 * type: "certificate-authentication",
182 * mutualAuthentication: {
183 * clientRootCertificateChainArn: certificate.certificateArn,
184 * },
185 * }],
186 * splitTunnel: true,
187 * vpcId: vpc.vpcId,
188 * });
189 * ```
190 *
191 * ### Logging Connections
192 * **Example:** Send connection logs to CloudWatch
193 * ```typescript
194 * const endpoint = yield* AWS.EC2.ClientVpnEndpoint("LoggedVpn", {
195 * clientCidrBlock: "172.20.0.0/22",
196 * serverCertificateArn: certificate.certificateArn,
197 * authenticationOptions: [{
198 * type: "directory-service-authentication",
199 * activeDirectory: { directoryId: "d-0123456789" },
200 * }],
201 * connectionLogOptions: {
202 * enabled: true,
203 * cloudwatchLogGroup: logGroup.logGroupName,
204 * },
205 * });
206 * ```
207 *
208 * @resource
209 */
210export const ClientVpnEndpoint = Resource<ClientVpnEndpoint>(
211 "AWS.EC2.ClientVpnEndpoint",
212);
213
214class ClientVpnEndpointNotReady extends Data.TaggedError(
215 "ClientVpnEndpointNotReady",
216)<{
217 endpointId: string;
218 status: string;
219 pendingSettings?: string[];
220}> {
221 get message() {
222 return `Client VPN endpoint ${this.endpointId} is ${this.status}${
223 this.pendingSettings?.length
224 ? `; waiting for ${this.pendingSettings.join(", ")}`
225 : ""
226 }`;
227 }
228}
229
230const gone = (endpoint: EC2.ClientVpnEndpoint) =>
231 endpoint.Status?.Code === "deleted";
232const usable = (endpoint: EC2.ClientVpnEndpoint) =>
233 endpoint.Status?.Code === "pending-associate" ||
234 endpoint.Status?.Code === "available";
235
236const describe = (endpointId: string) =>
237 EC2.describeClientVpnEndpoints({ ClientVpnEndpointIds: [endpointId] }).pipe(
238 Effect.map((result) =>
239 result.ClientVpnEndpoints?.find((endpoint) => !gone(endpoint)),
240 ),
241 Effect.catchTag("InvalidClientVpnEndpointId.NotFound", () =>
242 Effect.succeed(undefined),
243 ),
244 );
245
246const authentication = (
247 options: ClientVpnAuthenticationOptions[],
248): EC2.ClientVpnAuthenticationRequest[] =>
249 options.map((option) => ({
250 Type: option.type,
251 ActiveDirectory: option.activeDirectory && {
252 DirectoryId: option.activeDirectory.directoryId,
253 },
254 MutualAuthentication: option.mutualAuthentication && {
255 ClientRootCertificateChainArn:
256 option.mutualAuthentication.clientRootCertificateChainArn,
257 },
258 FederatedAuthentication: option.federatedAuthentication && {
259 SAMLProviderArn: option.federatedAuthentication.samlProviderArn,
260 SelfServiceSAMLProviderArn:
261 option.federatedAuthentication.selfServiceSamlProviderArn,
262 },
263 }));
264
265const settings = (props: ClientVpnEndpointProps) => ({
266 ServerCertificateArn: props.serverCertificateArn,
267 Description: props.description ?? "",
268 SplitTunnel: props.splitTunnel ?? false,
269 VpnPort: props.vpnPort ?? 443,
270 SelfServicePortal: props.selfServicePortal ?? "disabled",
271 SessionTimeoutHours: props.sessionTimeoutHours ?? 24,
272 DisconnectOnSessionTimeout: props.disconnectOnSessionTimeout ?? false,
273 ConnectionLogOptions: props.connectionLogOptions?.enabled
274 ? {
275 Enabled: true,
276 CloudwatchLogGroup: props.connectionLogOptions.cloudwatchLogGroup,
277 CloudwatchLogStream: props.connectionLogOptions.cloudwatchLogStream,
278 }
279 : { Enabled: false },
280 ClientConnectOptions: props.clientConnectOptions?.enabled
281 ? {
282 Enabled: true,
283 LambdaFunctionArn: props.clientConnectOptions.lambdaFunctionArn,
284 }
285 : { Enabled: false },
286 ClientLoginBannerOptions: props.clientLoginBannerOptions?.enabled
287 ? {
288 Enabled: true,
289 BannerText: props.clientLoginBannerOptions.bannerText,
290 }
291 : { Enabled: false },
292 ClientRouteEnforcementOptions: {
293 Enforced: props.clientRouteEnforcement ?? false,
294 },
295});
296
297const observedSettings = (
298 endpoint: EC2.ClientVpnEndpoint,
299 props: ClientVpnEndpointProps,
300) => ({
301 ServerCertificateArn: endpoint.ServerCertificateArn,
302 Description: endpoint.Description ?? "",
303 SplitTunnel: endpoint.SplitTunnel ?? false,
304 VpnPort: endpoint.VpnPort ?? 443,
305 SelfServicePortal: endpoint.SelfServicePortalUrl ? "enabled" : "disabled",
306 SessionTimeoutHours: endpoint.SessionTimeoutHours ?? 24,
307 DisconnectOnSessionTimeout: endpoint.DisconnectOnSessionTimeout ?? false,
308 ConnectionLogOptions: endpoint.ConnectionLogOptions?.Enabled
309 ? {
310 Enabled: true,
311 CloudwatchLogGroup: endpoint.ConnectionLogOptions.CloudwatchLogGroup,
312 // AWS chooses the stream when only a log group is requested.
313 CloudwatchLogStream:
314 props.connectionLogOptions?.cloudwatchLogStream === undefined
315 ? undefined
316 : endpoint.ConnectionLogOptions.CloudwatchLogStream,
317 }
318 : { Enabled: false },
319 ClientConnectOptions: endpoint.ClientConnectOptions?.Enabled
320 ? {
321 Enabled: true,
322 LambdaFunctionArn: endpoint.ClientConnectOptions.LambdaFunctionArn,
323 }
324 : { Enabled: false },
325 ClientLoginBannerOptions: endpoint.ClientLoginBannerOptions?.Enabled
326 ? {
327 Enabled: true,
328 BannerText: endpoint.ClientLoginBannerOptions.BannerText,
329 }
330 : { Enabled: false },
331 ClientRouteEnforcementOptions: {
332 Enforced: endpoint.ClientRouteEnforcementOptions?.Enforced ?? false,
333 },
334});
335
336const toAttrs = Effect.fn(function* (endpoint: EC2.ClientVpnEndpoint) {
337 const { region, accountId } = yield* AWSEnvironment.current;
338 return {
339 clientVpnEndpointId: endpoint.ClientVpnEndpointId as ClientVpnEndpointId,
340 clientVpnEndpointArn: `arn:aws:ec2:${region}:${accountId}:client-vpn-endpoint/${endpoint.ClientVpnEndpointId}`,
341 dnsName: endpoint.DnsName ?? "",
342 status: endpoint.Status?.Code ?? "pending-associate",
343 description: endpoint.Description ?? "",
344 clientCidrBlock: endpoint.ClientCidrBlock,
345 serverCertificateArn: endpoint.ServerCertificateArn!,
346 dnsServers: endpoint.DnsServers ?? [],
347 splitTunnel: endpoint.SplitTunnel ?? false,
348 vpnPort: endpoint.VpnPort ?? 443,
349 transportProtocol: endpoint.TransportProtocol ?? "udp",
350 vpcId: endpoint.VpcId,
351 securityGroupIds: endpoint.SecurityGroupIds ?? [],
352 sessionTimeoutHours: endpoint.SessionTimeoutHours ?? 24,
353 disconnectOnSessionTimeout: endpoint.DisconnectOnSessionTimeout ?? false,
354 selfServicePortalUrl: endpoint.SelfServicePortalUrl,
355 connectionLogOptions: {
356 enabled: endpoint.ConnectionLogOptions?.Enabled ?? false,
357 cloudwatchLogGroup: endpoint.ConnectionLogOptions?.CloudwatchLogGroup,
358 cloudwatchLogStream: endpoint.ConnectionLogOptions?.CloudwatchLogStream,
359 },
360 clientConnectOptions: {
361 enabled: endpoint.ClientConnectOptions?.Enabled ?? false,
362 lambdaFunctionArn: endpoint.ClientConnectOptions?.LambdaFunctionArn,
363 },
364 clientLoginBannerOptions: {
365 enabled: endpoint.ClientLoginBannerOptions?.Enabled ?? false,
366 bannerText: endpoint.ClientLoginBannerOptions?.BannerText,
367 },
368 tags: tagRecord(
369 endpoint.Tags?.map((tag) => ({ Key: tag.Key!, Value: tag.Value! })),
370 ),
371 } satisfies ClientVpnEndpoint["Attributes"];
372});
373
374const changesFor = Effect.fn(function* (
375 news: ClientVpnEndpointProps,
376 endpoint: EC2.ClientVpnEndpoint,
377) {
378 const desired = settings(news);
379 const observed = observedSettings(endpoint, news);
380 const changes: EC2.ModifyClientVpnEndpointRequest = {
381 ...Object.fromEntries(
382 Object.entries(desired).filter(
383 ([key, value]) =>
384 !deepEqual(value, observed[key as keyof typeof observed]),
385 ),
386 ),
387 };
388 if (!deepEqual(news.dnsServers ?? [], endpoint.DnsServers ?? [])) {
389 changes.DnsServers = {
390 Enabled: !!news.dnsServers?.length,
391 CustomDnsServers: news.dnsServers?.length ? news.dnsServers : undefined,
392 };
393 }
394 const vpcId = news.vpcId ?? endpoint.VpcId;
395 let groups = news.securityGroupIds;
396 if (vpcId && !groups?.length) {
397 const defaults = yield* EC2.describeSecurityGroups({
398 Filters: [
399 { Name: "vpc-id", Values: [vpcId] },
400 { Name: "group-name", Values: ["default"] },
401 ],
402 });
403 groups = defaults.SecurityGroups?.map((group) => group.GroupId!);
404 }
405 if (
406 vpcId &&
407 groups?.length &&
408 !deepEqual(
409 [...groups].sort(),
410 [...(endpoint.SecurityGroupIds ?? [])].sort(),
411 )
412 ) {
413 changes.VpcId = vpcId;
414 changes.SecurityGroupIds = groups;
415 }
416 return { changes, desired, groups };
417});
418
419export const ClientVpnEndpointProvider = () =>
420 Provider.effect(
421 ClientVpnEndpoint,
422 Effect.gen(function* () {
423 const recover = Effect.fn(function* (id: string, instanceId: string) {
424 const endpoints = yield* EC2.describeClientVpnEndpoints
425 .items({
426 Filters: [
427 ...(yield* createAlchemyTagFilters(id)),
428 { Name: "tag:alchemy::instance", Values: [instanceId] },
429 ],
430 })
431 .pipe(Stream.runCollect);
432 return endpoints.find((endpoint) => !gone(endpoint));
433 });
434
435 return {
436 stables: ["clientVpnEndpointId", "clientVpnEndpointArn"],
437 nuke: {
438 dependsOn: [
439 "AWS.EC2.VPC",
440 "AWS.EC2.SecurityGroup",
441 "AWS.ACM.Certificate",
442 ],
443 },
444 diff: Effect.fn(function* ({ id, instanceId, news, olds, output }) {
445 if (!("authenticationOptions" in news)) return { action: "replace" };
446 for (const key of [
447 "clientCidrBlock",
448 "authenticationOptions",
449 "transportProtocol",
450 "vpcId",
451 "endpointIpAddressType",
452 "trafficIpAddressType",
453 ] as const) {
454 if (
455 !isResolved<ClientVpnEndpointProps[typeof key]>(news[key]) ||
456 !deepEqual(news[key], olds[key])
457 )
458 return { action: "replace" };
459 }
460 if (!isResolved<ClientVpnEndpointProps>(news))
461 return { action: "update" };
462 if (!output) return;
463 const endpoint = yield* describe(output.clientVpnEndpointId);
464 if (!endpoint || endpoint.Status?.Code === "deleting")
465 return { action: "update", stables: [] };
466 if (!usable(endpoint)) return { action: "update" };
467 const { changes } = yield* changesFor(news, endpoint);
468 const tags = {
469 ...news.tags,
470 ...(yield* createInternalTags(id)),
471 "alchemy::instance": instanceId,
472 };
473 const { removed, upsert } = diffTags(
474 tagRecord(
475 endpoint.Tags?.map((tag) => ({
476 Key: tag.Key!,
477 Value: tag.Value!,
478 })),
479 ),
480 tags,
481 );
482 if (Object.keys(changes).length || removed.length || upsert.length)
483 return { action: "update" };
484 }),
485 read: Effect.fn(function* ({ id, instanceId, output }) {
486 const endpoint = output
487 ? yield* describe(output.clientVpnEndpointId)
488 : yield* recover(id, instanceId);
489 if (!endpoint) return undefined;
490 const attrs = yield* toAttrs(endpoint);
491 return (yield* hasAlchemyTags(id, attrs.tags))
492 ? attrs
493 : Unowned(attrs);
494 }),
495 list: Effect.fn(function* () {
496 const endpoints = yield* EC2.describeClientVpnEndpoints
497 .items({})
498 .pipe(Stream.runCollect);
499 return yield* Effect.forEach(
500 endpoints.filter((endpoint) => !gone(endpoint)),
501 toAttrs,
502 );
503 }),
504 reconcile: Effect.fn(function* ({ id, instanceId, news, output }) {
505 const tags = {
506 ...news.tags,
507 ...(yield* createInternalTags(id)),
508 "alchemy::instance": instanceId,
509 };
510 let endpoint = output
511 ? yield* describe(output.clientVpnEndpointId)
512 : undefined;
513 endpoint ??= yield* recover(id, instanceId);
514 if (endpoint?.Status?.Code === "deleting") {
515 const endpointId = endpoint.ClientVpnEndpointId!;
516 yield* describe(endpointId).pipe(
517 Effect.flatMap((value) =>
518 value
519 ? Effect.fail(
520 new ClientVpnEndpointNotReady({
521 endpointId,
522 status: value.Status?.Code ?? "unknown",
523 }),
524 )
525 : Effect.void,
526 ),
527 (effect) =>
528 retryClientVpn(
529 effect,
530 (error) => error._tag === "ClientVpnEndpointNotReady",
531 ),
532 );
533 endpoint = undefined;
534 }
535 if (!endpoint) {
536 const created = yield* EC2.createClientVpnEndpoint({
537 ...settings(news),
538 ClientToken: `${instanceId}-${output?.clientVpnEndpointId.slice(-17) ?? "initial"}`,
539 ClientCidrBlock: news.clientCidrBlock,
540 AuthenticationOptions: authentication(news.authenticationOptions),
541 TransportProtocol: news.transportProtocol ?? "udp",
542 DnsServers: news.dnsServers?.length ? news.dnsServers : undefined,
543 VpcId: news.vpcId,
544 SecurityGroupIds: news.securityGroupIds,
545 EndpointIpAddressType: news.endpointIpAddressType ?? "ipv4",
546 TrafficIpAddressType: news.trafficIpAddressType ?? "ipv4",
547 TagSpecifications: [
548 {
549 ResourceType: "client-vpn-endpoint",
550 Tags: createTagsList(tags),
551 },
552 ],
553 });
554 const endpointId = created.ClientVpnEndpointId!;
555 endpoint = yield* describe(endpointId).pipe(
556 Effect.flatMap((value) =>
557 value && usable(value)
558 ? Effect.succeed(value)
559 : Effect.fail(
560 new ClientVpnEndpointNotReady({
561 endpointId,
562 status: value?.Status?.Code ?? "missing",
563 }),
564 ),
565 ),
566 (effect) =>
567 retryClientVpn(
568 effect,
569 (error) => error._tag === "ClientVpnEndpointNotReady",
570 ),
571 );
572 }
573 const endpointId = endpoint.ClientVpnEndpointId!;
574 const { changes, desired, groups } = yield* changesFor(
575 news,
576 endpoint,
577 );
578 if (Object.keys(changes).length) {
579 yield* EC2.modifyClientVpnEndpoint({
580 ClientVpnEndpointId: endpointId,
581 ...changes,
582 });
583 endpoint = yield* describe(endpointId).pipe(
584 Effect.flatMap((value) => {
585 const current = value && observedSettings(value, news);
586 const matches =
587 current &&
588 Object.entries(desired).every(([key, expected]) =>
589 deepEqual(current[key as keyof typeof current], expected),
590 );
591 return value &&
592 matches &&
593 deepEqual(value.DnsServers ?? [], news.dnsServers ?? []) &&
594 (!groups ||
595 deepEqual(
596 [...(value.SecurityGroupIds ?? [])].sort(),
597 [...groups].sort(),
598 )) &&
599 // Disabled hooks can remain "applying" before a subnet is associated.
600 (!news.clientConnectOptions?.enabled ||
601 value.ClientConnectOptions?.Status?.Code !== "applying")
602 ? Effect.succeed(value)
603 : Effect.fail(
604 new ClientVpnEndpointNotReady({
605 endpointId,
606 status: value?.Status?.Code ?? "missing",
607 pendingSettings: Object.entries(desired)
608 .filter(
609 ([key, expected]) =>
610 !current ||
611 !deepEqual(
612 current[key as keyof typeof current],
613 expected,
614 ),
615 )
616 .map(([key]) => key),
617 }),
618 );
619 }),
620 (effect) =>
621 retryClientVpn(
622 effect,
623 (error) => error._tag === "ClientVpnEndpointNotReady",
624 ),
625 );
626 }
627 const { removed, upsert } = diffTags(
628 tagRecord(
629 endpoint.Tags?.map((tag) => ({
630 Key: tag.Key!,
631 Value: tag.Value!,
632 })),
633 ),
634 tags,
635 );
636 if (removed.length)
637 yield* EC2.deleteTags({
638 Resources: [endpointId],
639 Tags: removed.map((Key) => ({ Key })),
640 });
641 if (upsert.length)
642 yield* EC2.createTags({ Resources: [endpointId], Tags: upsert });
643 const final = yield* describe(endpointId);
644 if (!final)
645 return yield* Effect.fail(
646 new ClientVpnEndpointNotReady({ endpointId, status: "missing" }),
647 );
648 return yield* toAttrs(final);
649 }),
650 delete: Effect.fn(function* ({ output }) {
651 const endpointId = output.clientVpnEndpointId;
652 const endpoint = yield* describe(endpointId);
653 if (!endpoint) return;
654 if (endpoint.Status?.Code !== "deleting") {
655 yield* EC2.deleteClientVpnEndpoint({
656 ClientVpnEndpointId: endpointId,
657 }).pipe(
658 Effect.catchTag(
659 "InvalidClientVpnEndpointId.NotFound",
660 () => Effect.void,
661 ),
662 );
663 }
664 yield* describe(endpointId).pipe(
665 Effect.flatMap((value) =>
666 value
667 ? Effect.fail(
668 new ClientVpnEndpointNotReady({
669 endpointId,
670 status: value.Status?.Code ?? "unknown",
671 }),
672 )
673 : Effect.void,
674 ),
675 (effect) =>
676 retryClientVpn(
677 effect,
678 (error) => error._tag === "ClientVpnEndpointNotReady",
679 ),
680 );
681 }),
682 };
683 }),
684 );