1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as opensearch from "@distilled.cloud/aws/opensearch";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// Ungated typed-error probe: proves the distilled error union carries the
12// not-found tag this provider's read/delete paths depend on.
13test.provider(
14 "describeDomain on a nonexistent domain fails with ResourceNotFoundException",
15 () =>
16 Effect.gen(function* () {
17 const error = yield* Effect.flip(
18 opensearch.describeDomain({
19 DomainName: "alchemy-nonexistent-probe",
20 }),
21 );
22 expect(error._tag).toBe("ResourceNotFoundException");
23 }),
24 { tags: ["provider:aws", "provider:aws:opensearch", "live"] },
25);
26
27// Deletion is verified as INITIATED (Deleted=true / Processing, irreversible)
28// or fully gone. Full disappearance takes ~10-15 more minutes server-side;
29// waiting for it would push the test into its timeout.
30const assertDomainDeleting = (name: string) =>
31 Effect.gen(function* () {
32 const status = yield* opensearch.describeDomain({ DomainName: name }).pipe(
33 Effect.map((response) =>
34 response.DomainStatus.Deleted === true ||
35 response.DomainStatus.Processing === true
36 ? ("deleting" as const)
37 : ("active" as const),
38 ),
39 Effect.catchTag("ResourceNotFoundException", () =>
40 Effect.succeed("gone" as const),
41 ),
42 );
43 if (status === "active") {
44 return yield* Effect.fail(
45 new Error(`domain '${name}' still exists and is not deleting`),
46 );
47 }
48 }).pipe(
49 Effect.retry({
50 schedule: Schedule.max([
51 Schedule.fixed("10 seconds"),
52 Schedule.recurs(18),
53 ]),
54 }),
55 );
56
57// OpenSearch domains take ~15-25 minutes to provision and are billed per
58// instance-hour while they exist. The full lifecycle is gated behind
59// AWS_TEST_SLOW=1 and always destroys what it created.
60test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
61 "create encrypted single-node domain, verify, destroy",
62 (stack) =>
63 Effect.gen(function* () {
64 yield* stack.destroy();
65
66 const { accountId, region } = yield* AWSEnvironment.current;
67
68 // A structured (typed) PolicyDocument — deliberately NOT a raw JSON
69 // string — to prove PolicyDocument-valued access policies round-trip.
70 const accessPolicies: AWS.IAM.PolicyDocument = {
71 Version: "2012-10-17",
72 Statement: [
73 {
74 Effect: "Allow",
75 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
76 Action: ["es:*"],
77 Resource: `arn:aws:es:${region}:${accountId}:domain/*`,
78 },
79 ],
80 };
81
82 const program = Effect.gen(function* () {
83 const domain = yield* AWS.OpenSearch.Domain("Search", {
84 engineVersion: "OpenSearch_2.19",
85 clusterConfig: {
86 instanceType: "t3.small.search",
87 instanceCount: 1,
88 },
89 ebsOptions: { volumeType: "gp3", volumeSize: 10 },
90 encryptionAtRest: { enabled: true },
91 nodeToNodeEncryption: true,
92 domainEndpointOptions: {
93 enforceHTTPS: true,
94 tlsSecurityPolicy: "Policy-Min-TLS-1-2-2019-07",
95 },
96 accessPolicies,
97 tags: { fixture: "opensearch-domain" },
98 });
99 return { domain };
100 });
101
102 const { domain } = yield* stack.deploy(program);
103
104 expect(domain.domainName).toBeDefined();
105 expect(domain.domainArn).toContain(":domain/");
106 expect(domain.created).toBe(true);
107 expect(domain.processing).toBe(false);
108 expect(domain.endpoint).toBeDefined();
109 expect(domain.engineVersion).toBe("OpenSearch_2.19");
110
111 // Out-of-band verification via distilled.
112 const described = yield* opensearch.describeDomain({
113 DomainName: domain.domainName,
114 });
115 expect(described.DomainStatus.EncryptionAtRestOptions?.Enabled).toBe(
116 true,
117 );
118 expect(described.DomainStatus.NodeToNodeEncryptionOptions?.Enabled).toBe(
119 true,
120 );
121 expect(described.DomainStatus.ClusterConfig.InstanceType).toBe(
122 "t3.small.search",
123 );
124 expect(described.DomainStatus.DomainEndpointOptions?.EnforceHTTPS).toBe(
125 true,
126 );
127 expect(described.DomainStatus.AccessPolicies).toContain(accountId);
128 // The PolicyDocument round-trips: what OpenSearch echoes back is
129 // canonically equal to the structured document we deployed.
130 expect(
131 AWS.IAM.normalizePolicyDocument(
132 described.DomainStatus.AccessPolicies ?? "",
133 ),
134 ).toBe(AWS.IAM.normalizePolicyDocument(accessPolicies));
135
136 // Re-deploy the identical program — the normalized policy comparison
137 // must see no drift, so no updateDomainConfig (blue/green change) may
138 // fire. A config change would mint a new ChangeProgressDetails.ChangeId
139 // and flip Processing.
140 const changeIdBefore =
141 described.DomainStatus.ChangeProgressDetails?.ChangeId;
142 const { domain: redeployed } = yield* stack.deploy(program);
143 expect(redeployed.domainArn).toBe(domain.domainArn);
144 expect(redeployed.processing).toBe(false);
145 const redescribed = yield* opensearch.describeDomain({
146 DomainName: domain.domainName,
147 });
148 expect(redescribed.DomainStatus.Processing).not.toBe(true);
149 expect(redescribed.DomainStatus.ChangeProgressDetails?.ChangeId).toBe(
150 changeIdBefore,
151 );
152
153 // Destroy immediately — domains bill while they exist — and verify
154 // deletion was initiated out-of-band.
155 yield* stack.destroy();
156 yield* assertDomainDeleting(domain.domainName);
157 }),
158 // create (~15-25 min) + delete initiation, one test.
159 {
160 tags: [
161 "provider:aws",
162 "provider:aws:iam",
163 "provider:aws:opensearch",
164 "live",
165 ],
166 timeout: 3_000_000,
167 },
168);