1import * as AWS from "@/AWS";
2import { RealtimeLogConfig } from "@/AWS/CloudFront";
3import * as Test from "@/Test/Alchemy";
4import * as cloudfront from "@distilled.cloud/aws/cloudfront";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11// The role CloudFront assumes to deliver real-time log records to Kinesis.
12const logDeliveryRole = (stream: AWS.Kinesis.Stream) =>
13 AWS.IAM.Role("RealtimeLogRole", {
14 assumeRolePolicyDocument: {
15 Version: "2012-10-17",
16 Statement: [
17 {
18 Effect: "Allow",
19 Principal: { Service: "cloudfront.amazonaws.com" },
20 Action: ["sts:AssumeRole"],
21 },
22 ],
23 },
24 inlinePolicies: {
25 KinesisWrite: {
26 Version: "2012-10-17",
27 Statement: [
28 {
29 Effect: "Allow",
30 Action: [
31 "kinesis:DescribeStreamSummary",
32 "kinesis:DescribeStream",
33 "kinesis:PutRecord",
34 "kinesis:PutRecords",
35 ],
36 Resource: [stream.streamArn],
37 },
38 ],
39 },
40 },
41 });
42
43const assertConfigGone = (name: string) =>
44 cloudfront.getRealtimeLogConfig({ Name: name }).pipe(
45 Effect.flatMap(() =>
46 Effect.fail(new Error("realtime log config still exists")),
47 ),
48 Effect.catchTag("NoSuchRealtimeLogConfig", () => Effect.void),
49 Effect.retry({
50 while: (e) => e instanceof Error,
51 schedule: Schedule.max([
52 Schedule.fixed("2 seconds"),
53 Schedule.recurs(10),
54 ]),
55 }),
56 );
57
58test.provider(
59 "create, update in place, and delete realtime log config",
60 (stack) =>
61 Effect.gen(function* () {
62 yield* stack.destroy();
63
64 // Create.
65 const created = yield* stack.deploy(
66 Effect.gen(function* () {
67 const stream = yield* AWS.Kinesis.Stream("EdgeLogStream", {});
68 const role = yield* logDeliveryRole(stream);
69 return yield* RealtimeLogConfig("EdgeLogConfig", {
70 samplingRate: 100,
71 fields: ["timestamp", "c-ip", "cs-uri-stem", "sc-status"],
72 endpoints: [{ streamArn: stream.streamArn, roleArn: role.roleArn }],
73 });
74 }),
75 );
76
77 expect(created.arn).toContain("realtime-log-config");
78 expect(created.samplingRate).toBe(100);
79 // CloudFront canonicalizes (reorders) the field list.
80 expect([...created.fields].sort()).toEqual(
81 ["timestamp", "c-ip", "cs-uri-stem", "sc-status"].sort(),
82 );
83 expect(created.endpoints).toHaveLength(1);
84
85 // Out-of-band verification.
86 const observed = yield* cloudfront.getRealtimeLogConfig({
87 Name: created.name,
88 });
89 expect(observed.RealtimeLogConfig?.ARN).toBe(created.arn);
90 expect(observed.RealtimeLogConfig?.SamplingRate).toBe(100);
91
92 // Update sampling rate + fields in place (same ARN).
93 const updated = yield* stack.deploy(
94 Effect.gen(function* () {
95 const stream = yield* AWS.Kinesis.Stream("EdgeLogStream", {});
96 const role = yield* logDeliveryRole(stream);
97 return yield* RealtimeLogConfig("EdgeLogConfig", {
98 samplingRate: 25,
99 fields: ["timestamp", "c-ip"],
100 endpoints: [{ streamArn: stream.streamArn, roleArn: role.roleArn }],
101 });
102 }),
103 );
104
105 expect(updated.arn).toBe(created.arn);
106 expect(updated.samplingRate).toBe(25);
107 expect([...updated.fields].sort()).toEqual(["timestamp", "c-ip"].sort());
108
109 const observed2 = yield* cloudfront.getRealtimeLogConfig({
110 Name: created.name,
111 });
112 expect(observed2.RealtimeLogConfig?.SamplingRate).toBe(25);
113 expect([...(observed2.RealtimeLogConfig?.Fields ?? [])].sort()).toEqual(
114 ["timestamp", "c-ip"].sort(),
115 );
116
117 yield* stack.destroy();
118 yield* assertConfigGone(created.name);
119 }),
120 {
121 tags: [
122 "provider:aws",
123 "provider:aws:cloudfront",
124 "provider:aws:iam",
125 "provider:aws:kinesis",
126 "live",
127 ],
128 timeout: 180_000,
129 },
130);
131
132test.provider(
133 "changing the name replaces the realtime log config",
134 (stack) =>
135 Effect.gen(function* () {
136 yield* stack.destroy();
137
138 const make = (name: string) =>
139 Effect.gen(function* () {
140 const stream = yield* AWS.Kinesis.Stream("ReplaceLogStream", {});
141 const role = yield* AWS.IAM.Role("ReplaceLogRole", {
142 assumeRolePolicyDocument: {
143 Version: "2012-10-17",
144 Statement: [
145 {
146 Effect: "Allow",
147 Principal: { Service: "cloudfront.amazonaws.com" },
148 Action: ["sts:AssumeRole"],
149 },
150 ],
151 },
152 inlinePolicies: {
153 KinesisWrite: {
154 Version: "2012-10-17",
155 Statement: [
156 {
157 Effect: "Allow",
158 Action: [
159 "kinesis:DescribeStreamSummary",
160 "kinesis:DescribeStream",
161 "kinesis:PutRecord",
162 "kinesis:PutRecords",
163 ],
164 Resource: [stream.streamArn],
165 },
166 ],
167 },
168 },
169 });
170 return yield* RealtimeLogConfig("ReplaceLogConfig", {
171 name,
172 samplingRate: 100,
173 fields: ["timestamp"],
174 endpoints: [{ streamArn: stream.streamArn, roleArn: role.roleArn }],
175 });
176 });
177
178 const created = yield* stack.deploy(make("alchemy-test-rtlc-a"));
179 expect(created.name).toBe("alchemy-test-rtlc-a");
180
181 const replaced = yield* stack.deploy(make("alchemy-test-rtlc-b"));
182 expect(replaced.name).toBe("alchemy-test-rtlc-b");
183 expect(replaced.arn).not.toBe(created.arn);
184
185 // The old configuration must be gone.
186 yield* assertConfigGone("alchemy-test-rtlc-a");
187
188 yield* stack.destroy();
189 yield* assertConfigGone("alchemy-test-rtlc-b");
190 }),
191 {
192 tags: [
193 "provider:aws",
194 "provider:aws:cloudfront",
195 "provider:aws:iam",
196 "provider:aws:kinesis",
197 "live",
198 ],
199 timeout: 180_000,
200 },
201);