1import * as AWS from "@/AWS";
2import {
3 Application,
4 AttributeGroup,
5 AttributeGroupAssociation,
6 ResourceAssociation,
7} from "@/AWS/AppRegistry";
8import { Stack } from "@/AWS/CloudFormation";
9import * as Test from "@/Test/Alchemy";
10import * as appregistry from "@distilled.cloud/aws/service-catalog-appregistry";
11import { expect } from "alchemy-test";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16import { makeAppRegistryTestLease } from "./TestLease.ts";
17
18const { test, beforeAll, afterAll } = Test.make({ providers: AWS.providers() });
19const serviceLease = makeAppRegistryTestLease();
20
21beforeAll(serviceLease.acquire, { timeout: 3_600_000 });
22afterAll(serviceLease.release);
23
24class ApplicationStillExists extends Data.TaggedError(
25 "ApplicationStillExists",
26)<{ specifier: string }> {}
27
28const assertApplicationGone = (specifier: string) =>
29 appregistry.getApplication({ application: specifier }).pipe(
30 Effect.flatMap(() =>
31 Effect.fail(new ApplicationStillExists({ specifier })),
32 ),
33 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
34 Effect.retry({
35 while: (e) => e._tag === "ApplicationStillExists",
36 schedule: Schedule.max([
37 Schedule.fixed("2 seconds"),
38 Schedule.recurs(10),
39 ]),
40 }),
41 );
42
43const listAssociatedGroupIds = (application: string) =>
44 appregistry.listAssociatedAttributeGroups.pages({ application }).pipe(
45 Stream.runCollect,
46 Effect.map((chunk) =>
47 Array.from(chunk).flatMap((page) => page.attributeGroups ?? []),
48 ),
49 );
50
51// A free, instantly-creatable no-op stack to associate with the application.
52const NOOP_TEMPLATE = JSON.stringify({
53 Resources: {
54 Noop: { Type: "AWS::CloudFormation::WaitConditionHandle" },
55 },
56});
57
58// AppRegistry is in maintenance mode (see Application.test.ts) — lifecycle
59// tests only run on accounts that retain access via AWS_TEST_APPREGISTRY=1.
60test.provider.skipIf(!process.env.AWS_TEST_APPREGISTRY)(
61 "associates an attribute group and a CloudFormation stack with an application",
62 (stack) =>
63 Effect.gen(function* () {
64 yield* stack.destroy();
65
66 const created = yield* stack.deploy(
67 Effect.gen(function* () {
68 const app = yield* Application("AssocApp", {
69 description: "association lifecycle test",
70 });
71 const group = yield* AttributeGroup("AssocGroup", {
72 attributes: { owner: "alchemy-test" },
73 });
74 const cfn = yield* Stack("AssocStack", {
75 templateBody: NOOP_TEMPLATE,
76 });
77 const groupAssociation = yield* AttributeGroupAssociation(
78 "GroupAssoc",
79 {
80 application: app.applicationId,
81 attributeGroup: group.attributeGroupId,
82 },
83 );
84 const resourceAssociation = yield* ResourceAssociation("StackAssoc", {
85 application: app.applicationId,
86 resourceType: "CFN_STACK",
87 resource: cfn.stackName,
88 // Skip the awsApplication tag so association does not round-trip
89 // a CloudFormation stack update.
90 options: ["SKIP_APPLICATION_TAG"],
91 });
92 return {
93 applicationId: app.applicationId,
94 attributeGroupId: group.attributeGroupId,
95 stackName: cfn.stackName,
96 associatedGroupId: groupAssociation.attributeGroupId,
97 associatedResourceArn: resourceAssociation.resourceArn,
98 };
99 }),
100 );
101
102 expect(created.associatedGroupId).toBe(created.attributeGroupId);
103 expect(created.associatedResourceArn).toContain(":cloudformation:");
104
105 // out-of-band verify via distilled
106 const groupIds = yield* listAssociatedGroupIds(created.applicationId);
107 expect(groupIds).toContain(created.attributeGroupId);
108 const associated = yield* appregistry.getAssociatedResource({
109 application: created.applicationId,
110 resourceType: "CFN_STACK",
111 resource: created.stackName,
112 });
113 expect(associated.resource?.name).toBe(created.stackName);
114
115 // idempotent re-deploy converges without error
116 yield* stack.deploy(
117 Effect.gen(function* () {
118 const app = yield* Application("AssocApp", {
119 description: "association lifecycle test",
120 });
121 const group = yield* AttributeGroup("AssocGroup", {
122 attributes: { owner: "alchemy-test" },
123 });
124 const cfn = yield* Stack("AssocStack", {
125 templateBody: NOOP_TEMPLATE,
126 });
127 yield* AttributeGroupAssociation("GroupAssoc", {
128 application: app.applicationId,
129 attributeGroup: group.attributeGroupId,
130 });
131 yield* ResourceAssociation("StackAssoc", {
132 application: app.applicationId,
133 resourceType: "CFN_STACK",
134 resource: cfn.stackName,
135 options: ["SKIP_APPLICATION_TAG"],
136 });
137 }),
138 );
139
140 // destroy disassociates before deleting the application (AppRegistry
141 // rejects deleting an application with live associations).
142 yield* stack.destroy();
143 yield* assertApplicationGone(created.applicationId);
144 }),
145 {
146 tags: [
147 "provider:aws",
148 "provider:aws:appregistry",
149 "provider:aws:cloudformation",
150 "live",
151 ],
152 timeout: 300_000,
153 },
154);