1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as transcoder from "@distilled.cloud/gcp/transcoder_v1";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import { dockerAvailable, expectProbe } from "../bindingHost.ts";
10import TranscoderBindingsHost from "./fixtures/bindings-host.ts";
11
12const testOptions = { providers: GCP.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "TranscoderBindings");
15
16let baseUrl: string;
17let hostAccount: string;
18let project: string;
19
20/** Project-level roles held by the host's service account. */
21const projectRoles = () =>
22 resourcemanager
23 .getIamPolicyProjects({
24 resource: `projects/${project}`,
25 body: { options: { requestedPolicyVersion: 3 } },
26 })
27 .pipe(
28 Effect.map((policy) =>
29 (policy.bindings ?? [])
30 .filter((binding) =>
31 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
32 )
33 .map((binding) => ({
34 role: binding.role,
35 condition: binding.condition?.expression,
36 })),
37 ),
38 );
39
40const waitUntilJobGone = (name: string) =>
41 transcoder.getProjectsLocationsJobs({ name }).pipe(
42 Effect.as("found" as const),
43 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
44 Effect.repeat({
45 schedule: Schedule.spaced("1 second"),
46 until: (status) => status === "gone",
47 times: 10,
48 }),
49 );
50
51describe.skipIf(!dockerAvailable || !!process.env.FAST)(
52 "Transcoder Bindings",
53 {
54 tags: [
55 "provider:gcp",
56 "provider:gcp:transcoder",
57 "provider:gcp:run",
58 "live",
59 ],
60 },
61 () => {
62 beforeAll(
63 Effect.gen(function* () {
64 yield* sharedStack.destroy();
65 const out = yield* sharedStack.deploy(
66 Effect.gen(function* () {
67 const host = yield* TranscoderBindingsHost;
68 return {
69 uri: host.uri,
70 serviceAccount: host.serviceAccount,
71 project: host.project,
72 };
73 }),
74 );
75 baseUrl = out.uri!;
76 hostAccount = out.serviceAccount!;
77 project = out.project;
78 }),
79 { timeout: 900_000 },
80 );
81
82 afterAll(sharedStack.destroy(), { timeout: 600_000 });
83
84 describe("CreateJob", () => {
85 test.provider(
86 "starts a job from the bound template as the host, with transcoder.editor on the project",
87 (_stack) =>
88 Effect.gen(function* () {
89 const started = yield* expectProbe<{
90 name: string;
91 muxStreams: string[];
92 }>(baseUrl, "createJob");
93 const live = yield* transcoder.getProjectsLocationsJobs({
94 name: started.name,
95 });
96 // The job is not stack-managed: remove it before asserting.
97 yield* transcoder.deleteProjectsLocationsJobs({
98 name: started.name,
99 allowMissing: true,
100 });
101 const gone = yield* waitUntilJobGone(started.name);
102
103 expect(started.name).toMatch(
104 /^projects\/[^/]+\/locations\/us-central1\/jobs\/[^/]+$/,
105 );
106 // The job runs the bound template's config.
107 expect(started.muxStreams).toEqual(["sd"]);
108 expect(live.name).toEqual(started.name);
109 expect(
110 (live.config?.muxStreams ?? []).map((stream) => stream.key),
111 ).toEqual(["sd"]);
112 expect(gone).toEqual("gone");
113
114 // No resource-level IAM and no narrower role with jobs.create.
115 expect(yield* projectRoles()).toEqual([
116 { role: "roles/transcoder.editor", condition: undefined },
117 ]);
118 }),
119 {
120 tags: ["provider:gcp", "provider:gcp:transcoder", "live"],
121 timeout: 600_000,
122 },
123 );
124 });
125 },
126);