1import * as AWS from "@/AWS";
2import { LogGroup } from "@/AWS/Logs/LogGroup.ts";
3import { LogStream } from "@/AWS/Logs/LogStream.ts";
4import * as Test from "@/Test/Alchemy";
5import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const describeLogStream = Effect.fn(function* (
14 logGroupName: string,
15 logStreamName: string,
16) {
17 const described = yield* logs
18 .describeLogStreams({ logGroupName, logStreamNamePrefix: logStreamName })
19 .pipe(
20 Effect.catchTag("ResourceNotFoundException", () =>
21 Effect.succeed({ logStreams: [] }),
22 ),
23 );
24 return (described.logStreams ?? []).find(
25 (stream) => stream.logStreamName === logStreamName,
26 );
27});
28
29class LogStreamStillExists extends Data.TaggedError("LogStreamStillExists")<{
30 readonly logStreamName: string;
31}> {}
32
33const assertLogStreamDeleted = (logGroupName: string, logStreamName: string) =>
34 describeLogStream(logGroupName, logStreamName).pipe(
35 Effect.flatMap((stream) =>
36 stream === undefined
37 ? Effect.void
38 : Effect.fail(new LogStreamStillExists({ logStreamName })),
39 ),
40 Effect.retry({
41 while: (e) => e._tag === "LogStreamStillExists",
42 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
43 }),
44 );
45
46test.provider(
47 "create, replace on rename, delete log stream",
48 (stack) =>
49 Effect.gen(function* () {
50 yield* stack.destroy();
51
52 const created = yield* stack.deploy(
53 Effect.gen(function* () {
54 const logGroup = yield* LogGroup("StreamLogGroup", {
55 retention: "1 day",
56 });
57 return yield* LogStream("AuditStream", {
58 logGroupName: logGroup.logGroupName,
59 });
60 }),
61 );
62
63 expect(created.logStreamName).toBeDefined();
64 expect(created.logStreamArn).toContain(":log-group:");
65
66 // out-of-band verification via distilled
67 expect(
68 yield* describeLogStream(created.logGroupName, created.logStreamName),
69 ).toBeDefined();
70
71 // no-op deploy keeps the same stream
72 const noop = yield* stack.deploy(
73 Effect.gen(function* () {
74 const logGroup = yield* LogGroup("StreamLogGroup", {
75 retention: "1 day",
76 });
77 return yield* LogStream("AuditStream", {
78 logGroupName: logGroup.logGroupName,
79 });
80 }),
81 );
82 expect(noop.logStreamName).toBe(created.logStreamName);
83
84 // explicit logStreamName triggers a replacement
85 const replaced = yield* stack.deploy(
86 Effect.gen(function* () {
87 const logGroup = yield* LogGroup("StreamLogGroup", {
88 retention: "1 day",
89 });
90 return yield* LogStream("AuditStream", {
91 logGroupName: logGroup.logGroupName,
92 logStreamName: "alchemy-test-audit-stream-renamed",
93 });
94 }),
95 );
96 expect(replaced.logStreamName).toBe("alchemy-test-audit-stream-renamed");
97 expect(
98 yield* describeLogStream(replaced.logGroupName, replaced.logStreamName),
99 ).toBeDefined();
100 yield* assertLogStreamDeleted(
101 created.logGroupName,
102 created.logStreamName,
103 );
104
105 yield* stack.destroy();
106 yield* assertLogStreamDeleted(
107 replaced.logGroupName,
108 replaced.logStreamName,
109 );
110 }).pipe(Effect.onError(() => stack.destroy().pipe(Effect.ignore))),
111 { tags: ["provider:aws", "provider:aws:logs", "live"], timeout: 120_000 },
112);