1import * as AWS from "@/AWS";
2import {
3 AnomalyDetector,
4 LoggingConfiguration,
5 QueryLoggingConfiguration,
6 ResourcePolicy,
7 Workspace,
8} from "@/AWS/AMP";
9import * as Logs from "@/AWS/Logs";
10import * as Output from "@/Output";
11import * as Test from "@/Test/Alchemy";
12import * as amp from "@distilled.cloud/aws/amp";
13import * as sts from "@distilled.cloud/aws/sts";
14import { expect } from "alchemy-test";
15import * as Data from "effect/Data";
16import type * as Duration from "effect/Duration";
17import * as Effect from "effect/Effect";
18import * as Schedule from "effect/Schedule";
19
20const { test } = Test.make({ providers: AWS.providers() });
21
22class WorkspaceStillExists extends Data.TaggedError("WorkspaceStillExists")<{
23 readonly workspaceId: string;
24}> {}
25
26/** Poll the workspace configuration until the retention lands (bounded). */
27const pollRetention = (workspaceId: string, expected: number) =>
28 amp.describeWorkspaceConfiguration({ workspaceId }).pipe(
29 Effect.map((r) => r.workspaceConfiguration.retentionPeriodInDays),
30 Effect.repeat({
31 schedule: Schedule.spaced("4 seconds"),
32 until: (days): boolean => days === expected,
33 times: 25,
34 }),
35 );
36
37const assertWorkspaceDeleted = (workspaceId: string) =>
38 amp.describeWorkspace({ workspaceId }).pipe(
39 Effect.flatMap((r) =>
40 r.workspace.status.statusCode === "DELETING"
41 ? Effect.fail(new WorkspaceStillExists({ workspaceId }))
42 : Effect.succeed(undefined),
43 ),
44 Effect.catchTag("ResourceNotFoundException", () =>
45 Effect.succeed(undefined),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "WorkspaceStillExists",
49 schedule: Schedule.max([
50 Schedule.spaced("3 seconds"),
51 Schedule.recurs(20),
52 ]),
53 }),
54 );
55
56// Full lifecycle for the workspace-configuration surface: retention period
57// (workspace configuration), rules/alerting logging, query logging, and the
58// workspace resource-based policy.
59test.provider(
60 "workspace configuration, logging, query logging, and resource policy",
61 (stack) =>
62 Effect.gen(function* () {
63 yield* stack.destroy();
64
65 const { Account } = yield* sts.getCallerIdentity({});
66 const policyFor = (workspaceArn: string, action: string) =>
67 JSON.stringify({
68 Version: "2012-10-17",
69 Statement: [
70 {
71 Effect: "Allow",
72 Principal: { AWS: `arn:aws:iam::${Account}:root` },
73 Action: [action],
74 Resource: workspaceArn,
75 },
76 ],
77 });
78
79 const program = (retention: Duration.Input, qspThreshold: number) =>
80 Effect.gen(function* () {
81 const workspace = yield* Workspace("Metrics", {
82 alias: "alchemy-test-amp-config",
83 retentionPeriod: retention,
84 tags: { Environment: "test" },
85 });
86 const ruleLogs = yield* Logs.LogGroup("RuleLogs", {
87 logGroupName: "/aws/vendedlogs/prometheus/alchemy-test-amp-config",
88 retention: "1 day",
89 });
90 const queryLogs = yield* Logs.LogGroup("QueryLogs", {
91 logGroupName:
92 "/aws/vendedlogs/prometheus/alchemy-test-amp-config-queries",
93 retention: "1 day",
94 });
95 const logging = yield* LoggingConfiguration("Logging", {
96 workspaceId: workspace.workspaceId,
97 logGroupArn: ruleLogs.logGroupArn,
98 });
99 const queryLogging = yield* QueryLoggingConfiguration(
100 "QueryLogging",
101 {
102 workspaceId: workspace.workspaceId,
103 destinations: [
104 { logGroupArn: queryLogs.logGroupArn, qspThreshold },
105 ],
106 },
107 );
108 const policy = yield* ResourcePolicy("Policy", {
109 workspaceId: workspace.workspaceId,
110 policyDocument: Output.map(workspace.workspaceArn, (arn) =>
111 policyFor(arn, "aps:QueryMetrics"),
112 ),
113 });
114 return { workspace, ruleLogs, logging, queryLogging, policy };
115 });
116
117 // Create.
118 const created = yield* stack.deploy(program("30 days", 0));
119 const workspaceId = created.workspace.workspaceId;
120 expect(workspaceId).toMatch(/^ws-/);
121 expect(created.logging.workspaceId).toBe(workspaceId);
122 expect(created.logging.logGroupArn).toContain(":log-group:");
123 expect(created.queryLogging.destinations).toHaveLength(1);
124 expect(created.queryLogging.destinations[0].qspThreshold).toBe(0);
125 expect(created.policy.policyStatus).toBeTruthy();
126 const firstRevision = created.policy.revisionId;
127
128 // Out-of-band verification via distilled. The retention update
129 // applies asynchronously — poll until it lands.
130 const retentionAfterCreate = yield* pollRetention(workspaceId, 30);
131 expect(retentionAfterCreate).toBe(30);
132
133 const logging = yield* amp.describeLoggingConfiguration({ workspaceId });
134 expect(logging.loggingConfiguration.logGroupArn).toContain(
135 "alchemy-test-amp-config",
136 );
137
138 const queryLogging = yield* amp.describeQueryLoggingConfiguration({
139 workspaceId,
140 });
141 expect(queryLogging.queryLoggingConfiguration.destinations).toHaveLength(
142 1,
143 );
144 expect(
145 queryLogging.queryLoggingConfiguration.destinations[0].filters
146 .qspThreshold,
147 ).toBe(0);
148
149 const policy = yield* amp.describeResourcePolicy({ workspaceId });
150 expect(policy.policyDocument).toContain("aps:QueryMetrics");
151
152 // Update: retention 45 days, qsp threshold 1000. The policy document
153 // is unchanged — its revision must be stable (no-op skipped the put).
154 const updated = yield* stack.deploy(program("45 days", 1000));
155 expect(updated.workspace.workspaceId).toBe(workspaceId);
156 expect(updated.policy.revisionId).toBe(firstRevision);
157 expect(updated.queryLogging.destinations[0].qspThreshold).toBe(1000);
158
159 const retentionAfterUpdate = yield* pollRetention(workspaceId, 45);
160 expect(retentionAfterUpdate).toBe(45);
161 const queryLoggingAfter = yield* amp.describeQueryLoggingConfiguration({
162 workspaceId,
163 });
164 expect(
165 queryLoggingAfter.queryLoggingConfiguration.destinations[0].filters
166 .qspThreshold,
167 ).toBe(1000);
168
169 yield* stack.destroy();
170 yield* assertWorkspaceDeleted(workspaceId);
171
172 // The sub-configurations died with the workspace; a fresh describe on
173 // the deleted workspace id is a typed not-found.
174 const error = yield* Effect.flip(
175 amp.describeLoggingConfiguration({ workspaceId }),
176 );
177 expect(error._tag).toBe("ResourceNotFoundException");
178 }),
179 {
180 tags: ["provider:aws", "provider:aws:amp", "provider:aws:logs", "live"],
181 timeout: 300_000,
182 },
183);
184
185// Anomaly detectors train asynchronously (backfill against workspace data),
186// so the lifecycle asserts identity + configuration and lets the provider's
187// bounded wait tolerate a still-training detector.
188test.provider(
189 "anomaly detector lifecycle",
190 (stack) =>
191 Effect.gen(function* () {
192 yield* stack.destroy();
193
194 const program = (interval: Duration.Input) =>
195 Effect.gen(function* () {
196 const workspace = yield* Workspace("DetectorWorkspace", {
197 alias: "alchemy-test-amp-anomaly",
198 tags: { Environment: "test" },
199 });
200 const detector = yield* AnomalyDetector("Detector", {
201 workspaceId: workspace.workspaceId,
202 alias: "alchemy-test-detector",
203 query: "avg(rate(alchemy_amp_anomaly_test_total[5m]))",
204 evaluationInterval: interval,
205 missingDataAction: { skip: true },
206 tags: { Environment: "test" },
207 });
208 return { workspace, detector };
209 });
210
211 const created = yield* stack.deploy(program("1 minute"));
212 const workspaceId = created.workspace.workspaceId;
213 const detectorId = created.detector.anomalyDetectorId;
214 expect(detectorId).toBeTruthy();
215 expect(created.detector.anomalyDetectorArn).toContain("arn:aws:aps:");
216 expect(created.detector.alias).toBe("alchemy-test-detector");
217
218 // Out-of-band verification via distilled.
219 const described = yield* amp.describeAnomalyDetector({
220 workspaceId,
221 anomalyDetectorId: detectorId,
222 });
223 expect(described.anomalyDetector.evaluationIntervalInSeconds).toBe(60);
224 expect(described.anomalyDetector.tags?.["alchemy::id"]).toBe("Detector");
225
226 // Update the evaluation interval in place (same id survives).
227 const updated = yield* stack.deploy(program("2 minutes"));
228 expect(updated.detector.anomalyDetectorId).toBe(detectorId);
229 const describedAfter = yield* amp.describeAnomalyDetector({
230 workspaceId,
231 anomalyDetectorId: detectorId,
232 });
233 expect(describedAfter.anomalyDetector.evaluationIntervalInSeconds).toBe(
234 120,
235 );
236
237 yield* stack.destroy();
238 yield* assertWorkspaceDeleted(workspaceId);
239 }),
240 { tags: ["provider:aws", "provider:aws:amp", "live"], timeout: 300_000 },
241);