1import * as qbusiness from "@distilled.cloud/aws/qbusiness";
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 type { Providers } from "../Providers.ts";
18
19export type ApplicationStatus = qbusiness.ApplicationStatus;
20export type IdentityType = qbusiness.IdentityType;
21
22/**
23 * Encryption-at-rest configuration for an Amazon Q Business application.
24 */
25export interface ApplicationEncryptionConfiguration {
26 /**
27 * The identifier of the customer-managed KMS key. Amazon Q Business does
28 * not support asymmetric keys.
29 */
30 kmsKeyId?: string;
31}
32
33export interface ApplicationProps {
34 /**
35 * Display name of the application.
36 * @default ${app}-${stage}-${id}
37 */
38 displayName?: string;
39 /**
40 * The authentication type the application connects users with.
41 * `AWS_IAM_IDC` (IAM Identity Center) is the default and requires
42 * `identityCenterInstanceArn`. Changing it replaces the application.
43 * @default "AWS_IAM_IDC"
44 */
45 identityType?: IdentityType;
46 /**
47 * ARN of the IAM Identity Center instance the application connects to.
48 * Required when `identityType` is `AWS_IAM_IDC` (the default).
49 */
50 identityCenterInstanceArn?: string;
51 /**
52 * ARN of an IAM identity provider (SAML/OIDC) when `identityType` is
53 * `AWS_IAM_IDP_SAML` or `AWS_IAM_IDP_OIDC`. Changing it replaces the
54 * application.
55 */
56 iamIdentityProviderArn?: string;
57 /**
58 * OIDC client IDs when `identityType` is `AWS_IAM_IDP_OIDC`. Changing
59 * them replaces the application.
60 */
61 clientIdsForOIDC?: string[];
62 /**
63 * ARN of the IAM role Amazon Q Business assumes to publish CloudWatch
64 * logs and metrics.
65 */
66 roleArn?: string;
67 /**
68 * A description of the application.
69 */
70 description?: string;
71 /**
72 * Encryption-at-rest configuration. Changing it replaces the application.
73 */
74 encryptionConfiguration?: ApplicationEncryptionConfiguration;
75 /**
76 * Whether end users can upload files directly into chat conversations.
77 */
78 attachmentsConfiguration?: qbusiness.AttachmentsConfiguration;
79 /**
80 * Whether end users can create and use Amazon Q Apps.
81 */
82 qAppsConfiguration?: qbusiness.QAppsConfiguration;
83 /**
84 * Whether Amazon Q Business personalizes chat responses using details
85 * from the user's IAM Identity Center profile.
86 */
87 personalizationConfiguration?: qbusiness.PersonalizationConfiguration;
88 /**
89 * Automatic-subscription settings for users of the application.
90 */
91 autoSubscriptionConfiguration?: qbusiness.AutoSubscriptionConfiguration;
92 /**
93 * Amazon QuickSight identity configuration, when `identityType` is
94 * `AWS_QUICKSIGHT_IDP`. Changing it replaces the application.
95 */
96 quickSightConfiguration?: qbusiness.QuickSightConfiguration;
97 /**
98 * Tags to associate with the application.
99 */
100 tags?: Record<string, string>;
101}
102
103export interface Application extends Resource<
104 "AWS.QBusiness.Application",
105 ApplicationProps,
106 {
107 /**
108 * Service-assigned unique identifier of the application.
109 */
110 applicationId: string;
111 /**
112 * ARN of the application.
113 */
114 applicationArn: string;
115 /**
116 * The application's display name.
117 */
118 displayName: string;
119 /**
120 * Current lifecycle status of the application.
121 */
122 status: ApplicationStatus | undefined;
123 /**
124 * The authentication type the application was created with.
125 */
126 identityType: IdentityType | undefined;
127 /**
128 * ARN of the IAM Identity Center application created for this Amazon Q
129 * Business application (when `identityType` is `AWS_IAM_IDC`).
130 */
131 identityCenterApplicationArn: string | undefined;
132 /**
133 * ARN of the IAM role used for CloudWatch logs/metrics.
134 */
135 roleArn: string | undefined;
136 /**
137 * Current tags reported for the application.
138 */
139 tags: Record<string, string>;
140 },
141 never,
142 Providers
143> {}
144
145/**
146 * An Amazon Q Business application — the top-level container that indices,
147 * retrievers, data sources, and web experiences attach to.
148 *
149 * :::caution
150 * Creating an application with the default `AWS_IAM_IDC` identity type
151 * requires an IAM Identity Center instance in the account (pass its ARN as
152 * `identityCenterInstanceArn`).
153 * :::
154 * ### Creating Applications
155 * **Example:** Identity Center Application
156 * ```typescript
157 * import * as AWS from "alchemy/AWS";
158 *
159 * const app = yield* AWS.QBusiness.Application("Assistant", {
160 * identityCenterInstanceArn: "arn:aws:sso:::instance/ssoins-1234567890abcdef",
161 * description: "Company knowledge assistant",
162 * });
163 * ```
164 *
165 * **Example:** Anonymous Application
166 * ```typescript
167 * const app = yield* AWS.QBusiness.Application("PublicAssistant", {
168 * identityType: "ANONYMOUS",
169 * });
170 * ```
171 *
172 * @resource
173 */
174export const Application = Resource<Application>("AWS.QBusiness.Application");
175
176const createDisplayName = (
177 id: string,
178 props: { displayName?: string | undefined },
179) =>
180 props.displayName
181 ? Effect.succeed(props.displayName)
182 : createPhysicalName({ id, maxLength: 100 });
183
184const fetchTags = Effect.fn(function* (arn: string) {
185 const response = yield* qbusiness
186 .listTagsForResource({ resourceARN: arn })
187 .pipe(
188 Effect.catchTag("ResourceNotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192 return Object.fromEntries(
193 (response?.tags ?? []).map((tag) => [tag.key, tag.value]),
194 );
195});
196
197interface ApplicationState {
198 attrs: Application["Attributes"];
199 described: qbusiness.GetApplicationResponse;
200}
201
202const readApplicationById = Effect.fn(function* (applicationId: string) {
203 const described = yield* qbusiness
204 .getApplication({ applicationId })
205 .pipe(
206 Effect.catchTag("ResourceNotFoundException", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210 if (!described || described.status === "DELETING") return undefined;
211 const arn = described.applicationArn;
212 if (arn === undefined) return undefined;
213 const state: ApplicationState = {
214 described,
215 attrs: {
216 applicationId: described.applicationId ?? applicationId,
217 applicationArn: arn,
218 displayName: described.displayName ?? "",
219 status: described.status,
220 identityType: described.identityType,
221 identityCenterApplicationArn: described.identityCenterApplicationArn,
222 roleArn: described.roleArn,
223 tags: yield* fetchTags(arn),
224 },
225 };
226 return state;
227});
228
229const findApplicationByName = Effect.fn(function* (displayName: string) {
230 const summaries = yield* qbusiness.listApplications.pages({}).pipe(
231 EffectStream.runCollect,
232 Effect.map((chunk) =>
233 Array.from(chunk).flatMap((page) => page.applications ?? []),
234 ),
235 );
236 const match = summaries.find(
237 (summary) =>
238 summary.displayName === displayName && summary.status !== "DELETING",
239 );
240 if (!match?.applicationId) return undefined;
241 return yield* readApplicationById(match.applicationId);
242});
243
244/**
245 * An application still transitioning toward the awaited status — retried by
246 * {@link waitForApplicationStatus}'s bounded schedule.
247 */
248class ApplicationNotReady extends Data.TaggedError("ApplicationNotReady")<{
249 readonly applicationId: string;
250 readonly status: string | undefined;
251}> {}
252
253/**
254 * An application whose asynchronous provisioning converged to the terminal
255 * `FAILED` status.
256 */
257export class ApplicationProvisioningFailed extends Data.TaggedError(
258 "ApplicationProvisioningFailed",
259)<{
260 readonly applicationId: string;
261 readonly message: string | undefined;
262}> {}
263
264// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
265// lifecycle code leaks `Retry.Return`'s conditional type into declaration
266// emit and widens the provider layer to `unknown` for every consumer of
267// `AWS.providers()`.
268const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
269 self: Effect.Effect<A, E, R>,
270): Effect.Effect<A, E, R> =>
271 Effect.retry(self, {
272 while: (e) => e._tag === "ApplicationNotReady",
273 // Application provisioning normally completes within a couple minutes;
274 // poll every 5s up to ~10 min.
275 schedule: Schedule.max([
276 Schedule.spaced("5 seconds"),
277 Schedule.recurs(120),
278 ]),
279 });
280
281// Deleting an application while dependent resources are still tearing down
282// surfaces as ConflictException. Bounded retry through the window.
283const retryThroughConflict = <A, E extends { readonly _tag: string }, R>(
284 self: Effect.Effect<A, E, R>,
285): Effect.Effect<A, E, R> =>
286 Effect.retry(self, {
287 while: (e) => e._tag === "ConflictException",
288 schedule: Schedule.max([
289 Schedule.spaced("10 seconds"),
290 Schedule.recurs(10),
291 ]),
292 });
293
294const waitForApplicationStatus = (
295 applicationId: string,
296 target: "ACTIVE" | "DELETED",
297) =>
298 retryWhileNotReady(
299 Effect.gen(function* () {
300 const described = yield* qbusiness
301 .getApplication({ applicationId })
302 .pipe(
303 Effect.catchTag("ResourceNotFoundException", () =>
304 Effect.succeed(undefined),
305 ),
306 );
307 if (target === "DELETED") {
308 if (described === undefined) return;
309 return yield* Effect.fail(
310 new ApplicationNotReady({ applicationId, status: described.status }),
311 );
312 }
313 if (described?.status === "ACTIVE") return;
314 if (described?.status === "FAILED") {
315 return yield* Effect.fail(
316 new ApplicationProvisioningFailed({
317 applicationId,
318 message: described.error?.errorMessage,
319 }),
320 );
321 }
322 return yield* Effect.fail(
323 new ApplicationNotReady({ applicationId, status: described?.status }),
324 );
325 }),
326 );
327
328export const ApplicationProvider = () =>
329 Provider.effect(
330 Application,
331 Effect.gen(function* () {
332 return {
333 stables: ["applicationId", "applicationArn"],
334 list: () =>
335 Effect.gen(function* () {
336 const summaries = yield* qbusiness.listApplications.pages({}).pipe(
337 EffectStream.runCollect,
338 Effect.map((chunk) =>
339 Array.from(chunk).flatMap((page) => page.applications ?? []),
340 ),
341 );
342 const hydrated = yield* Effect.forEach(
343 summaries.flatMap((s) =>
344 s.applicationId ? [s.applicationId] : [],
345 ),
346 (applicationId) => readApplicationById(applicationId),
347 { concurrency: 5 },
348 );
349 return hydrated.flatMap((state) =>
350 state === undefined ? [] : [state.attrs],
351 );
352 }),
353 read: Effect.fn(function* ({ id, olds, output }) {
354 const state = output?.applicationId
355 ? yield* readApplicationById(output.applicationId)
356 : yield* findApplicationByName(
357 yield* createDisplayName(id, olds ?? {}),
358 );
359 if (!state) return undefined;
360 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
361 ? state.attrs
362 : Unowned(state.attrs);
363 }),
364 diff: Effect.fn(function* ({ news, olds }) {
365 if (!isResolved(news)) return;
366 if (olds === undefined) return;
367 // Identity wiring and encryption-at-rest are fixed at creation.
368 if (
369 (olds.identityType ?? "AWS_IAM_IDC") !==
370 (news.identityType ?? "AWS_IAM_IDC") ||
371 olds.iamIdentityProviderArn !== news.iamIdentityProviderArn ||
372 olds.encryptionConfiguration?.kmsKeyId !==
373 news.encryptionConfiguration?.kmsKeyId ||
374 olds.quickSightConfiguration?.clientNamespace !==
375 news.quickSightConfiguration?.clientNamespace ||
376 JSON.stringify(olds.clientIdsForOIDC ?? []) !==
377 JSON.stringify(news.clientIdsForOIDC ?? [])
378 ) {
379 return { action: "replace" } as const;
380 }
381 }),
382 reconcile: Effect.fn(function* ({ id, news, output, session }) {
383 if (!news) {
384 return yield* Effect.fail(
385 new Error("QBusiness Application requires props"),
386 );
387 }
388 const displayName = yield* createDisplayName(id, news);
389 const internalTags = yield* createInternalTags(id);
390 const desiredTags = { ...internalTags, ...news.tags };
391
392 // Observe — prefer the cached id; fall back to a name lookup so a
393 // create whose state failed to persist is adopted, not duplicated.
394 let state = output?.applicationId
395 ? yield* readApplicationById(output.applicationId)
396 : yield* findApplicationByName(displayName);
397
398 // Ensure — create if missing, then wait for ACTIVE.
399 if (state === undefined) {
400 const created = yield* qbusiness.createApplication({
401 displayName,
402 identityType: news.identityType,
403 identityCenterInstanceArn: news.identityCenterInstanceArn,
404 iamIdentityProviderArn: news.iamIdentityProviderArn,
405 clientIdsForOIDC: news.clientIdsForOIDC,
406 roleArn: news.roleArn,
407 description: news.description,
408 encryptionConfiguration: news.encryptionConfiguration,
409 attachmentsConfiguration: news.attachmentsConfiguration,
410 qAppsConfiguration: news.qAppsConfiguration,
411 personalizationConfiguration: news.personalizationConfiguration,
412 quickSightConfiguration: news.quickSightConfiguration,
413 tags: Object.entries(desiredTags).map(([key, value]) => ({
414 key,
415 value,
416 })),
417 });
418 if (!created.applicationId) {
419 return yield* Effect.fail(
420 new Error(
421 `CreateApplication for '${displayName}' returned no applicationId`,
422 ),
423 );
424 }
425 yield* session.note(
426 `Creating application ${displayName} (${created.applicationId})...`,
427 );
428 yield* waitForApplicationStatus(created.applicationId, "ACTIVE");
429 state = yield* readApplicationById(created.applicationId);
430 if (state === undefined) {
431 return yield* Effect.fail(
432 new Error(`failed to read created application ${displayName}`),
433 );
434 }
435 }
436
437 // Sync mutable settings via UpdateApplication — only when drifted.
438 const described = state.described;
439 const needsUpdate =
440 displayName !== described.displayName ||
441 (news.description ?? "") !== (described.description ?? "") ||
442 (news.roleArn !== undefined &&
443 news.roleArn !== described.roleArn) ||
444 (news.attachmentsConfiguration !== undefined &&
445 news.attachmentsConfiguration.attachmentsControlMode !==
446 described.attachmentsConfiguration?.attachmentsControlMode) ||
447 (news.qAppsConfiguration !== undefined &&
448 news.qAppsConfiguration.qAppsControlMode !==
449 described.qAppsConfiguration?.qAppsControlMode) ||
450 (news.personalizationConfiguration !== undefined &&
451 news.personalizationConfiguration.personalizationControlMode !==
452 described.personalizationConfiguration
453 ?.personalizationControlMode) ||
454 (news.autoSubscriptionConfiguration !== undefined &&
455 (news.autoSubscriptionConfiguration.autoSubscribe !==
456 described.autoSubscriptionConfiguration?.autoSubscribe ||
457 news.autoSubscriptionConfiguration.defaultSubscriptionType !==
458 described.autoSubscriptionConfiguration
459 ?.defaultSubscriptionType));
460 if (needsUpdate) {
461 yield* qbusiness.updateApplication({
462 applicationId: state.attrs.applicationId,
463 displayName,
464 description: news.description,
465 roleArn: news.roleArn,
466 identityCenterInstanceArn: news.identityCenterInstanceArn,
467 attachmentsConfiguration: news.attachmentsConfiguration,
468 qAppsConfiguration: news.qAppsConfiguration,
469 personalizationConfiguration: news.personalizationConfiguration,
470 autoSubscriptionConfiguration: news.autoSubscriptionConfiguration,
471 });
472 yield* waitForApplicationStatus(
473 state.attrs.applicationId,
474 "ACTIVE",
475 );
476 yield* session.note(`Updated application ${displayName}`);
477 }
478
479 // Sync tags — diff against observed cloud tags.
480 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
481 if (removed.length > 0) {
482 yield* qbusiness.untagResource({
483 resourceARN: state.attrs.applicationArn,
484 tagKeys: removed,
485 });
486 }
487 if (upsert.length > 0) {
488 yield* qbusiness.tagResource({
489 resourceARN: state.attrs.applicationArn,
490 tags: upsert.map(({ Key, Value }) => ({
491 key: Key,
492 value: Value,
493 })),
494 });
495 }
496
497 yield* session.note(state.attrs.applicationArn);
498
499 const final = yield* readApplicationById(state.attrs.applicationId);
500 if (!final) {
501 return yield* Effect.fail(
502 new Error(`failed to read reconciled application ${displayName}`),
503 );
504 }
505 return final.attrs;
506 }),
507 delete: Effect.fn(function* ({ output }) {
508 yield* retryThroughConflict(
509 qbusiness
510 .deleteApplication({ applicationId: output.applicationId })
511 .pipe(
512 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
513 ),
514 );
515 yield* waitForApplicationStatus(output.applicationId, "DELETED");
516 }),
517 };
518 }),
519 );