1import * as AWS from "@/AWS";
2import { DataSource, Index } from "@/AWS/Kendra";
3import * as Test from "@/Test/Alchemy";
4import * as kendra from "@distilled.cloud/aws/kendra";
5import { describe, 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// Kendra is reachable but an index takes ~20-30 minutes to provision and
12// bills hourly once ACTIVE (Developer edition included after free tier).
13// The ungated probes assert the distilled wiring surfaces the typed
14// not-found errors the provider's read/delete paths depend on; the full
15// lifecycle is gated behind AWS_TEST_SLOW=1.
16describe(
17 "AWS.Kendra.Index",
18 { tags: ["provider:aws", "provider:aws:kendra", "live"] },
19 () => {
20 test.provider(
21 "describeIndex on a nonexistent id yields a typed ResourceNotFoundException",
22 (_stack) =>
23 Effect.gen(function* () {
24 const error = yield* kendra
25 .describeIndex({ Id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" })
26 .pipe(Effect.flip);
27 expect(error._tag).toBe("ResourceNotFoundException");
28 }),
29 { timeout: 60_000 },
30 );
31
32 test.provider(
33 "describeDataSource on a nonexistent index yields a typed ResourceNotFoundException",
34 (_stack) =>
35 Effect.gen(function* () {
36 const error = yield* kendra
37 .describeDataSource({
38 Id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
39 IndexId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
40 })
41 .pipe(Effect.flip);
42 expect(error._tag).toBe("ResourceNotFoundException");
43 }),
44 { timeout: 60_000 },
45 );
46
47 test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
48 "create developer index + S3 data source, update, destroy, verify gone",
49 (stack) =>
50 Effect.gen(function* () {
51 yield* stack.destroy();
52
53 const deploy = (props: { description?: string }) =>
54 stack.deploy(
55 Effect.gen(function* () {
56 // Role Kendra assumes to publish CloudWatch metrics/logs.
57 const indexRole = yield* AWS.IAM.Role("KendraIndexRole", {
58 assumeRolePolicyDocument: {
59 Version: "2012-10-17",
60 Statement: [
61 {
62 Effect: "Allow",
63 Principal: { Service: ["kendra.amazonaws.com"] },
64 Action: ["sts:AssumeRole"],
65 },
66 ],
67 },
68 inlinePolicies: {
69 observability: {
70 Version: "2012-10-17",
71 Statement: [
72 {
73 Effect: "Allow",
74 Action: ["cloudwatch:PutMetricData"],
75 Resource: ["*"],
76 Condition: {
77 StringEquals: {
78 "cloudwatch:namespace": "AWS/Kendra",
79 },
80 },
81 },
82 {
83 Effect: "Allow",
84 Action: [
85 "logs:DescribeLogGroups",
86 "logs:CreateLogGroup",
87 "logs:DescribeLogStreams",
88 "logs:CreateLogStream",
89 "logs:PutLogEvents",
90 ],
91 Resource: ["*"],
92 },
93 ],
94 },
95 },
96 });
97
98 const bucket = yield* AWS.S3.Bucket("KendraDocs", {
99 forceDestroy: true,
100 });
101
102 // Role Kendra assumes to crawl the S3 bucket.
103 const dataSourceRole = yield* AWS.IAM.Role("KendraDataRole", {
104 assumeRolePolicyDocument: {
105 Version: "2012-10-17",
106 Statement: [
107 {
108 Effect: "Allow",
109 Principal: { Service: ["kendra.amazonaws.com"] },
110 Action: ["sts:AssumeRole"],
111 },
112 ],
113 },
114 inlinePolicies: {
115 s3: {
116 Version: "2012-10-17",
117 Statement: [
118 {
119 Effect: "Allow",
120 Action: ["s3:GetObject", "s3:ListBucket"],
121 Resource: ["*"],
122 },
123 {
124 Effect: "Allow",
125 Action: [
126 "kendra:BatchPutDocument",
127 "kendra:BatchDeleteDocument",
128 ],
129 Resource: ["*"],
130 },
131 ],
132 },
133 },
134 });
135
136 const index = yield* Index("Search", {
137 edition: "DEVELOPER_EDITION",
138 roleArn: indexRole.roleArn,
139 description: props.description,
140 tags: { Environment: "test" },
141 });
142
143 const source = yield* DataSource("Docs", {
144 indexId: index.id,
145 type: "S3",
146 roleArn: dataSourceRole.roleArn,
147 configuration: {
148 S3Configuration: {
149 BucketName: bucket.bucketName,
150 },
151 },
152 });
153
154 return { index, source };
155 }),
156 );
157
158 // Create — reconcile waits (bounded) for ACTIVE.
159 const { index, source } = yield* deploy({});
160 expect(index.id).toBeDefined();
161 expect(index.status).toBe("ACTIVE");
162 expect(index.edition).toBe("DEVELOPER_EDITION");
163 expect(source.id).toBeDefined();
164 expect(source.status).toBe("ACTIVE");
165 expect(source.type).toBe("S3");
166
167 // Out-of-band verification via distilled.
168 const described = yield* kendra.describeIndex({ Id: index.id });
169 expect(described.Status).toBe("ACTIVE");
170
171 // Update in place — description flows through UpdateIndex.
172 const updated = yield* deploy({ description: "updated by test" });
173 expect(updated.index.id).toBe(index.id);
174 const redescribed = yield* kendra.describeIndex({ Id: index.id });
175 expect(redescribed.Description).toBe("updated by test");
176
177 yield* stack.destroy();
178
179 // Typed wait-until-gone.
180 yield* Effect.gen(function* () {
181 const gone = yield* kendra.describeIndex({ Id: index.id }).pipe(
182 Effect.map((d) => d.Status === "DELETING"),
183 Effect.catchTag("ResourceNotFoundException", () =>
184 Effect.succeed(true),
185 ),
186 );
187 if (!gone) {
188 return yield* Effect.fail({ _tag: "StillExists" as const });
189 }
190 }).pipe(
191 Effect.retry({
192 while: (e: { _tag: string }) => e._tag === "StillExists",
193 schedule: Schedule.max([
194 Schedule.spaced("15 seconds"),
195 Schedule.recurs(40),
196 ]),
197 }),
198 );
199 }),
200 { tags: ["provider:aws:iam", "provider:aws:s3"], timeout: 3_600_000 },
201 );
202 },
203);