1import * as kendra from "@distilled.cloud/aws/kendra";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as EffectStream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createInternalTags,
13 diffTags,
14 hasAlchemyTags,
15 type Tags,
16} from "../../Tags.ts";
17import { AWSEnvironment } from "../Environment.ts";
18import type { Providers } from "../Providers.ts";
19
20export type IndexStatus = kendra.IndexStatus;
21export type IndexEdition = kendra.IndexEdition;
22
23/**
24 * Server-side encryption settings for a Kendra index.
25 */
26export interface IndexServerSideEncryption {
27 /**
28 * The identifier of the customer-managed KMS key. Kendra does not support
29 * asymmetric keys.
30 */
31 kmsKeyId?: string;
32}
33
34/**
35 * Provisioned capacity for an `ENTERPRISE_EDITION` index.
36 */
37export interface IndexCapacityUnits {
38 /**
39 * Extra storage capacity units. Each unit adds 30 GB / 500k documents.
40 */
41 storageCapacityUnits: number;
42 /**
43 * Extra query capacity units. Each unit adds 0.1 queries/sec.
44 */
45 queryCapacityUnits: number;
46}
47
48export interface IndexProps {
49 /**
50 * Name of the index.
51 * @default ${app}-${stage}-${id}
52 */
53 name?: string;
54 /**
55 * The Kendra edition to provision.
56 *
57 * `GEN_AI_ENTERPRISE_EDITION` is the recommended edition for new
58 * deployments. Changing the edition replaces the index.
59 * @default "DEVELOPER_EDITION"
60 */
61 edition?: IndexEdition;
62 /**
63 * ARN of the IAM role that grants Kendra permission to write CloudWatch
64 * log and metric data.
65 */
66 roleArn: string;
67 /**
68 * A description of the index.
69 */
70 description?: string;
71 /**
72 * Encryption-at-rest configuration. Changing it replaces the index.
73 */
74 serverSideEncryption?: IndexServerSideEncryption;
75 /**
76 * How to filter query results on user context (`ATTRIBUTE_FILTER` or
77 * `USER_TOKEN`).
78 */
79 userContextPolicy?: kendra.UserContextPolicy;
80 /**
81 * JWT / JSON token configurations used to authorize queries.
82 */
83 userTokenConfigurations?: kendra.UserTokenConfiguration[];
84 /**
85 * Fetch user-group information from IAM Identity Center.
86 */
87 userGroupResolutionConfiguration?: kendra.UserGroupResolutionConfiguration;
88 /**
89 * Extra provisioned storage and query capacity (only meaningful for
90 * `ENTERPRISE_EDITION` indexes).
91 */
92 capacityUnits?: IndexCapacityUnits;
93 /**
94 * Tags to associate with the index.
95 */
96 tags?: Record<string, string>;
97}
98
99export interface Index extends Resource<
100 "AWS.Kendra.Index",
101 IndexProps,
102 {
103 /**
104 * Service-assigned unique identifier of the index.
105 */
106 id: string;
107 /**
108 * ARN of the index.
109 */
110 arn: string;
111 /**
112 * The index's name.
113 */
114 name: string;
115 /**
116 * The provisioned edition.
117 */
118 edition: IndexEdition | undefined;
119 /**
120 * Current lifecycle status of the index.
121 */
122 status: IndexStatus | undefined;
123 /**
124 * ARN of the IAM role used for CloudWatch logs/metrics.
125 */
126 roleArn: string;
127 /**
128 * Current tags reported for the index.
129 */
130 tags: Record<string, string>;
131 },
132 never,
133 Providers
134> {}
135
136/**
137 * An Amazon Kendra index — a machine-learning powered enterprise search
138 * index that data sources (S3, SharePoint, databases, ...) sync documents
139 * into and that applications query with natural language.
140 *
141 * :::caution
142 * Provisioning an index takes ~20–30 minutes and bills hourly from the
143 * moment the index becomes `ACTIVE` (including the Developer edition's
144 * free-tier-exhausted rate). Destroy indexes promptly.
145 * :::
146 * ### Creating Indexes
147 * **Example:** Developer-Edition Index
148 * ```typescript
149 * import * as AWS from "alchemy/AWS";
150 *
151 * const role = yield* AWS.IAM.Role("KendraRole", {
152 * assumeRolePolicy: {
153 * Version: "2012-10-17",
154 * Statement: [{
155 * Effect: "Allow",
156 * Principal: { Service: "kendra.amazonaws.com" },
157 * Action: "sts:AssumeRole",
158 * }],
159 * },
160 * policies: [{
161 * policyName: "logs",
162 * policyDocument: {
163 * Version: "2012-10-17",
164 * Statement: [{
165 * Effect: "Allow",
166 * Action: ["logs:*", "cloudwatch:PutMetricData"],
167 * Resource: "*",
168 * }],
169 * },
170 * }],
171 * });
172 *
173 * const index = yield* AWS.Kendra.Index("Search", {
174 * edition: "DEVELOPER_EDITION",
175 * roleArn: role.roleArn,
176 * });
177 * ```
178 *
179 * @resource
180 */
181export const Index = Resource<Index>("AWS.Kendra.Index");
182
183const createIndexName = (id: string, props: { name?: string | undefined }) =>
184 props.name
185 ? Effect.succeed(props.name)
186 : createPhysicalName({ id, maxLength: 100 });
187
188const fetchTags = Effect.fn(function* (arn: string) {
189 const response = yield* kendra
190 .listTagsForResource({ ResourceARN: arn })
191 .pipe(
192 Effect.catchTag(
193 ["ResourceNotFoundException", "ResourceUnavailableException"],
194 () => Effect.succeed(undefined),
195 ),
196 );
197 return Object.fromEntries(
198 (response?.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
199 );
200});
201
202interface IndexState {
203 attrs: Index["Attributes"];
204 described: kendra.DescribeIndexResponse;
205}
206
207const readIndexById = Effect.fn(function* (
208 id: string,
209 arnOf: (indexId: string) => string,
210) {
211 const described = yield* kendra
212 .describeIndex({ Id: id })
213 .pipe(
214 Effect.catchTag("ResourceNotFoundException", () =>
215 Effect.succeed(undefined),
216 ),
217 );
218 if (!described || described.Status === "DELETING") return undefined;
219 const arn = arnOf(described.Id ?? id);
220 const state: IndexState = {
221 described,
222 attrs: {
223 id: described.Id ?? id,
224 arn,
225 name: described.Name ?? "",
226 edition: described.Edition,
227 status: described.Status,
228 roleArn: described.RoleArn ?? "",
229 tags: yield* fetchTags(arn),
230 },
231 };
232 return state;
233});
234
235const findIndexByName = Effect.fn(function* (
236 name: string,
237 arnOf: (indexId: string) => string,
238) {
239 const summaries = yield* kendra.listIndices.pages({}).pipe(
240 EffectStream.runCollect,
241 Effect.map((chunk) =>
242 Array.from(chunk).flatMap(
243 (page) => page.IndexConfigurationSummaryItems ?? [],
244 ),
245 ),
246 );
247 const match = summaries.find(
248 (summary) => summary.Name === name && summary.Status !== "DELETING",
249 );
250 if (!match?.Id) return undefined;
251 return yield* readIndexById(match.Id, arnOf);
252});
253
254/**
255 * An index still transitioning toward the awaited status — retried by
256 * {@link waitForIndexStatus}'s bounded schedule.
257 */
258class IndexNotReady extends Data.TaggedError("IndexNotReady")<{
259 readonly id: string;
260 readonly status: string | undefined;
261}> {}
262
263/**
264 * An index whose asynchronous provisioning converged to the terminal
265 * `FAILED` status.
266 */
267export class IndexProvisioningFailed extends Data.TaggedError(
268 "IndexProvisioningFailed",
269)<{
270 readonly id: string;
271 readonly message: string | undefined;
272}> {}
273
274// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
275// lifecycle code leaks `Retry.Return`'s conditional type into declaration
276// emit and widens the provider layer to `unknown` for every consumer of
277// `AWS.providers()`.
278const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
279 self: Effect.Effect<A, E, R>,
280): Effect.Effect<A, E, R> =>
281 Effect.retry(self, {
282 while: (e) => e._tag === "IndexNotReady",
283 // Index provisioning is slow (~20–30 min); poll every 20s up to ~40 min.
284 schedule: Schedule.max([
285 Schedule.spaced("20 seconds"),
286 Schedule.recurs(120),
287 ]),
288 });
289
290// CreateIndex validates the IAM role up front; a freshly-created role may
291// not have propagated yet and surfaces as ValidationException. Bounded retry
292// through the propagation window (~60s). Explicitly typed for the same
293// declaration-emit reason as retryWhileNotReady.
294const retryThroughIamPropagation = <A, E extends { readonly _tag: string }, R>(
295 self: Effect.Effect<A, E, R>,
296): Effect.Effect<A, E, R> =>
297 Effect.retry(self, {
298 while: (e) => e._tag === "ValidationException",
299 schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(12)]),
300 });
301
302const waitForIndexStatus = (id: string, target: "ACTIVE" | "DELETED") =>
303 retryWhileNotReady(
304 Effect.gen(function* () {
305 const described = yield* kendra
306 .describeIndex({ Id: id })
307 .pipe(
308 Effect.catchTag("ResourceNotFoundException", () =>
309 Effect.succeed(undefined),
310 ),
311 );
312 if (target === "DELETED") {
313 if (described === undefined) return;
314 return yield* Effect.fail(
315 new IndexNotReady({ id, status: described.Status }),
316 );
317 }
318 if (described?.Status === "ACTIVE") return;
319 if (described?.Status === "FAILED") {
320 return yield* Effect.fail(
321 new IndexProvisioningFailed({ id, message: described.ErrorMessage }),
322 );
323 }
324 return yield* Effect.fail(
325 new IndexNotReady({ id, status: described?.Status }),
326 );
327 }),
328 );
329
330const currentArnOf = Effect.gen(function* () {
331 const { accountId, region } = yield* AWSEnvironment.current;
332 return (indexId: string) =>
333 `arn:aws:kendra:${region}:${accountId}:index/${indexId}`;
334});
335
336export const IndexProvider = () =>
337 Provider.effect(
338 Index,
339 Effect.gen(function* () {
340 return {
341 stables: ["id", "arn"],
342 list: () =>
343 Effect.gen(function* () {
344 const arnOf = yield* currentArnOf;
345 const summaries = yield* kendra.listIndices.pages({}).pipe(
346 EffectStream.runCollect,
347 Effect.map((chunk) =>
348 Array.from(chunk).flatMap(
349 (page) => page.IndexConfigurationSummaryItems ?? [],
350 ),
351 ),
352 );
353 const hydrated = yield* Effect.forEach(
354 summaries.flatMap((s) => (s.Id ? [s.Id] : [])),
355 (indexId) => readIndexById(indexId, arnOf),
356 { concurrency: 5 },
357 );
358 return hydrated.flatMap((state) =>
359 state === undefined ? [] : [state.attrs],
360 );
361 }),
362 read: Effect.fn(function* ({ id, olds, output }) {
363 const arnOf = yield* currentArnOf;
364 const state = output?.id
365 ? yield* readIndexById(output.id, arnOf)
366 : yield* findIndexByName(
367 yield* createIndexName(id, olds ?? {}),
368 arnOf,
369 );
370 if (!state) return undefined;
371 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
372 ? state.attrs
373 : Unowned(state.attrs);
374 }),
375 diff: Effect.fn(function* ({ news, olds }) {
376 if (!isResolved(news)) return;
377 if (olds === undefined) return;
378 // Edition and encryption-at-rest are fixed at creation.
379 if (
380 (olds.edition ?? "DEVELOPER_EDITION") !==
381 (news.edition ?? "DEVELOPER_EDITION") ||
382 olds.serverSideEncryption?.kmsKeyId !==
383 news.serverSideEncryption?.kmsKeyId
384 ) {
385 return { action: "replace" } as const;
386 }
387 }),
388 reconcile: Effect.fn(function* ({ id, news, output, session }) {
389 if (!news) {
390 return yield* Effect.fail(new Error("Kendra Index requires props"));
391 }
392 const arnOf = yield* currentArnOf;
393 const name = yield* createIndexName(id, news);
394 const internalTags = yield* createInternalTags(id);
395 const desiredTags = { ...internalTags, ...news.tags };
396
397 // Observe — prefer the cached id; fall back to a name lookup so a
398 // create whose state failed to persist is adopted, not duplicated.
399 let state = output?.id
400 ? yield* readIndexById(output.id, arnOf)
401 : yield* findIndexByName(name, arnOf);
402
403 // Ensure — create if missing, then wait for ACTIVE.
404 if (state === undefined) {
405 const created = yield* retryThroughIamPropagation(
406 kendra.createIndex({
407 Name: name,
408 Edition: news.edition ?? "DEVELOPER_EDITION",
409 RoleArn: news.roleArn,
410 Description: news.description,
411 ServerSideEncryptionConfiguration: news.serverSideEncryption
412 ? { KmsKeyId: news.serverSideEncryption.kmsKeyId }
413 : undefined,
414 UserContextPolicy: news.userContextPolicy,
415 UserTokenConfigurations: news.userTokenConfigurations,
416 UserGroupResolutionConfiguration:
417 news.userGroupResolutionConfiguration,
418 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
419 Key,
420 Value,
421 })),
422 }),
423 );
424 if (!created.Id) {
425 return yield* Effect.fail(
426 new Error(`CreateIndex for '${name}' returned no Id`),
427 );
428 }
429 yield* session.note(`Creating index ${name} (${created.Id})...`);
430 yield* waitForIndexStatus(created.Id, "ACTIVE");
431 state = yield* readIndexById(created.Id, arnOf);
432 if (state === undefined) {
433 return yield* Effect.fail(
434 new Error(`failed to read created index ${name}`),
435 );
436 }
437 }
438
439 // Sync mutable settings via UpdateIndex — only send drifted fields.
440 const described = state.described;
441 const desiredCapacity = news.capacityUnits
442 ? {
443 StorageCapacityUnits: news.capacityUnits.storageCapacityUnits,
444 QueryCapacityUnits: news.capacityUnits.queryCapacityUnits,
445 }
446 : undefined;
447 const capacityDrifted =
448 desiredCapacity !== undefined &&
449 (desiredCapacity.StorageCapacityUnits !==
450 described.CapacityUnits?.StorageCapacityUnits ||
451 desiredCapacity.QueryCapacityUnits !==
452 described.CapacityUnits?.QueryCapacityUnits);
453 const needsUpdate =
454 name !== described.Name ||
455 news.roleArn !== described.RoleArn ||
456 (news.description ?? "") !== (described.Description ?? "") ||
457 (news.userContextPolicy !== undefined &&
458 news.userContextPolicy !== described.UserContextPolicy) ||
459 (news.userGroupResolutionConfiguration !== undefined &&
460 news.userGroupResolutionConfiguration.UserGroupResolutionMode !==
461 described.UserGroupResolutionConfiguration
462 ?.UserGroupResolutionMode) ||
463 news.userTokenConfigurations !== undefined ||
464 capacityDrifted;
465 if (needsUpdate) {
466 yield* kendra.updateIndex({
467 Id: state.attrs.id,
468 Name: name,
469 RoleArn: news.roleArn,
470 Description: news.description,
471 UserContextPolicy: news.userContextPolicy,
472 UserTokenConfigurations: news.userTokenConfigurations,
473 UserGroupResolutionConfiguration:
474 news.userGroupResolutionConfiguration,
475 CapacityUnits: desiredCapacity,
476 });
477 yield* waitForIndexStatus(state.attrs.id, "ACTIVE");
478 yield* session.note(`Updated index ${name}`);
479 }
480
481 // Sync tags — diff against observed cloud tags.
482 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
483 if (removed.length > 0) {
484 yield* kendra.untagResource({
485 ResourceARN: state.attrs.arn,
486 TagKeys: removed,
487 });
488 }
489 if (upsert.length > 0) {
490 yield* kendra.tagResource({
491 ResourceARN: state.attrs.arn,
492 Tags: upsert.map(({ Key, Value }) => ({ Key, Value })),
493 });
494 }
495
496 yield* session.note(state.attrs.arn);
497
498 const final = yield* readIndexById(state.attrs.id, arnOf);
499 if (!final) {
500 return yield* Effect.fail(
501 new Error(`failed to read reconciled index ${name}`),
502 );
503 }
504 return final.attrs;
505 }),
506 delete: Effect.fn(function* ({ output }) {
507 yield* kendra
508 .deleteIndex({ Id: output.id })
509 .pipe(
510 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
511 );
512 yield* waitForIndexStatus(output.id, "DELETED");
513 }),
514 };
515 }),
516 );