This commit is contained in:
+369
-32
@@ -1,13 +1,18 @@
|
||||
"""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
|
||||
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
||||
from yaml import YAMLError
|
||||
|
||||
APPS_REPO = "fritzlab/apps"
|
||||
GITEA_HOST = "code.fritzlab.net"
|
||||
@@ -16,11 +21,15 @@ DEFAULT_S3_ENDPOINT = "http://garage-s3.storage.svc:3900"
|
||||
|
||||
EXCLUDE_FILES = {
|
||||
".git", ".gitea", ".gitignore", "site.yaml",
|
||||
"build", "Makefile", "README.md", "CLAUDE.md",
|
||||
".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.
|
||||
@@ -43,13 +52,18 @@ example.\
|
||||
|
||||
|
||||
def k8s_name(name):
|
||||
"""Sanitize for DNS-1035 label (dots → dashes)."""
|
||||
return name.replace(".", "-")
|
||||
"""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:
|
||||
if val is None or val == "":
|
||||
die(f"Missing required env var: {key}")
|
||||
return val
|
||||
|
||||
@@ -59,9 +73,258 @@ def die(msg):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
print(f" $ {cmd}")
|
||||
return subprocess.run(cmd, shell=True, check=True, **kwargs)
|
||||
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):
|
||||
@@ -69,12 +332,50 @@ def parse_site_yaml(site_dir):
|
||||
if not path.exists():
|
||||
die("site.yaml not found in repo root")
|
||||
|
||||
with open(path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
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":
|
||||
@@ -83,20 +384,26 @@ def parse_site_yaml(site_dir):
|
||||
if site_type not in VALID_TYPES:
|
||||
die(f"Unknown site type: {site_type} (valid: {', '.join(sorted(VALID_TYPES))})")
|
||||
|
||||
excludes = cfg.get("excludes") or []
|
||||
if not isinstance(excludes, list) or any(not isinstance(p, str) for p in excludes):
|
||||
die("excludes must be a list of string patterns")
|
||||
excludes = _string_list(cfg, "excludes")
|
||||
middlewares = _validate_middlewares(cfg.get("middlewares") or [], "middlewares")
|
||||
|
||||
middlewares = cfg.get("middlewares") or []
|
||||
if not isinstance(middlewares, list) or any(not isinstance(m, str) for m in middlewares):
|
||||
die("middlewares must be a list of Traefik file-provider middleware names")
|
||||
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 = {
|
||||
"domain": cfg["domain"],
|
||||
"mode": "legacy",
|
||||
"domain": domain,
|
||||
"type": site_type,
|
||||
"enabled": cfg.get("enabled", True),
|
||||
"aliases": cfg.get("aliases") or [],
|
||||
"content_dir": cfg.get("content_dir", ""),
|
||||
"enabled": enabled,
|
||||
"aliases": aliases,
|
||||
"content_dir": content_dir,
|
||||
"tidy": cfg.get("tidy", True),
|
||||
"excludes": excludes,
|
||||
"middlewares": middlewares,
|
||||
@@ -110,21 +417,46 @@ def parse_site_yaml(site_dir):
|
||||
|
||||
def clone_apps(token):
|
||||
user = env("CI_BOT_USER", "ci-bot")
|
||||
apps_dir = Path("/tmp/apps-deploy")
|
||||
if apps_dir.exists():
|
||||
shutil.rmtree(apps_dir)
|
||||
run(f"git clone --depth 1 https://{user}:{token}@{GITEA_HOST}/{APPS_REPO}.git {apps_dir}")
|
||||
run(f"git -C {apps_dir} config user.name {user}")
|
||||
run(f"git -C {apps_dir} config user.email {user}@fritzlab.net")
|
||||
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",
|
||||
@@ -139,16 +471,21 @@ def render_templates(action_dir, template_vars, app_dir, manifests_dir):
|
||||
print(f" Rendered {tmpl_name} -> {dest}")
|
||||
|
||||
|
||||
def commit_and_push(apps_dir, message):
|
||||
run(f"git -C {apps_dir} add -A")
|
||||
def commit_and_push(apps_dir, message, token):
|
||||
run_args(["git", "-C", str(apps_dir), "add", "-A"])
|
||||
result = subprocess.run(
|
||||
f"git -C {apps_dir} diff --cached --quiet",
|
||||
shell=True, check=False,
|
||||
["git", "-C", str(apps_dir), "diff", "--cached", "--quiet"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print("No manifest changes to commit")
|
||||
return False
|
||||
run(f"git -C {apps_dir} commit -m '{message}'")
|
||||
run(f"git -C {apps_dir} push")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user