1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as amp from "@distilled.cloud/aws/amp";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import AmpTestFunctionLive, { AmpTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "AMPBindings");
16
17// Deterministic metric name — same on every run of this test file.
18const METRIC = "alchemy_amp_bindings_test_total";
19
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// Retry transient 5xx (cold re-init, IAM propagation on a fresh deploy —
33// aps permissions can take a few seconds to become effective).
34const send = (request: HttpClientRequest.HttpClientRequest) =>
35 HttpClient.execute(request).pipe(
36 Effect.flatMap((response) =>
37 response.status >= 500
38 ? response.text.pipe(
39 Effect.flatMap((body) =>
40 Effect.fail(
41 new TransientUpstream({ status: response.status, body }),
42 ),
43 ),
44 )
45 : Effect.succeed(response),
46 ),
47 Effect.retry({
48 while: (e) => e._tag === "TransientUpstream",
49 schedule: Schedule.max([
50 Schedule.exponential("1 second"),
51 Schedule.recurs(6),
52 ]),
53 }),
54 );
55
56const getJson = (path: string) =>
57 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
58 Effect.flatMap((r) => r.json),
59 );
60
61describe(
62 "AMP Bindings",
63 { tags: ["provider:aws", "provider:aws:amp", "provider:aws:lambda", "live"] },
64 () => {
65 beforeAll(
66 Effect.gen(function* () {
67 yield* Effect.logInfo("AMP test setup: destroying previous resources");
68 yield* sharedStack.destroy();
69
70 yield* Effect.logInfo("AMP test setup: deploying fixture");
71 const { functionUrl } = yield* sharedStack.deploy(
72 Effect.gen(function* () {
73 return yield* AmpTestFunction;
74 }).pipe(Effect.provide(AmpTestFunctionLive)),
75 );
76
77 expect(functionUrl).toBeTruthy();
78 baseUrl = functionUrl!.replace(/\/+$/, "");
79 const readinessUrl = `${baseUrl}/health`;
80
81 yield* Effect.logInfo(
82 `AMP test setup: probing readiness at ${readinessUrl}`,
83 );
84 yield* HttpClient.get(readinessUrl).pipe(
85 Effect.flatMap((response) =>
86 response.status === 200
87 ? Effect.succeed(response)
88 : Effect.fail(
89 new Error(`Function not ready: ${response.status}`),
90 ),
91 ),
92 Effect.tapError((error) =>
93 Effect.logWarning(
94 `AMP test setup: fixture not ready yet (${String(error)})`,
95 ),
96 ),
97 Effect.retry({ schedule: readinessPolicy }),
98 );
99 yield* Effect.logInfo("AMP test setup: fixture responded successfully");
100 }),
101 { timeout: 240_000 },
102 );
103
104 afterAll(
105 Effect.gen(function* () {
106 yield* sharedStack.destroy();
107 // Assert gone: no workspace with the fixture's alias survives the
108 // destroy (AMP deletes asynchronously — DELETING counts as gone; a
109 // fresh run's leading destroy would reconcile any remnant anyway).
110 // The out-of-band distilled call needs the AWS providers layer
111 // (credentials), which afterAll does not provide by default.
112 const { workspaces } = yield* Core.withProviders(
113 amp.listWorkspaces({ alias: "alchemy-test-amp-bindings" }),
114 testOptions,
115 "AMPBindings",
116 );
117 const alive = workspaces.filter(
118 (w) =>
119 w.status.statusCode !== "DELETING" &&
120 w.status.statusCode !== "DELETED",
121 );
122 expect(alive).toHaveLength(0);
123 }),
124 { timeout: 120_000 },
125 );
126
127 describe("RemoteWrite", () => {
128 test.provider("pushes a sample into the workspace", (_stack) =>
129 Effect.gen(function* () {
130 const body = (yield* send(
131 HttpClientRequest.bodyJsonUnsafe(
132 HttpClientRequest.post(`${baseUrl}/remote-write`),
133 { name: METRIC, labels: { source: "bindings-test" }, value: 1 },
134 ),
135 ).pipe(Effect.flatMap((r) => r.json))) as { success: boolean };
136 expect(body.success).toBe(true);
137 }),
138 );
139 });
140
141 describe("QueryMetrics", () => {
142 test.provider(
143 "instant query reads back the remote-written sample",
144 (_stack) =>
145 Effect.gen(function* () {
146 // Write, then poll the query API until ingestion catches up.
147 yield* send(
148 HttpClientRequest.bodyJsonUnsafe(
149 HttpClientRequest.post(`${baseUrl}/remote-write`),
150 { name: METRIC, labels: { source: "bindings-test" }, value: 2 },
151 ),
152 );
153
154 const body = (yield* getJson(`/query?query=${METRIC}`).pipe(
155 Effect.repeat({
156 schedule: Schedule.spaced("3 seconds"),
157 until: (b): boolean => {
158 const result = (
159 b as {
160 result: { resultType: string; result: unknown[] };
161 }
162 ).result;
163 return (
164 result.resultType === "vector" && result.result.length > 0
165 );
166 },
167 times: 20,
168 }),
169 )) as {
170 result: {
171 resultType: string;
172 result: Array<{
173 metric: Record<string, string>;
174 value: [number, string];
175 }>;
176 };
177 };
178
179 expect(body.result.resultType).toBe("vector");
180 const sample = body.result.result.find(
181 (s) => s.metric.__name__ === METRIC,
182 );
183 expect(sample).toBeDefined();
184 expect(sample!.metric.source).toBe("bindings-test");
185 expect(Number(sample!.value[1])).toBeGreaterThan(0);
186 }),
187 { timeout: 120_000 },
188 );
189
190 test.provider("range query returns a matrix", (_stack) =>
191 Effect.gen(function* () {
192 const body = (yield* getJson(`/query-range?query=${METRIC}`)) as {
193 result: { resultType: string };
194 };
195 expect(body.result.resultType).toBe("matrix");
196 }),
197 );
198 });
199
200 describe("GetLabels", () => {
201 test.provider("lists label names", (_stack) =>
202 Effect.gen(function* () {
203 const body = (yield* getJson("/labels")) as { names: string[] };
204 expect(body.names).toContain("__name__");
205 }),
206 );
207
208 test.provider("lists metric names via label values", (_stack) =>
209 Effect.gen(function* () {
210 const body = (yield* getJson("/label-values?label=__name__")) as {
211 values: string[];
212 };
213 expect(body.values).toContain(METRIC);
214 }),
215 );
216 });
217
218 describe("GetSeries", () => {
219 test.provider("matches the remote-written series", (_stack) =>
220 Effect.gen(function* () {
221 const body = (yield* getJson(
222 `/series?match=${encodeURIComponent(`{__name__="${METRIC}"}`)}`,
223 )) as { series: Array<Record<string, string>> };
224 const series = body.series.find((s) => s.__name__ === METRIC);
225 expect(series).toBeDefined();
226 expect(series!.source).toBe("bindings-test");
227 }),
228 );
229 });
230
231 describe("DescribeWorkspace", () => {
232 test.provider("describes the bound workspace", (_stack) =>
233 Effect.gen(function* () {
234 const body = (yield* getJson("/describe-workspace")) as {
235 workspaceId: string;
236 status: string;
237 };
238 expect(body.workspaceId).toMatch(/^ws-/);
239 expect(body.status).toBe("ACTIVE");
240 }),
241 );
242 });
243
244 describe("ListWorkspaces", () => {
245 test.provider("lists workspaces including the bound one", (_stack) =>
246 Effect.gen(function* () {
247 const described = (yield* getJson("/describe-workspace")) as {
248 workspaceId: string;
249 };
250 const body = (yield* getJson("/list-workspaces")) as {
251 workspaceIds: string[];
252 };
253 expect(body.workspaceIds).toContain(described.workspaceId);
254 }),
255 );
256 });
257
258 describe("GetDefaultScraperConfiguration", () => {
259 test.provider("reads the default scraper configuration YAML", (_stack) =>
260 Effect.gen(function* () {
261 const body = (yield* getJson("/default-scraper-config")) as {
262 configuration: string;
263 };
264 // The default configuration is Prometheus scrape-config YAML.
265 expect(body.configuration).toContain("scrape_configs");
266 }),
267 );
268 });
269
270 describe("GetMetricMetadata", () => {
271 test.provider("reads metric metadata", (_stack) =>
272 Effect.gen(function* () {
273 // Remote-write carries no metadata, so the map may be empty — the
274 // assertion is that the signed call succeeds and returns an object.
275 const body = (yield* getJson("/metadata")) as {
276 metadata: Record<string, unknown>;
277 };
278 expect(typeof body.metadata).toBe("object");
279 }),
280 );
281 });
282 },
283);