1import * as AWS from "@/AWS";
2import {
3 AlertManagerDefinition,
4 RuleGroupsNamespace,
5 Workspace,
6} from "@/AWS/AMP";
7import * as Test from "@/Test/Alchemy";
8import * as amp from "@distilled.cloud/aws/amp";
9import { expect } from "alchemy-test";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16const RULES_V1 = `groups:
17 - name: example
18 rules:
19 - record: metric:requests:rate5m
20 expr: rate(http_requests_total[5m])`;
21
22const RULES_V2 = `groups:
23 - name: example
24 rules:
25 - record: metric:requests:rate10m
26 expr: rate(http_requests_total[10m])`;
27
28const ALERTS = `alertmanager_config: |
29 route:
30 receiver: default
31 receivers:
32 - name: default`;
33
34const decode = (data: Uint8Array) => new TextDecoder().decode(data);
35
36class WorkspaceStillExists extends Data.TaggedError("WorkspaceStillExists")<{
37 readonly workspaceId: string;
38}> {}
39
40const assertWorkspaceDeleted = (workspaceId: string) =>
41 amp.describeWorkspace({ workspaceId }).pipe(
42 Effect.flatMap((r) =>
43 r.workspace.status.statusCode === "DELETING"
44 ? Effect.fail(new WorkspaceStillExists({ workspaceId }))
45 : Effect.succeed(undefined),
46 ),
47 Effect.catchTag("ResourceNotFoundException", () =>
48 Effect.succeed(undefined),
49 ),
50 Effect.retry({
51 while: (e) => e._tag === "WorkspaceStillExists",
52 schedule: Schedule.max([
53 Schedule.spaced("3 seconds"),
54 Schedule.recurs(20),
55 ]),
56 }),
57 );
58
59// Ungated typed-error probe: proves the distilled error union carries the
60// not-found tag the read/delete paths depend on.
61test.provider(
62 "describeWorkspace on a nonexistent id fails with ResourceNotFoundException",
63 () =>
64 Effect.gen(function* () {
65 const error = yield* Effect.flip(
66 amp.describeWorkspace({
67 workspaceId: "ws-00000000-0000-0000-0000-000000000000",
68 }),
69 );
70 expect(error._tag).toBe("ResourceNotFoundException");
71 }),
72 { tags: ["provider:aws", "provider:aws:amp", "live"] },
73);
74
75// AMP workspaces + rule/alert sub-resources are cheap and quick to
76// provision, so the full lifecycle runs ungated.
77test.provider(
78 "create, update, and destroy a workspace with rules and alerts",
79 (stack) =>
80 Effect.gen(function* () {
81 yield* stack.destroy();
82
83 // Create the workspace + a rule groups namespace + alert manager def.
84 const created = yield* stack.deploy(
85 Effect.gen(function* () {
86 const workspace = yield* Workspace("Metrics", {
87 alias: "alchemy-test-amp",
88 tags: { Environment: "test" },
89 });
90 const rules = yield* RuleGroupsNamespace("Rules", {
91 workspaceId: workspace.workspaceId,
92 name: "alchemy-test-rules",
93 definition: RULES_V1,
94 tags: { Environment: "test" },
95 });
96 const alerts = yield* AlertManagerDefinition("Alerts", {
97 workspaceId: workspace.workspaceId,
98 definition: ALERTS,
99 });
100 return { workspace, rules, alerts };
101 }),
102 );
103
104 const workspaceId = created.workspace.workspaceId;
105 expect(workspaceId).toMatch(/^ws-/);
106 expect(created.workspace.workspaceArn).toContain(":workspace/");
107 expect(created.workspace.status).toBe("ACTIVE");
108 expect(created.workspace.prometheusEndpoint).toContain("aps-workspaces");
109 expect(created.rules.ruleGroupsNamespaceArn).toContain(
110 ":rulegroupsnamespace/",
111 );
112 expect(created.alerts.workspaceId).toBe(workspaceId);
113
114 // Out-of-band verification via distilled.
115 const describedWs = yield* amp.describeWorkspace({ workspaceId });
116 expect(describedWs.workspace.alias).toBe("alchemy-test-amp");
117 expect(describedWs.workspace.tags?.["alchemy::id"]).toBe("Metrics");
118
119 const describedRules = yield* amp.describeRuleGroupsNamespace({
120 workspaceId,
121 name: "alchemy-test-rules",
122 });
123 expect(describedRules.ruleGroupsNamespace.data).toBeDefined();
124 expect(decode(describedRules.ruleGroupsNamespace.data!)).toBe(RULES_V1);
125 expect(describedRules.ruleGroupsNamespace.tags?.["alchemy::id"]).toBe(
126 "Rules",
127 );
128
129 // The workspace Alertmanager provisions asynchronously (a few
130 // minutes); the definition exists immediately but its `data` blob is
131 // only returned once ACTIVE.
132 const describedAlerts = yield* amp.describeAlertManagerDefinition({
133 workspaceId,
134 });
135 expect(
136 ["ACTIVE", "CREATING"].includes(
137 describedAlerts.alertManagerDefinition.status.statusCode,
138 ),
139 ).toBe(true);
140 if (describedAlerts.alertManagerDefinition.data !== undefined) {
141 expect(decode(describedAlerts.alertManagerDefinition.data)).toBe(
142 ALERTS,
143 );
144 }
145
146 // Update: change the alias and the rules definition in place.
147 const updated = yield* stack.deploy(
148 Effect.gen(function* () {
149 const workspace = yield* Workspace("Metrics", {
150 alias: "alchemy-test-amp-v2",
151 tags: { Environment: "test", Extra: "yes" },
152 });
153 const rules = yield* RuleGroupsNamespace("Rules", {
154 workspaceId: workspace.workspaceId,
155 name: "alchemy-test-rules",
156 definition: RULES_V2,
157 tags: { Environment: "test" },
158 });
159 const alerts = yield* AlertManagerDefinition("Alerts", {
160 workspaceId: workspace.workspaceId,
161 definition: ALERTS,
162 });
163 return { workspace, rules, alerts };
164 }),
165 );
166
167 // Stable identifiers survive the in-place update.
168 expect(updated.workspace.workspaceId).toBe(workspaceId);
169 expect(updated.workspace.workspaceArn).toBe(
170 created.workspace.workspaceArn,
171 );
172 expect(updated.rules.ruleGroupsNamespaceArn).toBe(
173 created.rules.ruleGroupsNamespaceArn,
174 );
175
176 const afterWs = yield* amp.describeWorkspace({ workspaceId });
177 expect(afterWs.workspace.alias).toBe("alchemy-test-amp-v2");
178 expect(afterWs.workspace.tags?.Extra).toBe("yes");
179
180 const afterRules = yield* amp.describeRuleGroupsNamespace({
181 workspaceId,
182 name: "alchemy-test-rules",
183 });
184 expect(afterRules.ruleGroupsNamespace.data).toBeDefined();
185 expect(decode(afterRules.ruleGroupsNamespace.data!)).toBe(RULES_V2);
186
187 yield* stack.destroy();
188 yield* assertWorkspaceDeleted(workspaceId);
189 }),
190 { tags: ["provider:aws", "provider:aws:amp", "live"], timeout: 240_000 },
191);