1import * as opensearch from "@distilled.cloud/aws/opensearch";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
9import type { PolicyDocument } from "../IAM/Policy.ts";
10import {
11 normalizePolicyDocument,
12 stringifyPolicyDocument,
13} from "../IAM/Policy.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 isDomainActive,
17 isDomainDeletable,
18 readDomainTags,
19 repeatUntilDomainState,
20 subsetDiffers,
21} from "./internal.ts";
22
23export interface DomainClusterConfig {
24 /**
25 * Instance type of the data nodes, e.g. `"t3.small.search"` (the cheapest
26 * general-purpose type) or `"r7g.large.search"`.
27 * @default "t3.small.search"
28 */
29 instanceType?: opensearch.OpenSearchPartitionInstanceType;
30 /**
31 * Number of data nodes in the cluster.
32 * @default 1
33 */
34 instanceCount?: number;
35 /**
36 * Whether dedicated master nodes are enabled.
37 */
38 dedicatedMasterEnabled?: boolean;
39 /**
40 * Instance type of the dedicated master nodes.
41 */
42 dedicatedMasterType?: opensearch.OpenSearchPartitionInstanceType;
43 /**
44 * Number of dedicated master nodes (3 or 5 recommended).
45 */
46 dedicatedMasterCount?: number;
47 /**
48 * Whether data is distributed across multiple Availability Zones.
49 */
50 zoneAwarenessEnabled?: boolean;
51 /**
52 * Number of Availability Zones (2 or 3) when zone awareness is enabled.
53 */
54 availabilityZoneCount?: number;
55 /**
56 * Whether UltraWarm storage is enabled. Not supported on t2/t3 instances.
57 */
58 warmEnabled?: boolean;
59 /**
60 * Instance type of the UltraWarm nodes.
61 */
62 warmType?: opensearch.OpenSearchWarmPartitionInstanceType;
63 /**
64 * Number of UltraWarm nodes.
65 */
66 warmCount?: number;
67 /**
68 * Whether the domain runs in Multi-AZ with Standby mode.
69 */
70 multiAZWithStandbyEnabled?: boolean;
71}
72
73export interface DomainEbsOptions {
74 /**
75 * Whether EBS volumes are attached to the data nodes. Required for
76 * EBS-only instance types like `t3.small.search`.
77 * @default true
78 */
79 enabled?: boolean;
80 /**
81 * EBS volume type.
82 * @default "gp3"
83 */
84 volumeType?: opensearch.VolumeType;
85 /**
86 * EBS volume size in GiB per data node.
87 * @default 10
88 */
89 volumeSize?: number;
90 /**
91 * Provisioned IOPS (io1/gp3 volumes).
92 */
93 iops?: number;
94 /**
95 * Provisioned throughput in MiB/s (gp3 volumes).
96 */
97 throughput?: number;
98}
99
100export interface DomainEncryptionAtRestOptions {
101 /**
102 * Whether encryption at rest is enabled. Enabling on an existing domain is
103 * an in-place update; DISABLING it is not supported and replaces the
104 * domain. Not supported on t2 instance types.
105 * @default true when `encryptionAtRest` is specified
106 */
107 enabled?: boolean;
108 /**
109 * Customer-managed KMS key ID used for encryption.
110 * @default the AWS-owned aws/es key
111 */
112 kmsKeyId?: string;
113}
114
115export interface DomainEndpointOptionsProps {
116 /**
117 * Whether all traffic to the domain must arrive over HTTPS.
118 */
119 enforceHTTPS?: boolean;
120 /**
121 * Minimum TLS version for HTTPS connections, e.g.
122 * `"Policy-Min-TLS-1-2-2019-07"`.
123 */
124 tlsSecurityPolicy?: opensearch.TLSSecurityPolicy;
125 /**
126 * Whether a custom endpoint is enabled for the domain.
127 */
128 customEndpointEnabled?: boolean;
129 /**
130 * Fully qualified custom endpoint, e.g. `"search.example.com"`.
131 */
132 customEndpoint?: string;
133 /**
134 * ACM certificate ARN for the custom endpoint.
135 */
136 customEndpointCertificateArn?: string;
137}
138
139export interface DomainSnapshotOptions {
140 /**
141 * Hour of the day (0-23, UTC) when automated snapshots are taken.
142 */
143 automatedSnapshotStartHour?: number;
144}
145
146export interface DomainVpcOptions {
147 /**
148 * Subnet IDs the domain's endpoints are placed into. One subnet unless
149 * zone awareness is enabled.
150 */
151 subnetIds?: string[];
152 /**
153 * Security group IDs applied to the domain's network interfaces.
154 */
155 securityGroupIds?: string[];
156}
157
158export interface DomainProps {
159 /**
160 * Name of the domain. 3-28 characters; lowercase letters, numbers, and
161 * hyphens; must start with a lowercase letter. If omitted, a deterministic
162 * physical name is generated. Changing the name replaces the domain.
163 */
164 domainName?: string;
165 /**
166 * Engine version, e.g. `"OpenSearch_2.19"` or `"Elasticsearch_7.10"`.
167 * Raising the version triggers an in-place engine upgrade.
168 * @default the latest OpenSearch version
169 */
170 engineVersion?: string;
171 /**
172 * Cluster topology: data node type/count, dedicated masters, zone
173 * awareness, and UltraWarm.
174 * @default a single t3.small.search data node
175 */
176 clusterConfig?: DomainClusterConfig;
177 /**
178 * EBS storage attached to each data node.
179 * @default 10 GiB gp3
180 */
181 ebsOptions?: DomainEbsOptions;
182 /**
183 * IAM resource-based access policy document controlling who can reach the
184 * domain endpoint, either as a structured {@link PolicyDocument} or a raw
185 * JSON string (escape hatch / adoption of pre-existing policies).
186 */
187 accessPolicies?: PolicyDocument | string;
188 /**
189 * IP address type of the endpoint — `"ipv4"` or `"dualstack"`.
190 */
191 ipAddressType?: opensearch.IPAddressType;
192 /**
193 * VPC placement for the domain endpoints. Omit for a public endpoint.
194 */
195 vpcOptions?: DomainVpcOptions;
196 /**
197 * Encryption of data at rest. Disabling on an existing domain replaces it.
198 */
199 encryptionAtRest?: DomainEncryptionAtRestOptions;
200 /**
201 * Whether node-to-node (in transit) encryption is enabled. Disabling on an
202 * existing domain replaces it.
203 */
204 nodeToNodeEncryption?: boolean;
205 /**
206 * Advanced OpenSearch settings, e.g.
207 * `{ "rest.action.multi.allow_explicit_index": "true" }`.
208 */
209 advancedOptions?: Record<string, string>;
210 /**
211 * HTTPS enforcement, TLS policy, and custom endpoint configuration.
212 */
213 domainEndpointOptions?: DomainEndpointOptionsProps;
214 /**
215 * Automated snapshot configuration.
216 */
217 snapshotOptions?: DomainSnapshotOptions;
218 /**
219 * User-defined tags for the domain.
220 */
221 tags?: Record<string, string>;
222}
223
224export interface Domain extends Resource<
225 "AWS.OpenSearch.Domain",
226 DomainProps,
227 {
228 /**
229 * Name of the domain.
230 */
231 domainName: string;
232 /**
233 * ARN of the domain.
234 */
235 domainArn: string;
236 /**
237 * Unique identifier of the domain (`account-id/domain-name`).
238 */
239 domainId: string;
240 /**
241 * Engine version running on the domain (e.g. `OpenSearch_2.19`).
242 */
243 engineVersion: string | undefined;
244 /**
245 * Domain-specific HTTPS endpoint for search and index requests.
246 */
247 endpoint: string | undefined;
248 /**
249 * Whether domain creation has completed.
250 */
251 created: boolean;
252 /**
253 * Whether a configuration change is currently being applied.
254 */
255 processing: boolean;
256 /**
257 * Tags on the domain.
258 */
259 tags: Record<string, string>;
260 },
261 never,
262 Providers
263> {}
264
265/**
266 * An Amazon OpenSearch Service domain — a managed OpenSearch/Elasticsearch
267 * cluster with a search/HTTP endpoint.
268 *
269 * Domains take roughly 15-25 minutes to provision (and about as long for
270 * blue/green configuration changes) and are billed per instance-hour while
271 * they exist. Destroy domains you are not using.
272 * ### Creating a Domain
273 * **Example:** Minimal Domain
274 * ```typescript
275 * // A single t3.small.search node with 10 GiB of gp3 EBS storage.
276 * const domain = yield* Domain("Search", {});
277 * ```
278 *
279 * **Example:** Encrypted Domain with Access Policy
280 * ```typescript
281 * const domain = yield* Domain("Search", {
282 * engineVersion: "OpenSearch_2.19",
283 * clusterConfig: { instanceType: "t3.small.search", instanceCount: 1 },
284 * ebsOptions: { volumeType: "gp3", volumeSize: 10 },
285 * encryptionAtRest: { enabled: true },
286 * nodeToNodeEncryption: true,
287 * domainEndpointOptions: {
288 * enforceHTTPS: true,
289 * tlsSecurityPolicy: "Policy-Min-TLS-1-2-2019-07",
290 * },
291 * accessPolicies: {
292 * Version: "2012-10-17",
293 * Statement: [
294 * {
295 * Effect: "Allow",
296 * Principal: { AWS: `arn:aws:iam::${accountId}:root` },
297 * Action: ["es:*"],
298 * Resource: `arn:aws:es:${region}:${accountId}:domain/*`,
299 * },
300 * ],
301 * },
302 * });
303 * ```
304 *
305 * ### Multi-AZ Cluster
306 * **Example:** Zone-Aware Cluster
307 * ```typescript
308 * const domain = yield* Domain("Search", {
309 * clusterConfig: {
310 * instanceType: "r7g.large.search",
311 * instanceCount: 2,
312 * zoneAwarenessEnabled: true,
313 * availabilityZoneCount: 2,
314 * },
315 * });
316 * ```
317 *
318 * @resource
319 */
320export const Domain = Resource<Domain>("AWS.OpenSearch.Domain");
321
322const DEFAULT_INSTANCE_TYPE = "t3.small.search";
323
324const toClusterConfig = (
325 config: DomainClusterConfig | undefined,
326): opensearch.ClusterConfig => ({
327 InstanceType: config?.instanceType ?? DEFAULT_INSTANCE_TYPE,
328 InstanceCount: config?.instanceCount ?? 1,
329 DedicatedMasterEnabled: config?.dedicatedMasterEnabled,
330 DedicatedMasterType: config?.dedicatedMasterType,
331 DedicatedMasterCount: config?.dedicatedMasterCount,
332 ZoneAwarenessEnabled: config?.zoneAwarenessEnabled,
333 ZoneAwarenessConfig:
334 config?.availabilityZoneCount !== undefined
335 ? { AvailabilityZoneCount: config.availabilityZoneCount }
336 : undefined,
337 WarmEnabled: config?.warmEnabled,
338 WarmType: config?.warmType,
339 WarmCount: config?.warmCount,
340 MultiAZWithStandbyEnabled: config?.multiAZWithStandbyEnabled,
341});
342
343const toEbsOptions = (
344 ebs: DomainEbsOptions | undefined,
345): opensearch.EBSOptions =>
346 ebs?.enabled === false
347 ? { EBSEnabled: false }
348 : {
349 EBSEnabled: true,
350 VolumeType: ebs?.volumeType ?? "gp3",
351 VolumeSize: ebs?.volumeSize ?? 10,
352 Iops: ebs?.iops,
353 Throughput: ebs?.throughput,
354 };
355
356const toEndpointOptions = (
357 options: DomainEndpointOptionsProps | undefined,
358): opensearch.DomainEndpointOptions | undefined =>
359 options === undefined
360 ? undefined
361 : {
362 EnforceHTTPS: options.enforceHTTPS,
363 TLSSecurityPolicy: options.tlsSecurityPolicy,
364 CustomEndpointEnabled: options.customEndpointEnabled,
365 CustomEndpoint: options.customEndpoint,
366 CustomEndpointCertificateArn: options.customEndpointCertificateArn,
367 };
368
369const toAccessPolicies = (
370 policy: PolicyDocument | string | undefined,
371): string | undefined =>
372 policy === undefined
373 ? undefined
374 : typeof policy === "string"
375 ? policy
376 : stringifyPolicyDocument(policy);
377
378const toVpcOptions = (
379 options: DomainVpcOptions | undefined,
380): opensearch.VPCOptions | undefined =>
381 options === undefined
382 ? undefined
383 : {
384 SubnetIds: options.subnetIds,
385 SecurityGroupIds: options.securityGroupIds,
386 };
387
388export const DomainProvider = () =>
389 Provider.effect(
390 Domain,
391 Effect.gen(function* () {
392 const toName = (id: string, props: DomainProps) =>
393 props.domainName
394 ? Effect.succeed(props.domainName)
395 : createPhysicalName({ id, maxLength: 28, lowercase: true });
396
397 const readDomain = Effect.fn(function* (name: string) {
398 return yield* opensearch.describeDomain({ DomainName: name }).pipe(
399 Effect.map((response) => response.DomainStatus),
400 Effect.catchTag("ResourceNotFoundException", () =>
401 Effect.succeed(undefined),
402 ),
403 );
404 });
405
406 const waitForActive = Effect.fn(function* (name: string) {
407 const domain = yield* repeatUntilDomainState(
408 readDomain(name),
409 isDomainActive,
410 );
411 if (domain === undefined) {
412 return yield* Effect.fail(
413 new Error(`OpenSearch domain '${name}' not found while waiting`),
414 );
415 }
416 return domain;
417 });
418
419 const toAttrs = Effect.fn(function* (domain: opensearch.DomainStatus) {
420 return {
421 domainName: domain.DomainName,
422 domainArn: domain.ARN,
423 domainId: domain.DomainId,
424 engineVersion: domain.EngineVersion,
425 endpoint: domain.Endpoint ?? domain.EndpointV2,
426 created: domain.Created === true,
427 processing: domain.Processing === true,
428 tags: yield* readDomainTags(domain.ARN),
429 };
430 });
431
432 return {
433 stables: ["domainName", "domainArn", "domainId"],
434
435 diff: Effect.fn(function* ({ id, olds, news }) {
436 if (!isResolved(news)) return undefined;
437 const o: DomainProps = olds ?? {};
438 const n: DomainProps = news ?? {};
439 if ((yield* toName(id, o)) !== (yield* toName(id, n))) {
440 return { action: "replace" } as const;
441 }
442 // Encryption at rest and node-to-node encryption can be ENABLED
443 // in place but never disabled — disabling replaces the domain.
444 if (
445 o.encryptionAtRest?.enabled !== false &&
446 o.encryptionAtRest !== undefined &&
447 n.encryptionAtRest?.enabled === false
448 ) {
449 return { action: "replace" } as const;
450 }
451 if (
452 o.nodeToNodeEncryption === true &&
453 n.nodeToNodeEncryption === false
454 ) {
455 return { action: "replace" } as const;
456 }
457 // Moving a domain between public and VPC endpoints replaces it.
458 if ((o.vpcOptions === undefined) !== (n.vpcOptions === undefined)) {
459 return { action: "replace" } as const;
460 }
461 }),
462
463 read: Effect.fn(function* ({ id, olds, output }) {
464 const name = output?.domainName ?? (yield* toName(id, olds ?? {}));
465 const domain = yield* readDomain(name);
466 if (domain === undefined) return undefined;
467 const attrs = yield* toAttrs(domain);
468 return (yield* hasAlchemyTags(id, attrs.tags))
469 ? attrs
470 : Unowned(attrs);
471 }),
472
473 reconcile: Effect.fn(function* ({ id, news, output, session }) {
474 const props = news!;
475 const name = output?.domainName ?? (yield* toName(id, props));
476 const internalTags = yield* createInternalTags(id);
477 const desiredTags = { ...internalTags, ...props.tags };
478
479 // 1. Observe — cloud state is authoritative.
480 let observed = yield* readDomain(name);
481
482 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
483 if (observed === undefined) {
484 yield* opensearch
485 .createDomain({
486 DomainName: name,
487 EngineVersion: props.engineVersion,
488 ClusterConfig: toClusterConfig(props.clusterConfig),
489 EBSOptions: toEbsOptions(props.ebsOptions),
490 AccessPolicies: toAccessPolicies(props.accessPolicies),
491 IPAddressType: props.ipAddressType,
492 SnapshotOptions:
493 props.snapshotOptions !== undefined
494 ? {
495 AutomatedSnapshotStartHour:
496 props.snapshotOptions.automatedSnapshotStartHour,
497 }
498 : undefined,
499 VPCOptions: toVpcOptions(props.vpcOptions),
500 EncryptionAtRestOptions:
501 props.encryptionAtRest !== undefined
502 ? {
503 Enabled: props.encryptionAtRest.enabled ?? true,
504 KmsKeyId: props.encryptionAtRest.kmsKeyId,
505 }
506 : undefined,
507 NodeToNodeEncryptionOptions:
508 props.nodeToNodeEncryption !== undefined
509 ? { Enabled: props.nodeToNodeEncryption }
510 : undefined,
511 AdvancedOptions: props.advancedOptions,
512 DomainEndpointOptions: toEndpointOptions(
513 props.domainEndpointOptions,
514 ),
515 TagList: Object.entries(desiredTags).map(([Key, Value]) => ({
516 Key,
517 Value,
518 })),
519 })
520 .pipe(
521 Effect.catchTag(
522 "ResourceAlreadyExistsException",
523 () => Effect.void,
524 ),
525 );
526 }
527
528 // Provisioning and blue/green config changes both surface as
529 // Processing=true; wait (bounded) so config updates do not fail.
530 observed = yield* waitForActive(name);
531
532 // 3. Sync — compute the update delta from OBSERVED state.
533 const update: opensearch.UpdateDomainConfigRequest = {
534 DomainName: name,
535 };
536 let mutated = false;
537
538 const desiredCluster = toClusterConfig(props.clusterConfig);
539 if (subsetDiffers(desiredCluster, observed.ClusterConfig)) {
540 update.ClusterConfig = desiredCluster;
541 mutated = true;
542 }
543 const desiredEbs = toEbsOptions(props.ebsOptions);
544 if (subsetDiffers(desiredEbs, observed.EBSOptions)) {
545 update.EBSOptions = desiredEbs;
546 mutated = true;
547 }
548 // Drift-compare canonicalized documents so key order / encoding
549 // differences in what OpenSearch echoes back never cause a
550 // spurious blue/green config change on re-deploy.
551 if (
552 props.accessPolicies !== undefined &&
553 normalizePolicyDocument(props.accessPolicies) !==
554 normalizePolicyDocument(observed.AccessPolicies ?? "")
555 ) {
556 update.AccessPolicies = toAccessPolicies(props.accessPolicies);
557 mutated = true;
558 }
559 if (
560 props.ipAddressType !== undefined &&
561 props.ipAddressType !== observed.IPAddressType
562 ) {
563 update.IPAddressType = props.ipAddressType;
564 mutated = true;
565 }
566 if (
567 props.snapshotOptions?.automatedSnapshotStartHour !== undefined &&
568 props.snapshotOptions.automatedSnapshotStartHour !==
569 observed.SnapshotOptions?.AutomatedSnapshotStartHour
570 ) {
571 update.SnapshotOptions = {
572 AutomatedSnapshotStartHour:
573 props.snapshotOptions.automatedSnapshotStartHour,
574 };
575 mutated = true;
576 }
577 const desiredVpc = toVpcOptions(props.vpcOptions);
578 if (
579 desiredVpc !== undefined &&
580 subsetDiffers(desiredVpc, {
581 SubnetIds: observed.VPCOptions?.SubnetIds,
582 SecurityGroupIds: observed.VPCOptions?.SecurityGroupIds,
583 })
584 ) {
585 update.VPCOptions = desiredVpc;
586 mutated = true;
587 }
588 if (
589 props.advancedOptions !== undefined &&
590 subsetDiffers(props.advancedOptions, observed.AdvancedOptions)
591 ) {
592 update.AdvancedOptions = props.advancedOptions;
593 mutated = true;
594 }
595 const desiredEndpointOptions = toEndpointOptions(
596 props.domainEndpointOptions,
597 );
598 if (
599 desiredEndpointOptions !== undefined &&
600 subsetDiffers(
601 desiredEndpointOptions,
602 observed.DomainEndpointOptions,
603 )
604 ) {
605 update.DomainEndpointOptions = desiredEndpointOptions;
606 mutated = true;
607 }
608 // Encryption can only be enabled in place (disable = replacement,
609 // handled by diff).
610 if (
611 props.encryptionAtRest !== undefined &&
612 props.encryptionAtRest.enabled !== false &&
613 observed.EncryptionAtRestOptions?.Enabled !== true
614 ) {
615 update.EncryptionAtRestOptions = {
616 Enabled: true,
617 KmsKeyId: props.encryptionAtRest.kmsKeyId,
618 };
619 mutated = true;
620 }
621 if (
622 props.nodeToNodeEncryption === true &&
623 observed.NodeToNodeEncryptionOptions?.Enabled !== true
624 ) {
625 update.NodeToNodeEncryptionOptions = { Enabled: true };
626 mutated = true;
627 }
628
629 if (mutated) {
630 yield* opensearch.updateDomainConfig(update);
631 observed = yield* waitForActive(name);
632 }
633
634 // 3b. Engine upgrades run through the dedicated upgrade API.
635 if (
636 props.engineVersion !== undefined &&
637 observed.EngineVersion !== undefined &&
638 observed.EngineVersion !== props.engineVersion
639 ) {
640 yield* opensearch.upgradeDomain({
641 DomainName: name,
642 TargetVersion: props.engineVersion,
643 });
644 observed = yield* waitForActive(name);
645 }
646
647 // 3c. Sync tags — diff against OBSERVED cloud tags.
648 const observedTags = yield* readDomainTags(observed.ARN);
649 const { removed, upsert } = diffTags(observedTags, desiredTags);
650 if (upsert.length > 0) {
651 yield* opensearch.addTags({ ARN: observed.ARN, TagList: upsert });
652 }
653 if (removed.length > 0) {
654 yield* opensearch.removeTags({
655 ARN: observed.ARN,
656 TagKeys: removed,
657 });
658 }
659
660 yield* session.note(name);
661 return yield* toAttrs(observed);
662 }),
663
664 delete: Effect.fn(function* ({ output }) {
665 const name = output.domainName;
666 // A domain mid-create/config-change may reject deletion — wait
667 // (bounded, tolerant) for it to settle first. Already deleting
668 // (or gone) is success.
669 yield* repeatUntilDomainState(
670 readDomain(name),
671 isDomainDeletable,
672 ).pipe(Effect.catch(() => Effect.succeed(undefined)));
673 yield* opensearch
674 .deleteDomain({ DomainName: name })
675 .pipe(
676 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
677 );
678 }),
679
680 list: () =>
681 opensearch.listDomainNames({}).pipe(
682 Effect.map((response) =>
683 (response.DomainNames ?? [])
684 .map((info) => info.DomainName)
685 .filter((name): name is string => name !== undefined),
686 ),
687 Effect.flatMap(
688 Effect.forEach(
689 (name) =>
690 opensearch.describeDomain({ DomainName: name }).pipe(
691 Effect.map((response) => response.DomainStatus),
692 Effect.catchTag("ResourceNotFoundException", () =>
693 Effect.succeed(undefined),
694 ),
695 ),
696 { concurrency: 4 },
697 ),
698 ),
699 Effect.map((domains) =>
700 domains.filter(
701 (domain): domain is opensearch.DomainStatus =>
702 domain !== undefined,
703 ),
704 ),
705 Effect.flatMap(
706 Effect.forEach((domain) => toAttrs(domain), { concurrency: 4 }),
707 ),
708 ),
709 };
710 }),
711 );