1import * as GCP from "alchemy/GCP";
2import * as Output from "alchemy/Output";
3import * as Effect from "effect/Effect";
4import Summarize from "./Summarize.ts";
5
6/**
7 * Runs {@link Summarize} every night at 02:00 UTC.
8 *
9 * Cloud Scheduler calls the Cloud Run Admin API's `jobs:run` endpoint
10 * directly, so no service sits between the schedule and the job. It
11 * authenticates with an OAuth token (not OIDC — the target is a
12 * `*.googleapis.com` API, not a Cloud Run URL) minted for the job's own
13 * runtime service account, which is granted `roles/run.invoker` on this
14 * one job — that role carries `run.jobs.run`.
15 */
16export const Nightly = Effect.gen(function* () {
17 const job = yield* Summarize;
18
19 const runner = yield* GCP.IAM.Member("NightlyRunner", {
20 kind: "run.job",
21 name: job.name,
22 role: "roles/run.invoker",
23 member: Output.interpolate`serviceAccount:${job.serviceAccount}`,
24 });
25
26 return yield* GCP.CloudScheduler.Job("Nightly", {
27 schedule: "0 2 * * *",
28 timeZone: "Etc/UTC",
29 description: "nightly order summary",
30 // A fresh grant can take a minute to propagate; a 403 on the first
31 // attempt is retried instead of skipping the night.
32 retryConfig: {
33 retryCount: 3,
34 minBackoffDuration: "30s",
35 maxBackoffDuration: "300s",
36 },
37 httpTarget: {
38 uri: Output.interpolate`https://run.googleapis.com/v2/${job.name}:run`,
39 httpMethod: "POST",
40 headers: { "Content-Type": "application/json" },
41 body: "{}",
42 oauthToken: {
43 // Reading the grant's member orders the scheduler after the grant.
44 serviceAccountEmail: Output.map(runner.member, (member) =>
45 member.replace(/^serviceAccount:/, ""),
46 ),
47 scope: "https://www.googleapis.com/auth/cloud-platform",
48 },
49 },
50 });
51});