Source code for pudl.scripts.batch_config

#! /usr/bin/env python
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "click>=8.4",
# ]
# ///
"""Generate a Google Batch Job configuration file.

This runs on bare GitHub Actions runners, without the full pixi/pudl environment -- the
inline script metadata above lets ``uv run`` install just the handful of dependencies
this script actually needs (stdlib plus ``click``) into an ephemeral environment, rather
than requiring a full pudl install first.

The ``--container-*`` flags are named after their equivalents in ``gcloud compute
instances update-container``.
"""

import json
import logging
import shutil
import subprocess
from pathlib import Path
from typing import Any

import click

logging.basicConfig()
[docs] logger = logging.getLogger(__name__)
[docs] DEFAULT_MACHINE_TYPE = "c4d-standard-8"
[docs] DEFAULT_DISK_GB = 250
[docs] DEFAULT_DISK_TYPE = "hyperdisk-balanced"
[docs] def _parse_container_env(container_env: tuple[str, ...]) -> dict[str, str]: """Parse --container-env KEY=VALUE pairs into a dict. Raises if the same key is given more than once. """ env_dict: dict[str, str] = {} for pair in sorted(container_env): name, value = pair.split("=", maxsplit=1) if name in env_dict: raise ValueError(f"Duplicate --container-env key: {name!r}") env_dict[name] = value.strip('"') return env_dict
[docs] def _lookup_machine_spec(machine_type: str) -> tuple[int, int]: """Return ``(cpuMilli, memoryMib)`` for a real GCE machine type, via ``gcloud``. Batch's ``computeResource.cpuMilli``/``memoryMib`` default to 2000/2000 (2 vCPU, 2 GB) if left unset -- regardless of the machine type pinned in ``allocationPolicy``. This looks up the real values and fills them in so the job doesn't lie about the resources it has available. """ gcloud_path = shutil.which("gcloud") if gcloud_path is None: raise click.ClickException( "gcloud CLI not found -- looking up machine type resources requires an " "authenticated gcloud (see google-github-actions/setup-gcloud)." ) try: result = subprocess.run( # noqa: S603 [ gcloud_path, "compute", "machine-types", "list", "--filter", f"name={machine_type}", "--limit", "1", "--format", "json", ], capture_output=True, text=True, check=True, ) except subprocess.CalledProcessError as exc: raise click.ClickException( f"gcloud lookup for machine type {machine_type!r} failed: {exc.stderr}" ) from exc matches = json.loads(result.stdout) if not matches: raise click.ClickException( f"No machine type found matching {machine_type!r}. Check the spelling, " "or that it's offered in at least one zone." ) return matches[0]["guestCpus"] * 1000, matches[0]["memoryMb"]
[docs] def to_config( *, container_image: str, container_env: tuple[str, ...], container_command: str, container_arg: tuple[str, ...], machine_type: str, cpu_milli: int, memory_mib: int, disk_gb: int, disk_type: str, batch_job_id: str, pipeline: str, ) -> dict[str, Any]: """Munge arguments into a configuration dictionary.""" if not container_image: raise ValueError("container_image is required") if not container_command: raise ValueError("container_command is required") env_dict = _parse_container_env(container_env) # NOTE (daz): the best documentation of the actual data structure I've found is at # https://cloud.google.com/python/docs/reference/batch/latest/google.cloud.batch_v1.types.Job return { "taskGroups": [ { "taskSpec": { "runnables": [ { "container": { "imageUri": container_image, "commands": [container_command, *container_arg], }, "environment": {"variables": env_dict}, }, ], "computeResource": { "cpuMilli": cpu_milli, "memoryMib": memory_mib, "bootDiskMib": disk_gb * 1024, }, "maxRunDuration": f"{60 * 60 * 12}s", } } ], "allocationPolicy": { "serviceAccount": { "email": "deploy-pudl-vm-service-account@catalyst-cooperative-pudl.iam.gserviceaccount.com" }, # Explicitly set rather than relying on Batch to auto-label VM instances # with the job ID. Allows dashboards to group per-VM metrics by job. # "pipeline" is (by convention) the name of the GitHub Actions workflow that # launched the job, e.g. "build-pudl", "deploy-pudl", # "build-deploy-ferceqr"). This lets a single dashboard switch between # pipelines via a template variable presented in a dropdown menu. "labels": {"batch-job-id": batch_job_id, "pipeline": pipeline}, "instances": [ { "installOpsAgent": True, "policy": { "machineType": machine_type, # Batch's default boot image is Container-Optimized OS, but # Google's own installOpsAgent bootstrap script only supports # Debian/CentOS/Rocky (it shells out to apt/yum, neither of # which exist on COS) Pin the Debian image explicitly so # installOpsAgent actually works. "bootDisk": { "image": "batch-debian", "type": disk_type, "sizeGb": str(disk_gb), }, }, } ], }, "logsPolicy": {"destination": "CLOUD_LOGGING"}, # Batch copies these job-level labels onto every `batch_task_logs` entry (as # `labels.<key>`), unlike the `allocationPolicy` instance labels above which # only surface on VM metrics. Repeating `pipeline` here lets the dashboard's # Logs widget filter by pipeline via the `${pipeline}` template variable, # matching the behavior of the metric widgets. "labels": { "component": "build", "pipeline": pipeline, }, }
@click.command(context_settings={"help_option_names": ["-h", "--help"]}) @click.option("--container-image", required=True) @click.option("--container-command", required=True) @click.option( "--container-env", multiple=True, default=(), help="A KEY=VALUE container environment variable. Repeat for multiple.", ) @click.option( "--container-arg", multiple=True, default=(), help="A container command argument. Repeat, in order, for multiple.", ) @click.option( "--machine-type", default=DEFAULT_MACHINE_TYPE, show_default=True, help="GCE machine type to run the job on (e.g. c2d-highmem-16).", ) @click.option( "--disk-gb", default=DEFAULT_DISK_GB, show_default=True, type=int, help="Size of the boot disk, in GB, to attach to the VM.", ) @click.option( "--disk-type", default=DEFAULT_DISK_TYPE, show_default=True, help="Boot disk type (e.g. pd-ssd, pd-balanced, hyperdisk-balanced).", ) @click.option( "--batch-job-id", required=True, help=( "Value for the batch-job-id label attached to created VM instances, used " "to group per-VM Cloud Monitoring metrics by job. Should match the job " "name passed to `gcloud batch jobs submit`." ), ) @click.option( "--pipeline", required=True, help=( "Value for the pipeline label attached to created VM instances and task " "logs. Use the name of the launching GitHub Actions workflow (e.g. " "build-pudl, deploy-pudl, build-deploy-ferceqr), used to switch the " "resource-usage dashboard between pipelines via a template variable." ), ) @click.option( "--output", required=True, type=click.Path(path_type=Path), help="Path to write the generated Batch job JSON config to.", )
[docs] def main( container_image: str, container_command: str, container_env: tuple[str, ...], container_arg: tuple[str, ...], machine_type: str, disk_gb: int, disk_type: str, batch_job_id: str, pipeline: str, output: Path, ) -> None: """Generate a Batch configuration file.""" cpu_milli, memory_mib = _lookup_machine_spec(machine_type) config = to_config( container_image=container_image, container_command=container_command, container_env=container_env, container_arg=container_arg, machine_type=machine_type, cpu_milli=cpu_milli, memory_mib=memory_mib, disk_gb=disk_gb, disk_type=disk_type, batch_job_id=batch_job_id, pipeline=pipeline, ) logger.info(f"Writing to {output}") with output.open("w") as f: f.write(json.dumps(config, indent=2))
if __name__ == "__main__": main()