2026-05-06 10:01:09 -05:00
|
|
|
"""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"
|
2026-05-28 10:04:24 -05:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 10:01:09 -05:00
|
|
|
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"
|
2026-05-06 10:01:09 -05:00
|
|
|
|
|
|
|
|
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")
|
2026-05-06 10:01:09 -05:00
|
|
|
|
|
|
|
|
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-05-28 10:12:10 -05:00
|
|
|
|
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-06-12 13:01:05 +00:00
|
|
|
|
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,
|
2026-05-06 10:01:09 -05:00
|
|
|
"tidy": cfg.get("tidy", True),
|
2026-05-28 10:12:10 -05:00
|
|
|
"excludes": excludes,
|
2026-06-12 13:01:05 +00:00
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 10:01:09 -05:00
|
|
|
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
|
|
|
)
|
|
|
|
|
|
2026-05-06 10:01:09 -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
|