#!/usr/bin/env python3 """Mint a build JWT for the docker build service.""" from __future__ import annotations import argparse import hashlib import os import re import sys import urllib.error import urllib.request from datetime import UTC, datetime, timedelta from pathlib import Path import jwt REPO_PATH = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") SAFE_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") MAX_DOCKERFILE_BYTES = 1024 * 1024 def _load_dockerfile_bytes(source: str) -> bytes: if source.startswith(("http://", "https://")): try: with urllib.request.urlopen(source, timeout=60) as response: data = response.read(MAX_DOCKERFILE_BYTES + 1) except urllib.error.URLError as exc: raise RuntimeError(f"failed to fetch --hash-from URL: {exc}") from exc else: path = Path(source) if not path.is_file(): raise RuntimeError(f"--hash-from not found: {source}") data = path.read_bytes() if len(data) > MAX_DOCKERFILE_BYTES: raise RuntimeError("Dockerfile exceeds 1 MB limit") return data def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--repo", required=True, help="Repository as owner/name on git.lschaefer.xyz", ) parser.add_argument( "--dockerfile", required=True, help="Dockerfile path inside the repo", ) hash_group = parser.add_mutually_exclusive_group(required=True) hash_group.add_argument( "--hash-from", help="Local Dockerfile path or http(s) URL to hash for the JWT", ) hash_group.add_argument( "--dockerfile-sha256", metavar="HEX", help="Dockerfile SHA-256 hex digest (64 chars) instead of hashing a file", ) parser.add_argument( "--files", nargs="+", metavar="PATH", help="Relative file paths for the JWT files claim (upload or combined builds)", ) parser.add_argument( "--secret", default=os.environ.get("JWT_SECRET"), help="HS256 secret (or set JWT_SECRET)", ) parser.add_argument("--ttl-minutes", type=int, default=15, help="Token lifetime") args = parser.parse_args() if not args.secret: print("error: provide --secret or JWT_SECRET", file=sys.stderr) return 1 if not REPO_PATH.match(args.repo.strip()): print("error: --repo must be owner/name", file=sys.stderr) return 1 if args.dockerfile_sha256 is not None: digest = args.dockerfile_sha256.strip().lower() if not SAFE_SHA256.match(digest): print("error: --dockerfile-sha256 must be a 64-character hex digest", file=sys.stderr) return 1 dockerfile_sha256 = digest else: try: contents = _load_dockerfile_bytes(args.hash_from) except RuntimeError as exc: print(f"error: {exc}", file=sys.stderr) return 1 dockerfile_sha256 = hashlib.sha256(contents).hexdigest() now = datetime.now(UTC) payload: dict[str, object] = { "repo": args.repo.strip(), "dockerfile": args.dockerfile, "dockerfile_sha256": dockerfile_sha256, "iat": now, "exp": now + timedelta(minutes=args.ttl_minutes), } if args.files: payload["files"] = args.files token = jwt.encode(payload, args.secret, algorithm="HS256") print(token) return 0 if __name__ == "__main__": raise SystemExit(main())