"""Shared utilities for the site-publish action.""" import hashlib import os import re import shutil import subprocess import sys import tempfile from pathlib import Path from urllib.parse import urlsplit import yaml from jinja2 import Environment, FileSystemLoader, StrictUndefined from yaml import YAMLError APPS_REPO = "fritzlab/apps" GITEA_HOST = "code.fritzlab.net" NAMESPACE = "websites" DEFAULT_S3_ENDPOINT = "http://garage-s3.storage.svc:3900" EXCLUDE_FILES = { ".git", ".gitea", ".gitignore", "site.yaml", ".site-publish", "build", "Makefile", "README.md", "CLAUDE.md", "Dockerfile", ".dockerignore", "go.mod", "go.sum", } VALID_TYPES = {"static", "hugo", "mkdocs"} NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") MIDDLEWARE_RE = re.compile(r"^[A-Za-z0-9_.-]+$") PROFILE_RE = re.compile(r"^[a-z][a-z0-9-]*$") AUTH_MIDDLEWARE = "authentik-forwardauth" DOCKER_DEPRECATION_MSG = """\ type: docker is no longer supported by action/site-publish. site-publish handles only static-content sites (static, hugo, mkdocs) that ship to Garage S3. For containerized web apps, use the standard image-producer chain: - uses: action/image-build@v1 # build + smoke-test - uses: action/image-push@v1 # push + prune - uses: action/image-deploy@v1 # apps repo image-pin Hand-author your apps-repo manifests once (Deployment, Service, Ingress, Certificate, kustomization with images: block) under sjc001/websites//manifests/. image-deploy will pin the tag on every CI run. See action/image-deploy README and sjc001/websites/rainsounds.vino.network/manifests/ for the canonical example.\ """ def k8s_name(name): """Return a stable DNS-1035 label, including for long host names.""" normalized = re.sub(r"[^a-z0-9-]", "-", name.lower()).strip("-") normalized = re.sub(r"-+", "-", normalized) if len(normalized) <= 63: return normalized digest = hashlib.sha256(normalized.encode()).hexdigest()[:8] return f"{normalized[:54].rstrip('-')}-{digest}" def env(key, default=None): val = os.environ.get(key, default) if val is None or val == "": die(f"Missing required env var: {key}") return val def die(msg): print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) def run_args(args, *, display=None, **kwargs): """Run an argv vector without shell interpolation or credential logging.""" print(f" $ {display or ' '.join(args)}") return subprocess.run(args, check=True, **kwargs) def _string_list(cfg, key): value = cfg.get(key) or [] if not isinstance(value, list) or any(not isinstance(item, str) for item in value): die(f"{key} must be a list of strings") return value def _patterns(cfg, key, field): values = _string_list(cfg, key) if any(not value or "\n" in value or "\r" in value for value in values): die(f"{field} must contain non-empty single-line patterns") return values def _cors_origins(raw, artifact_name): origins = _string_list(raw, "cors_origins") for origin in origins: if origin == "*": continue parsed = urlsplit(origin) if ( parsed.scheme != "https" or not parsed.netloc or parsed.path not in {"", "/"} or parsed.query or parsed.fragment or parsed.username or parsed.password ): die(f"artifact {artifact_name}.cors_origins must contain * or HTTPS origins") return origins def _validate_domain(value, field): if not isinstance(value, str) or not value or len(value) > 253: die(f"{field} must be a DNS name") labels = value.rstrip(".").split(".") if any(not NAME_RE.fullmatch(label) for label in labels): die(f"{field} must be a DNS name") return value.rstrip(".") def _validate_middlewares(value, field): if not isinstance(value, list) or any( not isinstance(item, str) or not MIDDLEWARE_RE.fullmatch(item) for item in value ): die(f"{field} must contain file-provider middleware names") return value def _known_keys(value, allowed, field): unknown = sorted(set(value) - set(allowed)) if unknown: die(f"{field} has unknown fields: {', '.join(unknown)}") def _cache_header(value, field): if not isinstance(value, str) or not value or "\n" in value or "\r" in value: die(f"{field} must be one Cache-Control header value") return value def _cache_config(raw, artifact_name): cache = raw.get("cache") or {} if not isinstance(cache, dict): die(f"artifact {artifact_name}.cache must be a mapping") _known_keys(cache, {"default", "rules"}, f"artifact {artifact_name}.cache") default = _cache_header( cache.get("default", "public, max-age=0, must-revalidate"), f"artifact {artifact_name}.cache.default", ) rules_raw = cache.get("rules") or [] if not isinstance(rules_raw, list): die(f"artifact {artifact_name}.cache.rules must be a list") rules = [] patterns = set() for index, rule in enumerate(rules_raw): field = f"artifact {artifact_name}.cache.rules[{index}]" if not isinstance(rule, dict): die(f"{field} must be a mapping") _known_keys(rule, {"match", "value"}, field) pattern = rule.get("match") if ( not isinstance(pattern, str) or not pattern or pattern.startswith("/") or "\n" in pattern or "\r" in pattern or ".." in Path(pattern).parts ): die(f"{field}.match must be a relative aws-cli include pattern") if pattern in patterns: die(f"artifact {artifact_name} repeats cache match {pattern}") patterns.add(pattern) rules.append({ "match": pattern, "value": _cache_header(rule.get("value"), f"{field}.value"), }) return {"default": default, "rules": rules} def _cache_directives(value): return {part.strip().lower().split("=", 1)[0] for part in value.split(",")} def _modern_config(cfg, domain, aliases): artifacts_cfg = cfg.get("artifacts") routes_cfg = cfg.get("routes") if not isinstance(artifacts_cfg, dict) or not artifacts_cfg: die("artifacts must be a non-empty mapping") if not isinstance(routes_cfg, list) or not routes_cfg: die("routes must be a non-empty list") artifacts = {} buckets = set() for name, raw in artifacts_cfg.items(): if not isinstance(name, str) or not NAME_RE.fullmatch(name): die(f"artifact name {name!r} must be a DNS label") if not isinstance(raw, dict): die(f"artifact {name} must be a mapping") _known_keys( raw, {"source", "bucket", "credential", "endpoint", "cache", "cors_origins", "excludes"}, f"artifact {name}", ) source = raw.get("source") bucket = raw.get("bucket") credential = raw.get("credential", "default") if not isinstance(source, str) or not source or Path(source).is_absolute(): die(f"artifact {name}.source must be a relative path") if source in {".", "./"} or ".." in Path(source).parts: die(f"artifact {name}.source must name a build output inside the repository") bucket = _validate_domain(bucket, f"artifact {name}.bucket") if bucket in buckets: die(f"artifact bucket {bucket} is used more than once") buckets.add(bucket) if not isinstance(credential, str) or not PROFILE_RE.fullmatch(credential): die(f"artifact {name}.credential must be a lowercase profile name") endpoint = raw.get("endpoint") if endpoint is not None: parsed_endpoint = urlsplit(endpoint) if isinstance(endpoint, str) else None if ( parsed_endpoint is None or parsed_endpoint.scheme not in {"http", "https"} or not parsed_endpoint.netloc or parsed_endpoint.username or parsed_endpoint.password or parsed_endpoint.query or parsed_endpoint.fragment ): die(f"artifact {name}.endpoint must be an HTTP URL without credentials") cors_origins = _cors_origins(raw, name) artifacts[name] = { "name": name, "source": source, "bucket": bucket, "credential": credential, "cache": _cache_config(raw, name), "endpoint": endpoint, "cors_origins": cors_origins, "excludes": _patterns(raw, "excludes", f"artifact {name}.excludes"), } routes = [] route_names = set() route_paths = set() referenced = set() for raw in routes_cfg: if not isinstance(raw, dict): die("each route must be a mapping") _known_keys( raw, {"name", "path", "artifact", "access", "middlewares"}, "route", ) name = raw.get("name") path = raw.get("path") artifact = raw.get("artifact") access = raw.get("access") if not isinstance(name, str) or not NAME_RE.fullmatch(name): die("each route.name must be a DNS label") if name in route_names: die(f"route name {name} is used more than once") route_names.add(name) if not isinstance(path, str) or not re.fullmatch( r"/(?:[A-Za-z0-9._~-]+(?:/[A-Za-z0-9._~-]+)*)?", path ): die(f"route {name}.path must be a canonical absolute URL path") if path != "/" and path.endswith("/"): die(f"route {name}.path must not end with /") if "//" in path or ".." in Path(path).parts or "?" in path or "#" in path: die(f"route {name}.path is not a canonical URL path") if path in route_paths: die(f"route path {path} is used more than once") route_paths.add(path) if artifact not in artifacts: die(f"route {name} references unknown artifact {artifact!r}") referenced.add(artifact) if access not in {"public", "protected"}: die(f"route {name}.access must be public or protected") route_middlewares = _validate_middlewares( raw.get("middlewares") or [], f"route {name}.middlewares" ) if access == "protected": route_middlewares = [AUTH_MIDDLEWARE, *route_middlewares] if len(set(route_middlewares)) != len(route_middlewares): die(f"route {name}.middlewares contains a duplicate") routes.append({ "name": name, "path": path, "artifact": artifact, "access": access, "middlewares": route_middlewares, }) unreferenced = sorted(set(artifacts) - referenced) if unreferenced: die(f"artifacts without routes: {', '.join(unreferenced)}") for artifact_name, artifact in artifacts.items(): access_modes = { route["access"] for route in routes if route["artifact"] == artifact_name } if len(access_modes) != 1: die(f"artifact {artifact_name} cannot cross public and protected routes") access = next(iter(access_modes)) policies = [artifact["cache"]["default"], *( rule["value"] for rule in artifact["cache"]["rules"] )] for policy in policies: directives = _cache_directives(policy) if access == "protected" and not ({"private", "no-store"} & directives): die(f"protected artifact {artifact_name} cache policy must be private or no-store") if access == "protected" and ({"public", "s-maxage"} & directives): die(f"protected artifact {artifact_name} cannot use shared-cache directives") if access == "public" and "private" in directives: die(f"public artifact {artifact_name} cannot use private cache metadata") if access == "protected" and "*" in artifact["cors_origins"]: die(f"protected artifact {artifact_name} cannot allow wildcard CORS") profile_access = {} for artifact_name, artifact in artifacts.items(): access = next( route["access"] for route in routes if route["artifact"] == artifact_name ) existing = profile_access.setdefault(artifact["credential"], access) if existing != access: die( f"credential profile {artifact['credential']} cannot cross public and protected artifacts" ) return { "mode": "multi", "domain": domain, "aliases": aliases, "artifacts": artifacts, "routes": routes, } def parse_site_yaml(site_dir): path = Path(site_dir) / "site.yaml" if not path.exists(): die("site.yaml not found in repo root") try: with open(path, encoding="utf-8") as f: cfg = yaml.safe_load(f) except YAMLError as error: die(f"site.yaml is not valid YAML: {error}") if not isinstance(cfg, dict): die("site.yaml must contain a mapping") if not cfg.get("domain"): die("domain is required in site.yaml") domain = _validate_domain(cfg["domain"], "domain") aliases = [ _validate_domain(value, "aliases entry") for value in _string_list(cfg, "aliases") ] if len(set([domain, *aliases])) != 1 + len(aliases): die("domain and aliases must be unique") has_artifacts = "artifacts" in cfg has_routes = "routes" in cfg if has_artifacts != has_routes: die("artifacts and routes must be declared together") if has_artifacts: _known_keys( cfg, {"domain", "aliases", "enabled", "artifacts", "routes"}, "site.yaml", ) site = _modern_config(cfg, domain, aliases) site["type"] = "artifacts" site["enabled"] = cfg.get("enabled", True) if not isinstance(site["enabled"], bool): die("enabled must be true or false") site["tidy"] = False site["content_dir"] = "" site["excludes"] = [] site["middlewares"] = [] print("Site config:") print(f" domain: {site['domain']}") print(f" artifacts: {', '.join(site['artifacts'])}") print(f" routes: {', '.join(route['path'] for route in site['routes'])}") return site site_type = cfg.get("type", "static") if site_type == "docker": die(DOCKER_DEPRECATION_MSG) if site_type not in VALID_TYPES: die(f"Unknown site type: {site_type} (valid: {', '.join(sorted(VALID_TYPES))})") excludes = _string_list(cfg, "excludes") middlewares = _validate_middlewares(cfg.get("middlewares") or [], "middlewares") content_dir = cfg.get("content_dir", "") if not isinstance(content_dir, str) or Path(content_dir).is_absolute(): die("content_dir must be a relative path") if ".." in Path(content_dir).parts: die("content_dir cannot escape the repository") enabled = cfg.get("enabled", True) if not isinstance(enabled, bool): die("enabled must be true or false") site = { "mode": "legacy", "domain": domain, "type": site_type, "enabled": enabled, "aliases": aliases, "content_dir": content_dir, "tidy": cfg.get("tidy", True), "excludes": excludes, "middlewares": middlewares, } print("Site config:") for k, v in site.items(): print(f" {k}: {v}") return site def clone_apps(token): user = env("CI_BOT_USER", "ci-bot") clone_root = Path(tempfile.mkdtemp(prefix="apps-deploy-")) apps_dir = clone_root / "repo" askpass = clone_root / "askpass" askpass.write_text( "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$GIT_AUTH_USER\" ;; " "*) printf '%s\\n' \"$GIT_AUTH_TOKEN\" ;; esac\n", encoding="utf-8", ) askpass.chmod(0o700) git_env = git_auth_environment(token, user, askpass) try: run_args( ["git", "clone", "--depth", "1", f"https://{GITEA_HOST}/{APPS_REPO}.git", str(apps_dir)], display=f"git clone --depth 1 https://{GITEA_HOST}/{APPS_REPO}.git {apps_dir}", env=git_env, ) except Exception: shutil.rmtree(clone_root, ignore_errors=True) raise run_args(["git", "-C", str(apps_dir), "config", "user.name", user]) run_args(["git", "-C", str(apps_dir), "config", "user.email", f"{user}@fritzlab.net"]) return apps_dir def git_auth_environment(token, user, askpass): child_env = os.environ.copy() child_env["GIT_ASKPASS"] = str(askpass) child_env["GIT_TERMINAL_PROMPT"] = "0" child_env["GIT_AUTH_USER"] = user child_env["GIT_AUTH_TOKEN"] = token return child_env def render_templates(action_dir, template_vars, app_dir, manifests_dir): """Render Jinja2 templates for a static-content site.""" templates_dir = Path(action_dir) / "templates" jinja_env = Environment( loader=FileSystemLoader(str(templates_dir)), keep_trailing_newline=True, undefined=StrictUndefined, ) tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2", "kustomization.yaml.j2", "service.yaml.j2"] for tmpl_name in tmpl_names: tmpl = jinja_env.get_template(tmpl_name) rendered = tmpl.render(**template_vars) out_name = tmpl_name.replace(".j2", "") dest = app_dir / out_name if tmpl_name == "app.yaml.j2" else manifests_dir / out_name dest.write_text(rendered) print(f" Rendered {tmpl_name} -> {dest}") def commit_and_push(apps_dir, message, token): run_args(["git", "-C", str(apps_dir), "add", "-A"]) result = subprocess.run( ["git", "-C", str(apps_dir), "diff", "--cached", "--quiet"], check=False, ) if result.returncode == 0: print("No manifest changes to commit") return False run_args(["git", "-C", str(apps_dir), "commit", "-m", message]) git_env = git_auth_environment( token, env("CI_BOT_USER", "ci-bot"), apps_dir.parent / "askpass", ) run_args(["git", "-C", str(apps_dir), "push"], env=git_env) print("Manifests pushed — ArgoCD will sync") return True