"""Deploy phase — S3 sync, manifest rendering, alias reconcile.""" import fnmatch import hashlib import json import mimetypes import os import re import shutil import subprocess import tempfile from pathlib import Path 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" ) 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 _existing_immutable_keys(endpoint, bucket, aws_env): """Enumerate publisher-owned immutable keys so route moves cannot delete them.""" args = ["aws", "--endpoint-url", endpoint, "s3api", "list-objects-v2", "--bucket", bucket, "--query", "Contents[].Key", "--output", "json"] result = _aws_capture(args, aws_env) if result.returncode != 0: error = f"{result.stdout}\n{result.stderr}".strip() raise RuntimeError(f"list-objects-v2 failed for s3://{bucket}: {error}") keys = json.loads(result.stdout) or [] immutable = [] for key in keys: info = _immutable_head(endpoint, bucket, key, aws_env) if info is None: continue directives = { part.strip().lower().split("=", 1)[0] for part in (info.get("CacheControl") or "").split(",") } if "immutable" in directives: immutable.append(key) return sorted(immutable) 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"(?