1import * as Lambda from "@/AWS/Lambda";
2import * as MediaPackageV2 from "@/AWS/MediaPackageV2";
3import * as S3 from "@/AWS/S3";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14/**
15 * Deterministic bucket name so the bucket's own policy can reference its
16 * ARN without a self-referential Output (MediaPackage writes harvested
17 * clips as the `mediapackagev2.amazonaws.com` service principal, which the
18 * bucket policy must allow).
19 */
20const HARVEST_BUCKET = "alchemy-test-mediapackagev2-harvest";
21
22export class MediaPackageV2TestFunction extends Lambda.Function<Lambda.Function>()(
23 "MediaPackageV2TestFunction",
24) {}
25
26export default MediaPackageV2TestFunction.make(
27 {
28 main,
29 functionUrl: true,
30 timeout: Duration.seconds(60),
31 },
32 Effect.gen(function* () {
33 // The channel-group → channel → endpoint chain the bindings are bound
34 // to. Nothing ever pushes content to the channel, so harvest jobs can
35 // be created (the schedule sits inside the startover window) but fail
36 // asynchronously with "no content" — which is fine: the fixture only
37 // proves the runtime call + IAM wiring.
38 const group = yield* MediaPackageV2.ChannelGroup("BindingGroup", {
39 description: "alchemy mediapackagev2 bindings fixture",
40 tags: { fixture: "mediapackagev2-bindings" },
41 });
42 const channel = yield* MediaPackageV2.Channel("BindingFeed", {
43 channelGroupName: group.channelGroupName,
44 inputType: "HLS",
45 tags: { fixture: "mediapackagev2-bindings" },
46 });
47 const endpoint = yield* MediaPackageV2.OriginEndpoint("BindingPlayback", {
48 channelGroupName: group.channelGroupName,
49 channelName: channel.channelName,
50 containerType: "TS",
51 segment: { SegmentDurationSeconds: 6 },
52 startoverWindow: "1 hour",
53 hlsManifests: [{ ManifestName: "index" }],
54 tags: { fixture: "mediapackagev2-bindings" },
55 });
56
57 // Destination bucket for harvested clips, writable by MediaPackage.
58 yield* S3.Bucket("HarvestBucket", {
59 bucketName: HARVEST_BUCKET,
60 forceDestroy: true,
61 policy: [
62 {
63 Sid: "AllowMediaPackageHarvest",
64 Effect: "Allow",
65 Principal: { Service: "mediapackagev2.amazonaws.com" },
66 Action: ["s3:PutObject", "s3:GetBucketLocation"],
67 Resource: [
68 `arn:aws:s3:::${HARVEST_BUCKET}`,
69 `arn:aws:s3:::${HARVEST_BUCKET}/*`,
70 ],
71 },
72 ],
73 tags: { fixture: "mediapackagev2-bindings" },
74 });
75
76 // Event source: subscribe the host to harvest-job notifications. The
77 // deploy proves the EventBridge rule + invoke permission wiring.
78 yield* MediaPackageV2.consumeHarvestJobEvents({}, (events) =>
79 Stream.runForEach(events, (event) =>
80 Effect.log(
81 `mediapackagev2 harvest job ${event.detail.harvestJob.harvestJobName}: ${event.detail.harvestJob.status}`,
82 ),
83 ),
84 );
85
86 const resetChannel = yield* MediaPackageV2.ResetChannelState(channel);
87 const resetEndpoint =
88 yield* MediaPackageV2.ResetOriginEndpointState(endpoint);
89 const createHarvestJob = yield* MediaPackageV2.CreateHarvestJob(endpoint);
90 const getHarvestJob = yield* MediaPackageV2.GetHarvestJob(endpoint);
91 const cancelHarvestJob = yield* MediaPackageV2.CancelHarvestJob(endpoint);
92 const listHarvestJobs = yield* MediaPackageV2.ListHarvestJobs(group);
93
94 const bound = {
95 resetChannel,
96 resetEndpoint,
97 createHarvestJob,
98 getHarvestJob,
99 cancelHarvestJob,
100 listHarvestJobs,
101 };
102
103 return {
104 fetch: Effect.gen(function* () {
105 const request = yield* HttpServerRequest;
106 const url = new URL(request.originalUrl);
107 const pathname = url.pathname;
108
109 if (request.method === "GET" && pathname === "/bindings") {
110 return yield* HttpServerResponse.json({
111 bound: Object.keys(bound),
112 });
113 }
114
115 // Reset the channel's ingest state. On an idle channel this
116 // trivially succeeds; MediaPackage rate-limits resets, so a
117 // repeated run may answer with the typed Conflict/Throttling tag.
118 if (request.method === "POST" && pathname === "/reset-channel") {
119 const result = yield* resetChannel().pipe(
120 Effect.map(({ Arn }) => ({
121 arn: Arn as string | undefined,
122 tag: undefined as string | undefined,
123 })),
124 Effect.catchTag(["ConflictException", "ThrottlingException"], (e) =>
125 Effect.succeed({
126 arn: undefined as string | undefined,
127 tag: e._tag as string | undefined,
128 }),
129 ),
130 );
131 return yield* HttpServerResponse.json(result);
132 }
133
134 // Reset the endpoint's packaging state — same tolerance as above.
135 if (request.method === "POST" && pathname === "/reset-endpoint") {
136 const result = yield* resetEndpoint().pipe(
137 Effect.map(({ Arn }) => ({
138 arn: Arn as string | undefined,
139 tag: undefined as string | undefined,
140 })),
141 Effect.catchTag(["ConflictException", "ThrottlingException"], (e) =>
142 Effect.succeed({
143 arn: undefined as string | undefined,
144 tag: e._tag as string | undefined,
145 }),
146 ),
147 );
148 return yield* HttpServerResponse.json(result);
149 }
150
151 // Enumerate the group's harvest jobs.
152 if (request.method === "GET" && pathname === "/harvest-jobs") {
153 const { Items } = yield* listHarvestJobs();
154 return yield* HttpServerResponse.json({
155 count: (Items ?? []).length,
156 });
157 }
158
159 // Full create → get → cancel roundtrip. The channel never received
160 // content, so MediaPackage may reject the job upfront with the
161 // typed ValidationException — that outcome still proves the binding
162 // and its IAM grant.
163 if (request.method === "POST" && pathname === "/harvest-cycle") {
164 const now = yield* Effect.sync(() => Date.now());
165 const result = yield* createHarvestJob({
166 HarvestedManifests: {
167 HlsManifests: [{ ManifestName: "index" }],
168 },
169 ScheduleConfiguration: {
170 StartTime: new Date(now - 4 * 60_000),
171 EndTime: new Date(now - 60_000),
172 },
173 Destination: {
174 S3Destination: {
175 BucketName: HARVEST_BUCKET,
176 DestinationPath: "clips/",
177 },
178 },
179 }).pipe(
180 Effect.flatMap((job) =>
181 Effect.gen(function* () {
182 const { Status } = yield* getHarvestJob({
183 HarvestJobName: job.HarvestJobName,
184 });
185 // Cancel is only legal while QUEUED/IN_PROGRESS; a job that
186 // already settled answers with the typed Conflict.
187 const cancelled = yield* cancelHarvestJob({
188 HarvestJobName: job.HarvestJobName,
189 }).pipe(
190 Effect.map(() => true),
191 Effect.catchTag("ConflictException", () =>
192 Effect.succeed(false),
193 ),
194 );
195 return {
196 created: true,
197 status: Status as string | undefined,
198 cancelled,
199 tag: undefined as string | undefined,
200 };
201 }),
202 ),
203 Effect.catchTag(["ValidationException", "ConflictException"], (e) =>
204 Effect.succeed({
205 created: false,
206 status: undefined as string | undefined,
207 cancelled: false,
208 tag: e._tag as string | undefined,
209 }),
210 ),
211 );
212 return yield* HttpServerResponse.json(result);
213 }
214
215 return yield* HttpServerResponse.json(
216 { error: "Not found", method: request.method, pathname },
217 { status: 404 },
218 );
219 }).pipe(Effect.orDie),
220 };
221 }).pipe(
222 Effect.provide(
223 Layer.mergeAll(
224 Lambda.EventSource,
225 MediaPackageV2.ResetChannelStateHttp,
226 MediaPackageV2.ResetOriginEndpointStateHttp,
227 MediaPackageV2.CreateHarvestJobHttp,
228 MediaPackageV2.GetHarvestJobHttp,
229 MediaPackageV2.CancelHarvestJobHttp,
230 MediaPackageV2.ListHarvestJobsHttp,
231 ),
232 ),
233 ),
234);