1import * as route53 from "@distilled.cloud/aws/route-53";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7
8export interface VpcAssociationAuthorizationProps {
9 /**
10 * ID of the private hosted zone to authorize the association with. Must be
11 * a zone owned by the current account. Changing this forces replacement.
12 */
13 hostedZoneId: string;
14 /**
15 * ID of the VPC that is authorized to be associated with the hosted zone
16 * (typically a VPC owned by a different AWS account). Changing this forces
17 * replacement.
18 */
19 vpcId: string;
20 /**
21 * Region the VPC lives in. Changing this forces replacement.
22 */
23 vpcRegion: string;
24}
25
26export interface VpcAssociationAuthorization extends Resource<
27 "AWS.Route53.VpcAssociationAuthorization",
28 VpcAssociationAuthorizationProps,
29 {
30 /**
31 * ID of the private hosted zone.
32 */
33 hostedZoneId: string;
34 /**
35 * ID of the authorized VPC.
36 */
37 vpcId: string;
38 /**
39 * Region of the authorized VPC.
40 */
41 vpcRegion: string;
42 },
43 never,
44 Providers
45> {}
46
47/**
48 * Authorization for a VPC (usually in another AWS account) to be associated
49 * with a private hosted zone.
50 *
51 * Cross-account private-zone association is a two-step handshake: the
52 * zone-owning account creates a `VpcAssociationAuthorization` for the foreign
53 * VPC, then the VPC-owning account submits the association (see
54 * `ZoneVpcAssociation`). Same-account associations don't need an
55 * authorization.
56 * ### Authorizing Cross-Account Association
57 * **Example:** Authorize a VPC
58 * ```typescript
59 * const authorization = yield* VpcAssociationAuthorization("PeerVpcAuth", {
60 * hostedZoneId: zone.id,
61 * vpcId: "vpc-0123456789abcdef0", // VPC in the other account
62 * vpcRegion: "us-west-2",
63 * });
64 * ```
65 *
66 * @resource
67 */
68export const VpcAssociationAuthorization =
69 Resource<VpcAssociationAuthorization>(
70 "AWS.Route53.VpcAssociationAuthorization",
71 );
72
73export const VpcAssociationAuthorizationProvider = () =>
74 Provider.effect(
75 VpcAssociationAuthorization,
76 Effect.gen(function* () {
77 // Enumerate the zone's pending authorizations and find ours. The list
78 // is per-zone and small; bound pagination defensively.
79 const observe = Effect.fn(function* (
80 hostedZoneId: string,
81 vpcId: string,
82 ) {
83 let nextToken: string | undefined;
84 for (let page = 0; page < 10; page++) {
85 const response = yield* route53
86 .listVPCAssociationAuthorizations({
87 HostedZoneId: hostedZoneId,
88 NextToken: nextToken,
89 })
90 .pipe(
91 Effect.catchTag("NoSuchHostedZone", () =>
92 Effect.succeed({
93 HostedZoneId: hostedZoneId,
94 VPCs: [],
95 NextToken: undefined,
96 }),
97 ),
98 );
99 const match = (response.VPCs ?? []).find(
100 (vpc) => vpc.VPCId === vpcId,
101 );
102 if (match) {
103 return match;
104 }
105 if (!response.NextToken) {
106 return undefined;
107 }
108 nextToken = response.NextToken;
109 }
110 return undefined;
111 });
112
113 return {
114 stables: ["hostedZoneId", "vpcId", "vpcRegion"],
115 // Sub-resource: authorizations are keyed by their parent hosted zone
116 // and cannot be enumerated account-wide.
117 list: () => Effect.succeed([]),
118 // Existence-only resource — every property is part of its identity.
119 diff: Effect.fn(function* ({ olds, news }) {
120 if (!isResolved(news)) return undefined;
121 if (
122 olds.hostedZoneId !== news.hostedZoneId ||
123 olds.vpcId !== news.vpcId ||
124 olds.vpcRegion !== news.vpcRegion
125 ) {
126 return { action: "replace" } as const;
127 }
128 }),
129 read: Effect.fn(function* ({ olds, output }) {
130 const hostedZoneId = output?.hostedZoneId ?? olds?.hostedZoneId;
131 const vpcId = output?.vpcId ?? olds?.vpcId;
132 if (hostedZoneId === undefined || vpcId === undefined) {
133 return undefined;
134 }
135 const observed = yield* observe(hostedZoneId, vpcId);
136 if (!observed) {
137 return undefined;
138 }
139 return {
140 hostedZoneId,
141 vpcId,
142 vpcRegion: observed.VPCRegion ?? output?.vpcRegion ?? "",
143 };
144 }),
145 // Existence-only: observe → if missing, create. There is no sync step
146 // (an authorization has no mutable aspects).
147 reconcile: Effect.fn(function* ({ news, session }) {
148 const observed = yield* observe(news.hostedZoneId, news.vpcId);
149 if (!observed) {
150 // `createVPCAssociationAuthorization` is an idempotent upsert for
151 // the same (zone, VPC) pair, so races simply succeed.
152 yield* route53.createVPCAssociationAuthorization({
153 HostedZoneId: news.hostedZoneId,
154 VPC: {
155 VPCId: news.vpcId,
156 VPCRegion: news.vpcRegion as route53.VPCRegion,
157 },
158 });
159 }
160 yield* session.note(`${news.hostedZoneId}/${news.vpcId}`);
161 return {
162 hostedZoneId: news.hostedZoneId,
163 vpcId: news.vpcId,
164 vpcRegion: news.vpcRegion,
165 };
166 }),
167 delete: Effect.fn(function* ({ output }) {
168 yield* route53
169 .deleteVPCAssociationAuthorization({
170 HostedZoneId: output.hostedZoneId,
171 VPC: {
172 VPCId: output.vpcId,
173 VPCRegion: output.vpcRegion as route53.VPCRegion,
174 },
175 })
176 .pipe(
177 Effect.catchTag(
178 "VPCAssociationAuthorizationNotFound",
179 () => Effect.void,
180 ),
181 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
182 );
183 }),
184 };
185 }),
186 );