1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as crm from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as scheduler from "@distilled.cloud/gcp/cloudscheduler_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 SchedulerBindingsHost, {
11 PausedJob,
12 ResumedJob,
13 RunJobTarget,
14} from "./fixtures/bindings-host.ts";
15
16const testOptions = { providers: GCP.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "CloudSchedulerBindings");
19
20let baseUrl: string;
21let hostAccount: string;
22let project: string;
23let jobs: { paused: string; resumed: string; run: string };
24
25/**
26 * The host's project-level roles. Cloud Scheduler has no per-job IAM
27 * policy: Pause/Resume grant `roles/cloudscheduler.admin` (the only
28 * predefined role with `jobs.pause`/`jobs.enable`) and Run grants
29 * `roles/cloudscheduler.jobRunner`, all on the project.
30 */
31const expectProjectGrants = Effect.gen(function* () {
32 const policy = yield* crm.getIamPolicyProjects({
33 resource: `projects/${project}`,
34 body: { options: { requestedPolicyVersion: 3 } },
35 });
36 const roles = (policy.bindings ?? [])
37 .filter((binding) =>
38 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
39 )
40 .map((binding) => ({ role: binding.role, condition: binding.condition }))
41 .sort((a, b) => (a.role ?? "").localeCompare(b.role ?? ""));
42 expect(roles).toEqual([
43 { role: "roles/cloudscheduler.admin", condition: undefined },
44 { role: "roles/cloudscheduler.jobRunner", condition: undefined },
45 ]);
46});
47
48const getJob = (name: string) => scheduler.getProjectsLocationsJobs({ name });
49
50describe.skipIf(!dockerAvailable)(
51 "CloudScheduler Bindings",
52 {
53 tags: [
54 "provider:gcp",
55 "provider:gcp:cloudscheduler",
56 "provider:gcp:run",
57 "live",
58 ],
59 },
60 () => {
61 beforeAll(
62 Effect.gen(function* () {
63 yield* sharedStack.destroy();
64 const out = yield* sharedStack.deploy(
65 Effect.gen(function* () {
66 const host = yield* SchedulerBindingsHost;
67 const paused = yield* PausedJob;
68 return {
69 uri: host.uri,
70 serviceAccount: host.serviceAccount,
71 project: paused.project,
72 paused: paused.name,
73 resumed: (yield* ResumedJob).name,
74 run: (yield* RunJobTarget).name,
75 };
76 }),
77 );
78 baseUrl = out.uri!;
79 hostAccount = out.serviceAccount!;
80 project = out.project;
81 jobs = { paused: out.paused, resumed: out.resumed, run: out.run };
82 }),
83 { timeout: 900_000 },
84 );
85
86 afterAll(sharedStack.destroy(), { timeout: 600_000 });
87
88 describe("PauseJob", () => {
89 test.provider(
90 "pauses the job as the host's service account",
91 (_stack) =>
92 Effect.gen(function* () {
93 const job = yield* expectProbe<scheduler.Job>(baseUrl, "pause");
94 expect(job.name).toEqual(jobs.paused);
95 expect(job.state).toEqual("PAUSED");
96 expect((yield* getJob(jobs.paused)).state).toEqual("PAUSED");
97 yield* expectProjectGrants;
98 }),
99 {
100 tags: ["provider:gcp", "provider:gcp:cloudscheduler", "live"],
101 timeout: 600_000,
102 },
103 );
104 });
105
106 describe("ResumeJob", () => {
107 test.provider(
108 "resumes a paused job as the host's service account",
109 (_stack) =>
110 Effect.gen(function* () {
111 yield* scheduler.pauseProjectsLocationsJobs({
112 name: jobs.resumed,
113 });
114 expect((yield* getJob(jobs.resumed)).state).toEqual("PAUSED");
115 const job = yield* expectProbe<scheduler.Job>(baseUrl, "resume");
116 expect(job.name).toEqual(jobs.resumed);
117 expect(job.state).toEqual("ENABLED");
118 expect((yield* getJob(jobs.resumed)).state).toEqual("ENABLED");
119 yield* expectProjectGrants;
120 }),
121 {
122 tags: ["provider:gcp", "provider:gcp:cloudscheduler", "live"],
123 timeout: 600_000,
124 },
125 );
126 });
127
128 describe("RunJob", () => {
129 test.provider(
130 "forces a run as the host's service account",
131 (_stack) =>
132 Effect.gen(function* () {
133 const job = yield* expectProbe<scheduler.Job>(baseUrl, "run");
134 expect(job.name).toEqual(jobs.run);
135 // The forced attempt is recorded on the job asynchronously.
136 const after = yield* getJob(jobs.run).pipe(
137 Effect.repeat({
138 schedule: Schedule.spaced("5 seconds"),
139 until: (current) => current.lastAttemptTime !== undefined,
140 times: 18,
141 }),
142 );
143 expect(after.lastAttemptTime).toBeDefined();
144 yield* expectProjectGrants;
145 }),
146 {
147 tags: ["provider:gcp", "provider:gcp:cloudscheduler", "live"],
148 timeout: 600_000,
149 },
150 );
151 });
152 },
153);