1import * as ds from "@distilled.cloud/aws/directory-service";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import { sameStringSet } from "./internal.ts";
9
10export interface ConditionalForwarderProps {
11 /**
12 * Id of the {@link Directory} (AWS Managed Microsoft AD or AD Connector)
13 * the conditional forwarder is configured on. Simple AD does not support
14 * conditional forwarders. Changing the directory replaces the forwarder.
15 */
16 directoryId: string;
17 /**
18 * Fully qualified domain name of the remote domain DNS queries are
19 * forwarded to, e.g. `partner.example.com`. Changing the domain replaces
20 * the forwarder.
21 */
22 remoteDomainName: string;
23 /**
24 * IP addresses of the remote domain's DNS servers. Updated in place.
25 */
26 dnsIpAddrs: string[];
27}
28
29export interface ConditionalForwarder extends Resource<
30 "AWS.DirectoryService.ConditionalForwarder",
31 ConditionalForwarderProps,
32 {
33 /** The ID of the directory the forwarder is attached to. */
34 directoryId: string;
35 /** The fully qualified domain name the forwarder resolves. */
36 remoteDomainName: string;
37 /** The IP addresses of the remote DNS servers. */
38 dnsIpAddrs: string[];
39 /** The replication scope of the forwarder, e.g. `Domain`. */
40 replicationScope: string | undefined;
41 },
42 never,
43 Providers
44> {}
45
46/**
47 * A conditional forwarder on an AWS Managed Microsoft AD (or AD Connector)
48 * directory — forwards DNS queries for a remote domain to that domain's DNS
49 * servers. Conditional forwarders are the prerequisite for trust
50 * relationships with other domains.
51 * ### Creating a Conditional Forwarder
52 * **Example:** Forward a Partner Domain
53 * ```typescript
54 * const directory = yield* Directory("Corp", {
55 * type: "MicrosoftAD",
56 * name: "corp.example.com",
57 * password: Redacted.make("SuperSecret123!"),
58 * vpcId: vpc.vpcId,
59 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
60 * });
61 * const forwarder = yield* ConditionalForwarder("Partner", {
62 * directoryId: directory.directoryId,
63 * remoteDomainName: "partner.example.com",
64 * dnsIpAddrs: ["10.10.0.2", "10.10.1.2"],
65 * });
66 * ```
67 *
68 * @resource
69 */
70export const ConditionalForwarder = Resource<ConditionalForwarder>(
71 "AWS.DirectoryService.ConditionalForwarder",
72);
73
74// The directory transiently rejects forwarder mutations while its domain
75// controllers are busy (DirectoryUnavailableException); bounded retry
76// through that window.
77const retryWhileDirectoryUnavailable = <
78 A,
79 E extends { readonly _tag: string },
80 R,
81>(
82 self: Effect.Effect<A, E, R>,
83): Effect.Effect<A, E, R> =>
84 Effect.retry(self, {
85 while: (e) => e._tag === "DirectoryUnavailableException",
86 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(8)]),
87 });
88
89export const ConditionalForwarderProvider = () =>
90 Provider.effect(
91 ConditionalForwarder,
92 Effect.gen(function* () {
93 const readForwarder = Effect.fn(function* (
94 directoryId: string,
95 remoteDomainName: string,
96 ) {
97 const response = yield* ds
98 .describeConditionalForwarders({
99 DirectoryId: directoryId,
100 RemoteDomainNames: [remoteDomainName],
101 })
102 .pipe(
103 Effect.catchTag("EntityDoesNotExistException", () =>
104 Effect.succeed(undefined),
105 ),
106 );
107 return response?.ConditionalForwarders?.find(
108 (forwarder) => forwarder.RemoteDomainName === remoteDomainName,
109 );
110 });
111
112 const toAttrs = (
113 directoryId: string,
114 remoteDomainName: string,
115 forwarder: ds.ConditionalForwarder,
116 ) => ({
117 directoryId,
118 remoteDomainName,
119 dnsIpAddrs: [...(forwarder.DnsIpAddrs ?? [])],
120 replicationScope: forwarder.ReplicationScope,
121 });
122
123 return {
124 stables: ["directoryId", "remoteDomainName"],
125
126 diff: Effect.fn(function* ({ olds, news }) {
127 if (!isResolved(news)) return undefined;
128 if (olds === undefined || news === undefined) return undefined;
129 if (news.directoryId !== olds.directoryId) {
130 return { action: "replace" } as const;
131 }
132 if (news.remoteDomainName !== olds.remoteDomainName) {
133 return { action: "replace" } as const;
134 }
135 }),
136
137 read: Effect.fn(function* ({ olds, output }) {
138 const directoryId = output?.directoryId ?? olds?.directoryId;
139 const remoteDomainName =
140 output?.remoteDomainName ?? olds?.remoteDomainName;
141 if (directoryId === undefined || remoteDomainName === undefined) {
142 return undefined;
143 }
144 const forwarder = yield* readForwarder(directoryId, remoteDomainName);
145 if (forwarder === undefined) return undefined;
146 return toAttrs(directoryId, remoteDomainName, forwarder);
147 }),
148
149 reconcile: Effect.fn(function* ({ news, session }) {
150 const props = news!;
151
152 // 1. Observe — cloud state is authoritative.
153 let observed = yield* readForwarder(
154 props.directoryId,
155 props.remoteDomainName,
156 );
157
158 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
159 if (observed === undefined) {
160 yield* ds
161 .createConditionalForwarder({
162 DirectoryId: props.directoryId,
163 RemoteDomainName: props.remoteDomainName,
164 DnsIpAddrs: props.dnsIpAddrs,
165 })
166 .pipe(
167 retryWhileDirectoryUnavailable,
168 Effect.catchTag(
169 "EntityAlreadyExistsException",
170 () => Effect.void,
171 ),
172 );
173 observed = yield* readForwarder(
174 props.directoryId,
175 props.remoteDomainName,
176 );
177 }
178
179 // 3. Sync — update the DNS addresses only when observed state
180 // differs from desired.
181 if (!sameStringSet(observed?.DnsIpAddrs, props.dnsIpAddrs)) {
182 yield* ds
183 .updateConditionalForwarder({
184 DirectoryId: props.directoryId,
185 RemoteDomainName: props.remoteDomainName,
186 DnsIpAddrs: props.dnsIpAddrs,
187 })
188 .pipe(retryWhileDirectoryUnavailable);
189 observed = yield* readForwarder(
190 props.directoryId,
191 props.remoteDomainName,
192 );
193 }
194 if (observed === undefined) {
195 return yield* Effect.fail(
196 new Error(
197 `conditional forwarder for '${props.remoteDomainName}' on '${props.directoryId}' not visible after create`,
198 ),
199 );
200 }
201
202 yield* session.note(`${props.directoryId}/${props.remoteDomainName}`);
203 return toAttrs(props.directoryId, props.remoteDomainName, observed);
204 }),
205
206 delete: Effect.fn(function* ({ output }) {
207 yield* ds
208 .deleteConditionalForwarder({
209 DirectoryId: output.directoryId,
210 RemoteDomainName: output.remoteDomainName,
211 })
212 .pipe(
213 retryWhileDirectoryUnavailable,
214 // The forwarder — or the whole parent directory — being gone
215 // is success.
216 Effect.catchTag("EntityDoesNotExistException", () => Effect.void),
217 );
218 }),
219
220 // Conditional forwarders are keyed by their parent directory; there
221 // is no account-wide enumeration.
222 list: () => Effect.succeed([]),
223 };
224 }),
225 );