Files
site-publish/scripts/utils.py
T

492 lines
18 KiB
Python
Raw Normal View History

"""Shared utilities for the site-publish action."""
2026-05-06 08:07:28 -05:00
2026-08-29 21:28:28 +00:00
import hashlib
2026-05-06 08:07:28 -05:00
import os
2026-08-29 21:28:28 +00:00
import re
2026-05-06 08:07:28 -05:00
import shutil
import subprocess
import sys
2026-08-29 21:28:28 +00:00
import tempfile
2026-05-06 08:07:28 -05:00
from pathlib import Path
2026-08-29 21:28:28 +00:00
from urllib.parse import urlsplit
2026-05-06 08:07:28 -05:00
import yaml
2026-08-29 21:28:28 +00:00
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from yaml import YAMLError
2026-05-06 08:07:28 -05:00
APPS_REPO = "fritzlab/apps"
GITEA_HOST = "code.fritzlab.net"
NAMESPACE = "websites"
DEFAULT_S3_ENDPOINT = "http://garage-s3.storage.svc:3900"
2026-05-06 08:07:28 -05:00
EXCLUDE_FILES = {
".git", ".gitea", ".gitignore", "site.yaml",
2026-08-29 21:28:28 +00:00
".site-publish", "build", "Makefile", "README.md", "CLAUDE.md",
2026-05-06 08:07:28 -05:00
"Dockerfile", ".dockerignore", "go.mod", "go.sum",
}
VALID_TYPES = {"static", "hugo", "mkdocs"}
2026-08-29 21:28:28 +00:00
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/<repo>/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.\
"""
2026-05-06 08:07:28 -05:00
def k8s_name(name):
2026-08-29 21:28:28 +00:00
"""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}"
2026-05-06 08:07:28 -05:00
def env(key, default=None):
val = os.environ.get(key, default)
2026-08-29 21:28:28 +00:00
if val is None or val == "":
2026-05-06 08:07:28 -05:00
die(f"Missing required env var: {key}")
return val
def die(msg):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
2026-08-29 21:28:28 +00:00
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,
}
2026-05-06 08:07:28 -05:00
def parse_site_yaml(site_dir):
path = Path(site_dir) / "site.yaml"
if not path.exists():
die("site.yaml not found in repo root")
2026-08-29 21:28:28 +00:00
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}")
2026-05-06 08:07:28 -05:00
2026-08-29 21:28:28 +00:00
if not isinstance(cfg, dict):
die("site.yaml must contain a mapping")
2026-05-06 08:07:28 -05:00
if not cfg.get("domain"):
die("domain is required in site.yaml")
2026-08-29 21:28:28 +00:00
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
2026-05-06 08:07:28 -05:00
site_type = cfg.get("type", "static")
if site_type == "docker":
die(DOCKER_DEPRECATION_MSG)
2026-05-06 08:07:28 -05:00
if site_type not in VALID_TYPES:
die(f"Unknown site type: {site_type} (valid: {', '.join(sorted(VALID_TYPES))})")
2026-08-29 21:28:28 +00:00
excludes = _string_list(cfg, "excludes")
middlewares = _validate_middlewares(cfg.get("middlewares") or [], "middlewares")
2026-08-29 21:28:28 +00:00
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")
2026-05-06 08:07:28 -05:00
site = {
2026-08-29 21:28:28 +00:00
"mode": "legacy",
"domain": domain,
2026-05-06 08:07:28 -05:00
"type": site_type,
2026-08-29 21:28:28 +00:00
"enabled": enabled,
"aliases": aliases,
"content_dir": content_dir,
"tidy": cfg.get("tidy", True),
"excludes": excludes,
"middlewares": middlewares,
2026-05-06 08:07:28 -05:00
}
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")
2026-08-29 21:28:28 +00:00
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"])
2026-05-06 08:07:28 -05:00
return apps_dir
2026-08-29 21:28:28 +00:00
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."""
2026-05-06 08:07:28 -05:00
templates_dir = Path(action_dir) / "templates"
jinja_env = Environment(
loader=FileSystemLoader(str(templates_dir)),
keep_trailing_newline=True,
2026-08-29 21:28:28 +00:00
undefined=StrictUndefined,
2026-05-06 08:07:28 -05:00
)
tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2",
"kustomization.yaml.j2", "service.yaml.j2"]
2026-05-06 08:07:28 -05:00
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}")
2026-08-29 21:28:28 +00:00
def commit_and_push(apps_dir, message, token):
run_args(["git", "-C", str(apps_dir), "add", "-A"])
2026-05-06 08:07:28 -05:00
result = subprocess.run(
2026-08-29 21:28:28 +00:00
["git", "-C", str(apps_dir), "diff", "--cached", "--quiet"],
check=False,
2026-05-06 08:07:28 -05:00
)
if result.returncode == 0:
print("No manifest changes to commit")
return False
2026-08-29 21:28:28 +00:00
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)
2026-05-06 08:07:28 -05:00
print("Manifests pushed — ArgoCD will sync")
return True