"""Deploy phase — S3 sync, manifest rendering, alias reconcile.""" import fnmatch import hashlib import json import mimetypes import os import re import shutil import subprocess from pathlib import Path, PurePosixPath from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from utils import ( NAMESPACE, clone_apps, commit_and_push, die, env, k8s_name, parse_site_yaml, render_templates, run, validate_artifact_inputs, ) GARAGE_ADMIN_ENDPOINT = os.environ.get( "GARAGE_ADMIN_ENDPOINT", "http://garage.storage.svc:3903" ) HISTORY_FILE = "site-publish-history.json" def validate_artifact_output(site_dir, artifact): """Prove every artifact and declared cache prefix exists before publishing any.""" html_dir = site_dir / artifact["build_dir"] if not html_dir.is_dir() or not any(path.is_file() for path in html_dir.rglob("*")): die(f"artifact {artifact['name']} build output is absent or empty: {html_dir}") for rule in artifact["cache_rules"]: if not rule["path"]: continue cache_root = html_dir / rule["path"] if not cache_root.exists() or not any(path.is_file() for path in cache_root.rglob("*")): die(f"artifact {artifact['name']} cache path /{rule['path']} has no built files") def validate_publication_environment(cfg): """Resolve every declared credential before the first bucket is changed.""" for artifact in cfg["artifacts"]: env(artifact["credentials"]["access_key_env"]) env(artifact["credentials"]["secret_key_env"]) if cfg["compatibility"] and cfg["aliases"] and not os.environ.get("GARAGE_ADMIN_TOKEN"): die("GARAGE_ADMIN_TOKEN is required when aliases are declared") def _is_immutable(rule): return "immutable" in { part.strip().lower().split("=", 1)[0] for part in rule["cache_control"].split(",") } def _aws_capture(args, aws_env): """Run a non-streaming AWS request without exposing environment credentials.""" print(f" $ {' '.join(str(part) for part in args)}") return subprocess.run(args, env=aws_env, text=True, capture_output=True, check=False) def _immutable_head(endpoint, bucket, key, aws_env): args = ["aws", "--endpoint-url", endpoint, "s3api", "head-object", "--bucket", bucket, "--key", key, "--output", "json"] result = _aws_capture(args, aws_env) if result.returncode == 0: return json.loads(result.stdout) error = f"{result.stdout}\n{result.stderr}" if any(marker in error for marker in ("404", "Not Found", "NoSuchKey")): return None raise RuntimeError(f"head-object failed for s3://{bucket}/{key}: {error.strip()}") def _immutable_digests(source, cache_control, content_type): with source.open("rb") as stream: content_digest = hashlib.file_digest(stream, "sha256").hexdigest() publication = hashlib.sha256() publication.update(cache_control.encode()) publication.update(b"\0") publication.update(content_type.encode()) publication.update(b"\0") with source.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): publication.update(block) return content_digest, publication.hexdigest() def _same_immutable_object(info, content_digest, publication_digest, cache_control, content_type): metadata = {key.lower(): value for key, value in (info.get("Metadata") or {}).items()} return ( metadata.get("sha256") == content_digest and metadata.get("publication-sha256") == publication_digest and info.get("CacheControl") == cache_control and info.get("ContentType") == content_type ) def publish_immutable_file(endpoint, bucket, key, source, cache_control, aws_env): """Publish a content-addressed key; identical retries converge.""" content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream" content_digest, publication_digest = _immutable_digests(source, cache_control, content_type) address_digests = re.findall(r"(?