116 lines
3.5 KiB
Python
116 lines
3.5 KiB
Python
#!/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())
|