1import * as ds from "@distilled.cloud/aws/directory-service";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14import { readDirectoryTags, sameStringSet } from "./internal.ts";
15
16/** The kind of managed directory to launch. */
17export type DirectoryFlavor = "SimpleAD" | "MicrosoftAD";
18
19export interface DirectoryProps {
20 /**
21 * Which managed directory to launch — a Samba-based `"SimpleAD"` or an
22 * actual `"MicrosoftAD"` (AWS Managed Microsoft AD). Changing the flavor
23 * replaces the directory.
24 * @default "SimpleAD"
25 */
26 type?: DirectoryFlavor;
27 /**
28 * Fully qualified domain name of the directory, e.g. `corp.example.com`.
29 * The domain does not need to be publicly resolvable. Changing the name
30 * replaces the directory.
31 */
32 name: string;
33 /**
34 * NetBIOS short name of the directory, e.g. `CORP`. Changing the short
35 * name replaces the directory.
36 * @default the first label of `name`
37 */
38 shortName?: string;
39 /**
40 * Password for the directory administrator account (`Administrator` for
41 * Simple AD, `Admin` for Microsoft AD). There is no API to change it after
42 * creation, so changing the password replaces the directory.
43 */
44 password: Redacted.Redacted<string>;
45 /**
46 * Human-readable description of the directory. There is no update API, so
47 * changing the description replaces the directory.
48 */
49 description?: string;
50 /**
51 * Size of a Simple AD directory — `"Small"` (up to ~500 users) or
52 * `"Large"` (up to ~5000 users). Ignored for Microsoft AD. Changing the
53 * size replaces the directory.
54 * @default "Small"
55 */
56 size?: ds.DirectorySize;
57 /**
58 * Edition of a Microsoft AD directory — `"Standard"` or `"Enterprise"`.
59 * Ignored for Simple AD. Changing the edition replaces the directory.
60 * @default "Standard"
61 */
62 edition?: ds.DirectoryEdition;
63 /**
64 * VPC the directory's domain controllers are placed into. Changing the
65 * VPC replaces the directory.
66 */
67 vpcId: string;
68 /**
69 * Exactly two subnets in DIFFERENT Availability Zones of `vpcId` — one
70 * domain controller is launched into each. Changing the subnets replaces
71 * the directory.
72 */
73 subnetIds: string[];
74 /**
75 * User-defined tags for the directory.
76 */
77 tags?: Record<string, string>;
78}
79
80export interface Directory extends Resource<
81 "AWS.DirectoryService.Directory",
82 DirectoryProps,
83 {
84 /** The ID of the directory, e.g. `d-1234567890`. */
85 directoryId: string;
86 /** The ARN of the directory, e.g. `arn:aws:ds:us-east-1:123456789012:directory/d-1234567890`. */
87 directoryArn: string;
88 /** The fully qualified name of the directory. */
89 directoryName: string;
90 /** The directory type, e.g. `SimpleAD` or `MicrosoftAD`. */
91 type: string;
92 /** The current lifecycle stage of the directory, e.g. `Active`. */
93 stage: string;
94 /** The size of a Simple AD directory (`Small` or `Large`). */
95 size: string | undefined;
96 /** The edition of a Microsoft AD directory (`Standard` or `Enterprise`). */
97 edition: string | undefined;
98 /** The directory alias used for the access URL. */
99 alias: string | undefined;
100 /** The access URL of the directory, e.g. `<alias>.awsapps.com`. */
101 accessUrl: string | undefined;
102 /** The IP addresses of the directory's DNS servers. */
103 dnsIpAddrs: string[];
104 /** The security group created for the directory's controllers. */
105 securityGroupId: string | undefined;
106 /** The VPC the directory is deployed into. */
107 vpcId: string | undefined;
108 /** The subnets hosting the directory's domain controllers. */
109 subnetIds: string[];
110 /** The Availability Zones the directory spans. */
111 availabilityZones: string[];
112 /** The tags attached to the directory. */
113 tags: Record<string, string>;
114 },
115 never,
116 Providers
117> {}
118
119/**
120 * An AWS Directory Service managed directory — either Simple AD (Samba) or
121 * AWS Managed Microsoft AD.
122 *
123 * Directories are VPC-only and require two subnets in different Availability
124 * Zones. Provisioning is SLOW: Simple AD takes roughly 10 minutes and
125 * Microsoft AD 20-40 minutes, and directories bill hourly while they exist.
126 * Destroy directories you are not using.
127 * ### Creating a Directory
128 * **Example:** Simple AD Directory
129 * ```typescript
130 * const directory = yield* Directory("Corp", {
131 * name: "corp.example.com",
132 * password: Redacted.make("SuperSecret123!"),
133 * size: "Small",
134 * vpcId: vpc.vpcId,
135 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
136 * });
137 * ```
138 *
139 * **Example:** Managed Microsoft AD Directory
140 * ```typescript
141 * const directory = yield* Directory("Corp", {
142 * type: "MicrosoftAD",
143 * name: "corp.example.com",
144 * shortName: "CORP",
145 * password: Redacted.make("SuperSecret123!"),
146 * edition: "Standard",
147 * vpcId: vpc.vpcId,
148 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
149 * });
150 * ```
151 *
152 * ### Using the Directory
153 * **Example:** Read the DNS Addresses
154 * ```typescript
155 * const directory = yield* Directory("Corp", { ... });
156 * // the directory-provided DNS servers, one per Availability Zone
157 * const dns = directory.dnsIpAddrs;
158 * ```
159 *
160 * @resource
161 */
162export const Directory = Resource<Directory>("AWS.DirectoryService.Directory");
163
164const DEFAULT_SIZE: ds.DirectorySize = "Small";
165const DEFAULT_EDITION: ds.DirectoryEdition = "Standard";
166
167/** Stages from which a directory can never come back. */
168const isTerminalStage = (stage: string | undefined): boolean =>
169 stage === "Deleting" || stage === "Deleted" || stage === "Failed";
170
171class DirectoryNotReady extends Data.TaggedError("DirectoryNotReady")<{
172 readonly directoryId: string;
173 readonly stage: string | undefined;
174}> {}
175
176class DirectoryProvisioningFailed extends Data.TaggedError(
177 "DirectoryProvisioningFailed",
178)<{
179 readonly directoryId: string;
180 readonly stage: string | undefined;
181 readonly reason: string | undefined;
182}> {}
183
184// Directory provisioning is slow (Simple AD ~10 min, Microsoft AD 20-40
185// min); poll every 30s with a ~50 min budget. Only DirectoryNotReady is
186// retried — a Failed/Deleted stage aborts immediately.
187const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
188 self: Effect.Effect<A, E, R>,
189): Effect.Effect<A, E, R> =>
190 Effect.retry(self, {
191 while: (e) => e._tag === "DirectoryNotReady",
192 schedule: Schedule.max([
193 Schedule.fixed("30 seconds"),
194 Schedule.recurs(100),
195 ]),
196 });
197
198export const DirectoryProvider = () =>
199 Provider.effect(
200 Directory,
201 Effect.gen(function* () {
202 const getById = Effect.fn(function* (directoryId: string) {
203 const response = yield* ds
204 .describeDirectories({ DirectoryIds: [directoryId] })
205 .pipe(
206 Effect.catchTag("EntityDoesNotExistException", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210 return response?.DirectoryDescriptions?.[0];
211 });
212
213 // Best-effort fallback for lost state: scan the account's directories
214 // for a live one with the desired domain name.
215 const findByName = Effect.fn(function* (name: string | undefined) {
216 if (name === undefined) return undefined;
217 const matches = yield* ds.describeDirectories.items({}).pipe(
218 Stream.filter((d) => d.Name === name && !isTerminalStage(d.Stage)),
219 Stream.take(1),
220 Stream.runCollect,
221 );
222 return Array.from(matches)[0];
223 });
224
225 const observe = Effect.fn(function* (
226 directoryId: string | undefined,
227 name: string | undefined,
228 ) {
229 const found = directoryId
230 ? yield* getById(directoryId)
231 : yield* findByName(name);
232 return found !== undefined && !isTerminalStage(found.Stage)
233 ? found
234 : undefined;
235 });
236
237 const waitForActive = (directoryId: string) =>
238 Effect.gen(function* () {
239 const directory = yield* getById(directoryId);
240 if (directory === undefined || isTerminalStage(directory.Stage)) {
241 return yield* new DirectoryProvisioningFailed({
242 directoryId,
243 stage: directory?.Stage,
244 reason: directory?.StageReason,
245 });
246 }
247 if (directory.Stage !== "Active") {
248 return yield* new DirectoryNotReady({
249 directoryId,
250 stage: directory.Stage,
251 });
252 }
253 return directory;
254 }).pipe(retryWhileNotReady);
255
256 // Wait for a directory to leave a transitional stage before deleting.
257 // Ends when the directory is in a steady stage, already deleting, or
258 // gone.
259 const waitUntilSettled = Effect.fn(function* (directoryId: string) {
260 return yield* getById(directoryId).pipe(
261 Effect.flatMap((directory) => {
262 if (
263 directory !== undefined &&
264 (directory.Stage === "Requested" ||
265 directory.Stage === "Creating" ||
266 directory.Stage === "Created" ||
267 directory.Stage === "Restoring" ||
268 directory.Stage === "Updating")
269 ) {
270 return Effect.fail(
271 new DirectoryNotReady({
272 directoryId,
273 stage: directory.Stage,
274 }),
275 );
276 }
277 return Effect.succeed(directory);
278 }),
279 retryWhileNotReady,
280 );
281 });
282
283 const toAttrs = Effect.fn(function* (directory: ds.DirectoryDescription) {
284 if (!directory.DirectoryId || !directory.Name) {
285 return yield* Effect.fail(
286 new Error(
287 `directory '${directory.DirectoryId}' is missing its id or name`,
288 ),
289 );
290 }
291 const { accountId, region } = yield* AWSEnvironment.current;
292 return {
293 directoryId: directory.DirectoryId,
294 directoryArn: `arn:aws:ds:${region}:${accountId}:directory/${directory.DirectoryId}`,
295 directoryName: directory.Name,
296 type: directory.Type ?? "SimpleAD",
297 stage: directory.Stage ?? "Active",
298 size: directory.Size,
299 edition: directory.Edition,
300 alias: directory.Alias,
301 accessUrl: directory.AccessUrl,
302 dnsIpAddrs: [...(directory.DnsIpAddrs ?? [])],
303 securityGroupId: directory.VpcSettings?.SecurityGroupId,
304 vpcId: directory.VpcSettings?.VpcId,
305 subnetIds: [...(directory.VpcSettings?.SubnetIds ?? [])],
306 availabilityZones: [
307 ...(directory.VpcSettings?.AvailabilityZones ?? []),
308 ],
309 tags: yield* readDirectoryTags(directory.DirectoryId),
310 };
311 });
312
313 return {
314 stables: ["directoryId", "directoryName"],
315
316 diff: Effect.fn(function* ({ olds, news }) {
317 if (!isResolved(news)) return undefined;
318 const n = news;
319 const o = olds;
320 if (o === undefined || n === undefined) return undefined;
321 // Every launch property is create-only — there is no update API
322 // for the directory itself; only tags mutate in place.
323 const type = (p: DirectoryProps) => p.type ?? "SimpleAD";
324 if (type(n) !== type(o)) return { action: "replace" } as const;
325 if (n.name !== o.name) return { action: "replace" } as const;
326 if (n.shortName !== o.shortName) {
327 return { action: "replace" } as const;
328 }
329 if (Redacted.value(n.password) !== Redacted.value(o.password)) {
330 return { action: "replace" } as const;
331 }
332 if (n.description !== o.description) {
333 return { action: "replace" } as const;
334 }
335 if (
336 type(n) === "SimpleAD" &&
337 (n.size ?? DEFAULT_SIZE) !== (o.size ?? DEFAULT_SIZE)
338 ) {
339 return { action: "replace" } as const;
340 }
341 if (
342 type(n) === "MicrosoftAD" &&
343 (n.edition ?? DEFAULT_EDITION) !== (o.edition ?? DEFAULT_EDITION)
344 ) {
345 return { action: "replace" } as const;
346 }
347 if (n.vpcId !== o.vpcId) return { action: "replace" } as const;
348 if (!sameStringSet(n.subnetIds, o.subnetIds)) {
349 return { action: "replace" } as const;
350 }
351 }),
352
353 read: Effect.fn(function* ({ id, olds, output }) {
354 const directory = yield* observe(output?.directoryId, olds?.name);
355 if (directory === undefined) return undefined;
356 const attrs = yield* toAttrs(directory);
357 return (yield* hasAlchemyTags(id, attrs.tags))
358 ? attrs
359 : Unowned(attrs);
360 }),
361
362 reconcile: Effect.fn(function* ({ id, news, output, session }) {
363 const props = news!;
364 const type = props.type ?? "SimpleAD";
365 const internalTags = yield* createInternalTags(id);
366 const desiredTags = { ...internalTags, ...props.tags };
367
368 // 1. Observe — cloud state is authoritative; output only caches
369 // the directory id.
370 const observed = yield* observe(output?.directoryId, props.name);
371
372 // 2. Ensure — create if missing.
373 let directoryId = observed?.DirectoryId;
374 if (directoryId === undefined) {
375 const tagList = Object.entries(desiredTags).map(([Key, Value]) => ({
376 Key,
377 Value,
378 }));
379 const created =
380 type === "MicrosoftAD"
381 ? yield* ds.createMicrosoftAD({
382 Name: props.name,
383 ShortName: props.shortName,
384 Password: props.password,
385 Description: props.description,
386 Edition: props.edition ?? DEFAULT_EDITION,
387 VpcSettings: {
388 VpcId: props.vpcId,
389 SubnetIds: props.subnetIds,
390 },
391 Tags: tagList,
392 })
393 : yield* ds.createDirectory({
394 Name: props.name,
395 ShortName: props.shortName,
396 Password: props.password,
397 Description: props.description,
398 Size: props.size ?? DEFAULT_SIZE,
399 VpcSettings: {
400 VpcId: props.vpcId,
401 SubnetIds: props.subnetIds,
402 },
403 Tags: tagList,
404 });
405 if (created.DirectoryId === undefined) {
406 return yield* Effect.fail(
407 new Error(
408 `Create${type} for '${props.name}' returned no DirectoryId`,
409 ),
410 );
411 }
412 directoryId = created.DirectoryId;
413 }
414
415 // Provisioning is slow (Simple AD ~10 min, Microsoft AD 20-40
416 // min); wait (bounded) for the directory to become Active so the
417 // returned attributes (DNS addresses, security group) are real.
418 const active = yield* waitForActive(directoryId);
419
420 // 3. Sync tags — diff against OBSERVED cloud tags.
421 const observedTags = yield* readDirectoryTags(directoryId);
422 const { upsert, removed } = diffTags(observedTags, desiredTags);
423 if (upsert.length > 0) {
424 yield* ds.addTagsToResource({
425 ResourceId: directoryId,
426 Tags: upsert,
427 });
428 }
429 if (removed.length > 0) {
430 yield* ds.removeTagsFromResource({
431 ResourceId: directoryId,
432 TagKeys: removed,
433 });
434 }
435
436 yield* session.note(directoryId);
437 return yield* toAttrs(active);
438 }),
439
440 delete: Effect.fn(function* ({ output }) {
441 const directoryId = output.directoryId;
442 // A directory mid-create rejects deletion — wait (bounded) for it
443 // to settle first. Already deleting (or gone) is success.
444 const settled = yield* waitUntilSettled(directoryId).pipe(
445 Effect.catch(() => Effect.succeed(undefined)),
446 );
447 if (
448 settled === undefined ||
449 settled.Stage === "Deleting" ||
450 settled.Stage === "Deleted"
451 ) {
452 return;
453 }
454 yield* ds
455 .deleteDirectory({ DirectoryId: directoryId })
456 .pipe(
457 Effect.catchTag("EntityDoesNotExistException", () => Effect.void),
458 );
459 }),
460
461 list: () =>
462 ds.describeDirectories.items({}).pipe(
463 Stream.runCollect,
464 Effect.flatMap((directories) =>
465 Effect.forEach(
466 Array.from(directories).filter(
467 (d) =>
468 d.DirectoryId !== undefined &&
469 d.Name !== undefined &&
470 !isTerminalStage(d.Stage),
471 ),
472 (d) => toAttrs(d),
473 { concurrency: 4 },
474 ),
475 ),
476 ),
477 };
478 }),
479 );