1import * as AWS from "@/AWS";
2import { Channel, Input, InputSecurityGroup } from "@/AWS/MediaLive";
3import { Role } from "@/AWS/IAM/Role.ts";
4import * as Test from "@/Test/Alchemy";
5import * as medialive from "@distilled.cloud/aws/medialive";
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
13// Deterministic, run-stable physical names (never Date.now()).
14const PULL_INPUT_NAME = "alchemy-test-ml-pull-input";
15const PUSH_INPUT_NAME = "alchemy-test-ml-push-input";
16const CHANNEL_NAME = "alchemy-test-ml-channel";
17
18class StillExists extends Data.TaggedError("StillExists")<{
19 readonly id: string;
20}> {}
21
22// ---------------------------------------------------------------------------
23// Ungated typed-error probes — prove the distilled error union carries the
24// not-found tag the read/delete paths depend on, at near-zero cost.
25// ---------------------------------------------------------------------------
26
27test.provider(
28 "describeChannel/describeInput/describeInputSecurityGroup on a bogus id fail with NotFoundException",
29 () =>
30 Effect.gen(function* () {
31 const c = yield* Effect.flip(
32 medialive.describeChannel({ ChannelId: "9999999" }),
33 );
34 expect(c._tag).toBe("NotFoundException");
35 const i = yield* Effect.flip(
36 medialive.describeInput({ InputId: "9999999" }),
37 );
38 expect(i._tag).toBe("NotFoundException");
39 const g = yield* Effect.flip(
40 medialive.describeInputSecurityGroup({
41 InputSecurityGroupId: "9999999",
42 }),
43 );
44 expect(g._tag).toBe("NotFoundException");
45 }),
46 { tags: ["provider:aws", "provider:aws:medialive", "live"] },
47);
48
49// ---------------------------------------------------------------------------
50// InputSecurityGroup lifecycle — free and fast.
51// ---------------------------------------------------------------------------
52
53test.provider(
54 "InputSecurityGroup: create, update whitelist rules, and destroy",
55 (stack) =>
56 Effect.gen(function* () {
57 yield* stack.destroy();
58
59 const created = yield* stack.deploy(
60 Effect.gen(function* () {
61 return yield* InputSecurityGroup("Allowlist", {
62 whitelistRules: ["10.0.0.0/16"],
63 tags: { Environment: "test" },
64 });
65 }),
66 );
67 expect(created.inputSecurityGroupId).toBeTruthy();
68 expect(created.inputSecurityGroupArn).toContain(":inputSecurityGroup:");
69 expect(created.whitelistRules).toEqual(["10.0.0.0/16"]);
70
71 // Out-of-band verification via distilled, including ownership tags.
72 const observed = yield* medialive.describeInputSecurityGroup({
73 InputSecurityGroupId: created.inputSecurityGroupId,
74 });
75 expect(observed.WhitelistRules?.map((r) => r.Cidr)).toEqual([
76 "10.0.0.0/16",
77 ]);
78 expect(observed.Tags?.["alchemy::id"]).toBe("Allowlist");
79 expect(observed.Tags?.["Environment"]).toBe("test");
80
81 // Update: change the whitelist + tags in place (same id).
82 const updated = yield* stack.deploy(
83 Effect.gen(function* () {
84 return yield* InputSecurityGroup("Allowlist", {
85 whitelistRules: ["10.1.0.0/16", "192.168.0.0/24"],
86 tags: { Environment: "test", Extra: "yes" },
87 });
88 }),
89 );
90 expect(updated.inputSecurityGroupId).toBe(created.inputSecurityGroupId);
91 expect([...updated.whitelistRules].sort()).toEqual([
92 "10.1.0.0/16",
93 "192.168.0.0/24",
94 ]);
95
96 const observed2 = yield* medialive.describeInputSecurityGroup({
97 InputSecurityGroupId: created.inputSecurityGroupId,
98 });
99 expect(observed2.WhitelistRules?.map((r) => r.Cidr)?.sort()).toEqual([
100 "10.1.0.0/16",
101 "192.168.0.0/24",
102 ]);
103 expect(observed2.Tags?.["Extra"]).toBe("yes");
104
105 yield* stack.destroy();
106 yield* assertInputSecurityGroupDeleted(created.inputSecurityGroupId);
107 }),
108 {
109 tags: ["provider:aws", "provider:aws:medialive", "live"],
110 timeout: 120_000,
111 },
112);
113
114// ---------------------------------------------------------------------------
115// Input lifecycle — a URL_PULL input needs no security group; covers update
116// (sources) plus replacement (type is immutable).
117// ---------------------------------------------------------------------------
118
119test.provider(
120 "Input: create URL_PULL, update sources, replace on type change, destroy",
121 (stack) =>
122 Effect.gen(function* () {
123 yield* stack.destroy();
124
125 const created = yield* stack.deploy(
126 Effect.gen(function* () {
127 return yield* Input("Pull", {
128 name: PULL_INPUT_NAME,
129 type: "URL_PULL",
130 sources: [{ Url: "https://example.com/stream/index.m3u8" }],
131 tags: { Environment: "test" },
132 });
133 }),
134 );
135 expect(created.inputId).toBeTruthy();
136 expect(created.inputArn).toContain(":input:");
137 expect(created.inputName).toBe(PULL_INPUT_NAME);
138 expect(created.type).toBe("URL_PULL");
139 expect(created.state === "DETACHED" || created.state === "ATTACHED").toBe(
140 true,
141 );
142
143 const observed = yield* medialive.describeInput({
144 InputId: created.inputId,
145 });
146 expect(observed.Sources?.map((s) => s.Url)).toEqual([
147 "https://example.com/stream/index.m3u8",
148 ]);
149 expect(observed.Tags?.["alchemy::id"]).toBe("Pull");
150
151 // Update: swap the pull URL in place (same id).
152 const updated = yield* stack.deploy(
153 Effect.gen(function* () {
154 return yield* Input("Pull", {
155 name: PULL_INPUT_NAME,
156 type: "URL_PULL",
157 sources: [{ Url: "https://example.com/stream/other.m3u8" }],
158 tags: { Environment: "test" },
159 });
160 }),
161 );
162 expect(updated.inputId).toBe(created.inputId);
163
164 const observed2 = yield* medialive.describeInput({
165 InputId: created.inputId,
166 });
167 expect(observed2.Sources?.map((s) => s.Url)).toEqual([
168 "https://example.com/stream/other.m3u8",
169 ]);
170
171 // Replace: the input type is immutable, so switching to MP4_FILE must
172 // produce a NEW input id.
173 const replaced = yield* stack.deploy(
174 Effect.gen(function* () {
175 return yield* Input("Pull", {
176 name: PULL_INPUT_NAME,
177 type: "MP4_FILE",
178 sources: [{ Url: "s3ssl://alchemy-nonexistent/in.mp4" }],
179 tags: { Environment: "test" },
180 });
181 }),
182 );
183 expect(replaced.type).toBe("MP4_FILE");
184 expect(replaced.inputId).not.toBe(created.inputId);
185 yield* assertInputDeleted(created.inputId);
186
187 yield* stack.destroy();
188 yield* assertInputDeleted(replaced.inputId);
189 }),
190 {
191 tags: ["provider:aws", "provider:aws:medialive", "live"],
192 timeout: 180_000,
193 },
194);
195
196// ---------------------------------------------------------------------------
197// Composition: an RTMP_PUSH input gated by an input security group. Also
198// exercises the delete ordering (the group is IN_USE until the input is
199// fully deleted).
200// ---------------------------------------------------------------------------
201
202test.provider(
203 "Input + InputSecurityGroup: RTMP_PUSH input behind an allowlist",
204 (stack) =>
205 Effect.gen(function* () {
206 yield* stack.destroy();
207
208 const { isg, input } = yield* stack.deploy(
209 Effect.gen(function* () {
210 const isg = yield* InputSecurityGroup("PushAllowlist", {
211 whitelistRules: ["0.0.0.0/0"],
212 });
213 const input = yield* Input("Push", {
214 name: PUSH_INPUT_NAME,
215 type: "RTMP_PUSH",
216 inputSecurityGroups: [isg.inputSecurityGroupId],
217 destinations: [
218 { StreamName: "live/primary" },
219 { StreamName: "live/secondary" },
220 ],
221 });
222 return { isg, input };
223 }),
224 );
225 expect(input.securityGroups).toEqual([isg.inputSecurityGroupId]);
226 // A STANDARD-class push input exposes two ingest endpoints.
227 expect(input.destinations.length).toBe(2);
228 expect(input.destinations[0].Url).toContain("rtmp");
229
230 const observedIsg = yield* medialive.describeInputSecurityGroup({
231 InputSecurityGroupId: isg.inputSecurityGroupId,
232 });
233 expect(observedIsg.Inputs).toContain(input.inputId);
234
235 yield* stack.destroy();
236 yield* assertInputDeleted(input.inputId);
237 yield* assertInputSecurityGroupDeleted(isg.inputSecurityGroupId);
238 }),
239 {
240 tags: ["provider:aws", "provider:aws:medialive", "live"],
241 timeout: 180_000,
242 },
243);
244
245// ---------------------------------------------------------------------------
246// Channel lifecycle — channels are heavy (slow create, billed per RUNNING
247// hour). Gated behind AWS_TEST_MEDIALIVE=1; the channel is provisioned IDLE
248// and never started.
249// ---------------------------------------------------------------------------
250
251const ENCODER_SETTINGS: medialive.EncoderSettings = {
252 TimecodeConfig: { Source: "EMBEDDED" },
253 AudioDescriptions: [
254 {
255 Name: "audio_1",
256 AudioSelectorName: "default",
257 CodecSettings: {
258 AacSettings: { Bitrate: 96000, SampleRate: 48000 },
259 },
260 },
261 ],
262 VideoDescriptions: [
263 {
264 Name: "video_1",
265 Width: 640,
266 Height: 360,
267 CodecSettings: {
268 H264Settings: {
269 Bitrate: 1000000,
270 FramerateControl: "SPECIFIED",
271 FramerateNumerator: 30,
272 FramerateDenominator: 1,
273 GopSize: 60,
274 },
275 },
276 },
277 ],
278 OutputGroups: [
279 {
280 Name: "udp",
281 OutputGroupSettings: { UdpGroupSettings: {} },
282 Outputs: [
283 {
284 OutputName: "output_1",
285 VideoDescriptionName: "video_1",
286 AudioDescriptionNames: ["audio_1"],
287 OutputSettings: {
288 UdpOutputSettings: {
289 Destination: { DestinationRefId: "dest1" },
290 ContainerSettings: { M2tsSettings: {} },
291 },
292 },
293 },
294 ],
295 },
296 ],
297};
298
299test.provider.skipIf(!process.env.AWS_TEST_MEDIALIVE)(
300 "Channel: create IDLE single-pipeline channel, update log level, destroy (gated AWS_TEST_MEDIALIVE=1)",
301 (stack) =>
302 Effect.gen(function* () {
303 yield* stack.destroy();
304
305 const { channel, input } = yield* stack.deploy(
306 Effect.gen(function* () {
307 const role = yield* Role("MediaLiveRole", {
308 assumeRolePolicyDocument: {
309 Version: "2012-10-17",
310 Statement: [
311 {
312 Effect: "Allow",
313 Principal: { Service: "medialive.amazonaws.com" },
314 Action: ["sts:AssumeRole"],
315 },
316 ],
317 },
318 });
319 const input = yield* Input("ChannelPull", {
320 type: "URL_PULL",
321 sources: [{ Url: "https://example.com/stream/index.m3u8" }],
322 });
323 const channel = yield* Channel("Live", {
324 name: CHANNEL_NAME,
325 channelClass: "SINGLE_PIPELINE",
326 roleArn: role.roleArn,
327 inputAttachments: [
328 {
329 InputId: input.inputId,
330 InputAttachmentName: "primary",
331 InputSettings: {
332 AudioSelectors: [{ Name: "default" }],
333 },
334 },
335 ],
336 inputSpecification: {
337 Codec: "AVC",
338 Resolution: "SD",
339 MaximumBitrate: "MAX_10_MBPS",
340 },
341 destinations: [
342 {
343 Id: "dest1",
344 Settings: [{ Url: "udp://10.220.171.28:5000" }],
345 },
346 ],
347 encoderSettings: ENCODER_SETTINGS,
348 tags: { Environment: "test" },
349 });
350 return { channel, input };
351 }),
352 );
353 expect(channel.channelId).toBeTruthy();
354 expect(channel.channelArn).toContain(":channel:");
355 expect(channel.channelName).toBe(CHANNEL_NAME);
356 expect(channel.channelClass).toBe("SINGLE_PIPELINE");
357 // NEVER started — provisioned and left IDLE.
358 expect(channel.state).toBe("IDLE");
359
360 const observed = yield* medialive.describeChannel({
361 ChannelId: channel.channelId,
362 });
363 expect(observed.State).toBe("IDLE");
364 expect(observed.Tags?.["alchemy::id"]).toBe("Live");
365
366 // Update: bump the log level in place (same id), channel stays IDLE.
367 const updated = yield* stack.deploy(
368 Effect.gen(function* () {
369 const role = yield* Role("MediaLiveRole", {
370 assumeRolePolicyDocument: {
371 Version: "2012-10-17",
372 Statement: [
373 {
374 Effect: "Allow",
375 Principal: { Service: "medialive.amazonaws.com" },
376 Action: ["sts:AssumeRole"],
377 },
378 ],
379 },
380 });
381 const input = yield* Input("ChannelPull", {
382 type: "URL_PULL",
383 sources: [{ Url: "https://example.com/stream/index.m3u8" }],
384 });
385 return {
386 channel: yield* Channel("Live", {
387 name: CHANNEL_NAME,
388 channelClass: "SINGLE_PIPELINE",
389 roleArn: role.roleArn,
390 logLevel: "ERROR",
391 inputAttachments: [
392 {
393 InputId: input.inputId,
394 InputAttachmentName: "primary",
395 InputSettings: {
396 AudioSelectors: [{ Name: "default" }],
397 },
398 },
399 ],
400 inputSpecification: {
401 Codec: "AVC",
402 Resolution: "SD",
403 MaximumBitrate: "MAX_10_MBPS",
404 },
405 destinations: [
406 {
407 Id: "dest1",
408 Settings: [{ Url: "udp://10.220.171.28:5000" }],
409 },
410 ],
411 encoderSettings: ENCODER_SETTINGS,
412 tags: { Environment: "test" },
413 }),
414 input,
415 };
416 }),
417 );
418 expect(updated.channel.channelId).toBe(channel.channelId);
419
420 const observed2 = yield* medialive.describeChannel({
421 ChannelId: channel.channelId,
422 });
423 expect(observed2.LogLevel).toBe("ERROR");
424 expect(observed2.State).toBe("IDLE");
425
426 yield* stack.destroy();
427 yield* assertChannelDeleted(channel.channelId);
428 yield* assertInputDeleted(input.inputId);
429 }),
430 {
431 tags: [
432 "provider:aws",
433 "provider:aws:iam",
434 "provider:aws:medialive",
435 "live",
436 ],
437 timeout: 600_000,
438 },
439);
440
441// ---------------------------------------------------------------------------
442// Deletion assertions — typed wait-until-gone.
443// ---------------------------------------------------------------------------
444
445const assertInputDeleted = (inputId: string) =>
446 medialive.describeInput({ InputId: inputId }).pipe(
447 Effect.flatMap((input) =>
448 input.State === "DELETED" || input.State === "DELETING"
449 ? Effect.void
450 : Effect.fail(new StillExists({ id: inputId })),
451 ),
452 Effect.catchTag("NotFoundException", () => Effect.void),
453 Effect.retry({
454 while: (e) => e._tag === "StillExists",
455 schedule: Schedule.max([
456 Schedule.spaced("3 seconds"),
457 Schedule.recurs(10),
458 ]),
459 }),
460 );
461
462const assertInputSecurityGroupDeleted = (id: string) =>
463 medialive.describeInputSecurityGroup({ InputSecurityGroupId: id }).pipe(
464 Effect.flatMap((isg) =>
465 isg.State === "DELETED"
466 ? Effect.void
467 : Effect.fail(new StillExists({ id })),
468 ),
469 Effect.catchTag("NotFoundException", () => Effect.void),
470 Effect.retry({
471 while: (e) => e._tag === "StillExists",
472 schedule: Schedule.max([
473 Schedule.spaced("3 seconds"),
474 Schedule.recurs(10),
475 ]),
476 }),
477 );
478
479const assertChannelDeleted = (channelId: string) =>
480 medialive.describeChannel({ ChannelId: channelId }).pipe(
481 Effect.flatMap((channel) =>
482 channel.State === "DELETED" || channel.State === "DELETING"
483 ? Effect.void
484 : Effect.fail(new StillExists({ id: channelId })),
485 ),
486 Effect.catchTag("NotFoundException", () => Effect.void),
487 Effect.retry({
488 while: (e) => e._tag === "StillExists",
489 schedule: Schedule.max([
490 Schedule.spaced("5 seconds"),
491 Schedule.recurs(12),
492 ]),
493 }),
494 );