From d01c3bcc43b3b5c11d96f1d544a41196a7825df3 Mon Sep 17 00:00:00 2001 From: Donavan Fritz Date: Wed, 6 May 2026 08:07:28 -0500 Subject: [PATCH] initial: action/site-publish @v1 --- README.md | 107 +++++++++++++ action.yaml | 64 ++++++++ new-site.sh | 170 +++++++++++++++++++++ scripts/build.py | 98 ++++++++++++ scripts/deploy.py | 255 +++++++++++++++++++++++++++++++ scripts/publish.py | 41 +++++ scripts/setup.py | 49 ++++++ scripts/utils.py | 140 +++++++++++++++++ templates/app.yaml.j2 | 22 +++ templates/certificate.yaml.j2 | 15 ++ templates/deployment.yaml.j2 | 52 +++++++ templates/ingress.yaml.j2 | 41 +++++ templates/kustomization.yaml.j2 | 9 ++ templates/service-docker.yaml.j2 | 13 ++ templates/service-static.yaml.j2 | 11 ++ 15 files changed, 1087 insertions(+) create mode 100644 README.md create mode 100644 action.yaml create mode 100755 new-site.sh create mode 100644 scripts/build.py create mode 100644 scripts/deploy.py create mode 100644 scripts/publish.py create mode 100644 scripts/setup.py create mode 100644 scripts/utils.py create mode 100644 templates/app.yaml.j2 create mode 100644 templates/certificate.yaml.j2 create mode 100644 templates/deployment.yaml.j2 create mode 100644 templates/ingress.yaml.j2 create mode 100644 templates/kustomization.yaml.j2 create mode 100644 templates/service-docker.yaml.j2 create mode 100644 templates/service-static.yaml.j2 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4221c10 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# action/site-publish + +Composite Gitea Action that publishes a website to the fritzlab k8s cluster. +Supports `static`, `hugo`, `mkdocs` (content → Garage S3 + ExternalName Service) +and `docker` (Dockerfile → Deployment + headless Service). Handles manifest +rendering, TLS via cert-manager, and Garage bucket aliases. + +Renamed from `fritzlab/publish-site` → `action/site-publish` as part of the +2026 action-org consolidation. + +## Convention + +Bucket name = repo name = canonical domain. Sibling hostnames (e.g. `www.`, +`ipv6.`) are declared as `aliases:` in `site.yaml` — the action registers each +as a Garage `globalAlias` on the bucket and adds it to the Ingress + Certificate +on every deploy. Manual edits to manifests in the apps repo are clobbered; +edit `site.yaml` instead. + +## Usage + +Scaffold a new site (handles repo creation + Garage bucket): + +```sh +./new-site.sh --name my-site.vino.network --domain my-site.vino.network --type static +``` + +Or do it manually. `site.yaml`: + +```yaml +domain: my-site.vino.network +type: static # static | hugo | mkdocs | docker +# content_dir: html # subdirectory containing content (default: repo root) +# aliases: # additional hostnames (each gets a globalAlias on the bucket) +# - www.my-site.vino.network +# tidy: true # set false to skip HTML tidy +# enabled: true # set false to decommission +``` + +`.gitea/workflows/publish.yaml`: + +```yaml +name: Publish +on: + push: + branches: [main] +jobs: + publish: + runs-on: fritzlab + steps: + - uses: actions/checkout@v4 + - uses: https://code.fritzlab.net/action/site-publish@v1 + with: + token: ${{ secrets.CI_BOT_TOKEN }} + s3-access-key: ${{ secrets.GARAGE_S3_ACCESS_KEY }} + s3-secret-key: ${{ secrets.GARAGE_S3_SECRET_KEY }} + garage-admin-token: ${{ secrets.GARAGE_ADMIN_TOKEN }} +``` + +DNS: subdomains of `vino.network` are covered by the wildcard CNAME to +`traefik.edge.svc…`. For other zones, add an explicit CNAME: + +``` +my-site.fritzlab.net 300 IN CNAME traefik.edge.svc.k8s.sjc001.fritzlab.net. +``` + +## Inputs + +| Input | Required | Default | Description | +|---|---|---|---| +| `token` | yes | | Gitea token for apps repo push | +| `s3-access-key` | static/hugo/mkdocs | | Garage `ci-deploy-key` access key id | +| `s3-secret-key` | static/hugo/mkdocs | | Garage `ci-deploy-key` secret key | +| `s3-endpoint` | no | `http://garage.storage.svc:3900` | Garage S3 endpoint | +| `garage-admin-token` | static/hugo/mkdocs (only if site has `aliases`) | | Garage admin API token (`admin-token` from `garage-rpc-secret` in `storage` ns) | +| `garage-admin-endpoint` | no | `http://garage.storage.svc:3903` | Garage admin API endpoint | +| `registry-password` | docker | inputs.token | Container registry password | +| `username` | no | `ci-bot` | Gitea username | + +Org secrets in `websites`: `CI_BOT_TOKEN`, `GARAGE_S3_ACCESS_KEY`, +`GARAGE_S3_SECRET_KEY`, `GARAGE_ADMIN_TOKEN`. + +## Tools + +- **`new-site.sh`** — create a new site: Gitea repo, Garage bucket, web hosting enabled. +- **`scripts/publish.py decommission `** — remove a site's manifests from apps repo. Bucket purge is manual. + +## Architecture + +``` +push to websites/ + → CI runs site-publish action + → reads site.yaml, builds content (static copy / hugo / mkdocs), runs tidy + → aws s3 sync → Garage bucket named after the repo + → admin API: ensures every alias from site.yaml is a globalAlias on the bucket + → renders manifests in fritzlab/apps from templates: ExternalName Service → + garage.storage.svc, Traefik Ingress (canonical + aliases), cert-manager + Certificate (canonical + aliases as SANs), kustomization + → commits + pushes apps repo only if diff is non-empty + → ArgoCD syncs → site live with TLS +``` + +The Ingress + Certificate are re-rendered on every deploy from `site.yaml`. +There is no "first-deploy vs. update" branching — every deploy is idempotent. + +No nginx pods, no per-site Docker images. Garage matches `Host:` header to +bucket name (or any of its globalAliases), so every site shares a single +ExternalName target. diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..7e5122d --- /dev/null +++ b/action.yaml @@ -0,0 +1,64 @@ +name: Publish Site +description: Build and deploy a site (static, hugo, mkdocs, or docker) to fritzlab k8s. +inputs: + token: + description: Gitea token (ci-bot) for apps repo push and API operations + required: true + s3-access-key: + description: Garage ci-deploy-key access key id (required for static/hugo/mkdocs) + required: false + s3-secret-key: + description: Garage ci-deploy-key secret access key (required for static/hugo/mkdocs) + required: false + registry-password: + description: Container registry password (required for docker type; defaults to token) + required: false + s3-endpoint: + description: Garage S3 endpoint URL + required: false + default: http://garage.storage.svc:3900 + garage-admin-token: + description: Garage admin API token (required for static/hugo/mkdocs to reconcile bucket aliases) + required: false + garage-admin-endpoint: + description: Garage admin API endpoint URL + required: false + default: http://garage.storage.svc:3903 + username: + description: Gitea username for git operations + required: false + default: ci-bot +runs: + using: composite + steps: + - name: Setup + shell: bash + run: python3 ${{ github.action_path }}/scripts/setup.py + + - name: Build + shell: bash + run: python3 ${{ github.action_path }}/scripts/publish.py build + env: + SITE_REPO: ${{ github.repository }} + SITE_DIR: ${{ github.workspace }} + ACTION_DIR: ${{ github.action_path }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + REGISTRY_PASSWORD: ${{ inputs.registry-password || inputs.token }} + CI_BOT_USER: ${{ inputs.username }} + + - name: Deploy + shell: bash + run: python3 ${{ github.action_path }}/scripts/publish.py deploy + env: + SITE_REPO: ${{ github.repository }} + SITE_DIR: ${{ github.workspace }} + ACTION_DIR: ${{ github.action_path }} + CI_BOT_TOKEN: ${{ inputs.token }} + CI_BOT_USER: ${{ inputs.username }} + AWS_ACCESS_KEY_ID: ${{ inputs.s3-access-key }} + AWS_SECRET_ACCESS_KEY: ${{ inputs.s3-secret-key }} + AWS_DEFAULT_REGION: sjc001 + GARAGE_S3_ENDPOINT: ${{ inputs.s3-endpoint }} + GARAGE_ADMIN_ENDPOINT: ${{ inputs.garage-admin-endpoint }} + GARAGE_ADMIN_TOKEN: ${{ inputs.garage-admin-token }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} diff --git a/new-site.sh b/new-site.sh new file mode 100755 index 0000000..21cb685 --- /dev/null +++ b/new-site.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +# new-site.sh — Create a new static site: Gitea repo + Garage bucket. +# +# Usage: ./new-site.sh --name --domain [--type static|hugo|mkdocs] +# +# Requires: tea CLI configured; kubectl with sjc001 context (for bucket setup). + +usage() { + echo "Usage: $0 --name --domain [--type static|hugo|mkdocs]" >&2 + exit 1 +} + +NAME="" +DOMAIN="" +TYPE="static" + +while [ $# -gt 0 ]; do + case "$1" in + --name) NAME="$2"; shift 2 ;; + --domain) DOMAIN="$2"; shift 2 ;; + --type) TYPE="$2"; shift 2 ;; + *) usage ;; + esac +done + +[ -z "$NAME" ] || [ -z "$DOMAIN" ] && usage + +ORG="websites" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +# --- Garage bucket setup (one-off, via in-cluster Job) --- +echo "Creating Garage bucket '${NAME}' and enabling web hosting..." +cat </dev/null +apiVersion: batch/v1 +kind: Job +metadata: + name: bucket-setup-${NAME//./-} + namespace: storage +spec: + ttlSecondsAfterFinished: 120 + backoffLimit: 0 + template: + spec: + restartPolicy: Never + containers: + - name: setup + image: alpine:3.20 + command: [/bin/sh, -euc] + args: + - | + apk add --no-cache curl jq >/dev/null + H=http://garage.storage.svc:3903 + AUTH="Authorization: Bearer \$TOKEN" + # ci-deploy-key id is fixed per cluster — update if re-keyed + CI_KID=GKbe265a2b8ea53695ae734787 + BUCKET=${NAME} + # Create bucket (idempotent: 409 if exists) + curl -s -X POST -H "\$AUTH" -H "Content-Type: application/json" \ + -d "{\"globalAlias\":\"\$BUCKET\"}" "\$H/v2/CreateBucket" -o /dev/null + BID=\$(curl -sf -H "\$AUTH" "\$H/v2/GetBucketInfo?globalAlias=\$BUCKET" | jq -r .id) + echo "bucket id: \$BID" + curl -s -X POST -H "\$AUTH" -H "Content-Type: application/json" \ + -d "{\"bucketId\":\"\$BID\",\"accessKeyId\":\"\$CI_KID\",\"permissions\":{\"read\":true,\"write\":true,\"owner\":false}}" \ + "\$H/v2/AllowBucketKey" -o /dev/null + curl -s -X POST -H "\$AUTH" -H "Content-Type: application/json" \ + -d '{"websiteAccess":{"enabled":true,"indexDocument":"index.html","errorDocument":"404.html"}}' \ + "\$H/v2/UpdateBucket?id=\$BID" -o /dev/null + curl -sf -H "\$AUTH" "\$H/v2/GetBucketInfo?globalAlias=\$BUCKET" | jq '{websiteAccess, keys: (.keys | map(.name))}' + env: + - name: TOKEN + valueFrom: + secretKeyRef: + name: garage-rpc-secret + key: admin-token +EOF +kubectl --context sjc001 -n storage wait --for=condition=complete --timeout=60s job/bucket-setup-${NAME//./-} +kubectl --context sjc001 -n storage logs job/bucket-setup-${NAME//./-} +kubectl --context sjc001 -n storage delete job bucket-setup-${NAME//./-} --ignore-not-found >/dev/null + +# --- Gitea repo --- +echo "Creating Gitea repo ${ORG}/${NAME}..." +tea repo create --owner "$ORG" --name "$NAME" --init + +echo "Cloning..." +git clone "ssh://git@code.fritzlab.net/${ORG}/${NAME}.git" "$WORK_DIR" +cd "$WORK_DIR" + +# --- Starter content + site.yaml --- +case "$TYPE" in + static) + mkdir -p html + cat > html/index.html <<'HTML' + + + + + + Welcome + + +

Hello from fritzlab

+

Served from Garage S3.

+ + +HTML + cat > site.yaml < site.yaml < mkdocs.yml < docs/index.md < site.yaml < .gitea/workflows/publish.yaml <<'WORKFLOW' +name: Publish +on: + push: + branches: [main] +jobs: + publish: + runs-on: fritzlab + steps: + - uses: actions/checkout@v4 + - uses: https://code.fritzlab.net/fritzlab/publish-static-site@v1 + with: + token: ${{ secrets.CI_BOT_TOKEN }} + s3-access-key: ${{ secrets.GARAGE_S3_ACCESS_KEY }} + s3-secret-key: ${{ secrets.GARAGE_S3_SECRET_KEY }} +WORKFLOW + +git add -A +git commit -m "Initial site scaffold" +git push + +echo +echo "Site created: ${ORG}/${NAME}" +echo "First build will trigger on push." +echo +echo "DNS: ${DOMAIN} is covered by the *.vino.network wildcard (→ traefik.edge)." +echo "For a domain outside vino.network, add an explicit CNAME:" +echo " ${DOMAIN} 300 IN CNAME traefik.edge.svc.k8s.sjc001.fritzlab.net." diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..cc11e6b --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,98 @@ +"""Build phase — content prep (static) or docker image build.""" + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from utils import EXCLUDE_FILES, die, env, parse_site_yaml, run + + +def build_static(site_dir, cfg): + build_dir = site_dir / "build" + html_dir = build_dir / "html" + + if build_dir.exists(): + shutil.rmtree(build_dir) + + content_dir = cfg["content_dir"] + src = site_dir / content_dir if content_dir else site_dir + + if cfg["type"] == "static": + print(f"Copying static content from {src}") + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "html" + shutil.copytree(src, tmp_path, dirs_exist_ok=True) + for name in EXCLUDE_FILES: + p = tmp_path / name + if p.is_dir(): + shutil.rmtree(p) + elif p.exists(): + p.unlink() + build_dir.mkdir(parents=True) + shutil.move(str(tmp_path), str(html_dir)) + + elif cfg["type"] == "hugo": + print(f"Building Hugo site from {src}") + run(f"hugo --source {src} --destination {html_dir}") + + elif cfg["type"] == "mkdocs": + print(f"Building MkDocs site from {src}") + run(f"cd {src} && mkdocs build -d {html_dir}") + + if cfg.get("tidy", True): + print("Running tidy on HTML files...") + for html_file in html_dir.rglob("*.html"): + subprocess.run( + ["tidy", "-modify", "-quiet", + "--wrap", "0", "--indent", "auto", "--indent-spaces", "2", + "--drop-empty-elements", "no", "--tidy-mark", "no", + "--show-warnings", "no", str(html_file)], + check=False, + ) + + print(f"Build complete — content at {html_dir}") + + +def docker_login(): + password = os.environ.get("REGISTRY_PASSWORD", "") + user = env("CI_BOT_USER", "ci-bot") + if not password: + die("REGISTRY_PASSWORD is required for docker builds") + subprocess.run( + f"echo '{password}' | docker login code.fritzlab.net -u {user} --password-stdin", + shell=True, check=True, + ) + + +def build_docker(site_dir, cfg): + docker_login() + image = cfg["image"] + run_number = env("GITHUB_RUN_NUMBER", "0") + tags = [f"{image}:latest", f"{image}:{run_number}"] + + cmd_parts = ["docker", "build"] + for tag in tags: + cmd_parts += ["-t", tag] + cmd_parts += ["--network", "host", "--provenance=false"] + for key, val in cfg.get("build_args", {}).items(): + cmd_parts += ["--build-arg", f"{key}={val}"] + cmd_parts.append(str(site_dir)) + + run(" ".join(cmd_parts)) + print(f"Docker build complete — tags: {', '.join(tags)}") + + +def cmd_build(): + site_dir = Path(env("SITE_DIR")) + cfg = parse_site_yaml(site_dir) + + if not cfg["enabled"]: + print("Site disabled — skipping build") + return + + if cfg["type"] == "docker": + build_docker(site_dir, cfg) + else: + build_static(site_dir, cfg) diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..6c41aac --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,255 @@ +"""Deploy phase — S3 sync or docker push, manifest rendering, alias reconcile.""" + +import json +import os +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 ( + DEFAULT_S3_ENDPOINT, + GITEA_HOST, + NAMESPACE, + clone_apps, + commit_and_push, + die, + env, + k8s_name, + parse_site_yaml, + render_templates, + run, +) + +GARAGE_ADMIN_ENDPOINT = os.environ.get( + "GARAGE_ADMIN_ENDPOINT", "http://garage.storage.svc:3903" +) + + +CACHE_CONTROL = "public, max-age=0, must-revalidate" + + +def s3_sync(site_name, site_dir): + endpoint = os.environ.get("GARAGE_S3_ENDPOINT", DEFAULT_S3_ENDPOINT) + html_dir = site_dir / "build" / "html" + if not html_dir.exists(): + die(f"build/html not found — did the build step run? ({html_dir})") + env("AWS_ACCESS_KEY_ID") + env("AWS_SECRET_ACCESS_KEY") + os.environ.setdefault("AWS_DEFAULT_REGION", "sjc001") + print(f"Syncing {html_dir} → s3://{site_name} via {endpoint}") + # `sync --delete` handles new/changed/orphaned files. `cp --recursive` + # then re-uploads everything to refresh metadata (cache-control, + # content-type) on objects sync skipped because nothing changed. + # Cost: a no-op deploy still re-uploads every byte. Sites here are + # small enough that that's free; correctness wins over throughput. + # AWS CLI guesses Content-Type from file extension on local→S3 uploads, + # so a fresh upload always carries the right MIME type. + run( + f"aws --endpoint-url {endpoint} s3 sync {html_dir}/ s3://{site_name}/ " + f"--delete --only-show-errors " + f"--cache-control '{CACHE_CONTROL}'" + ) + print("Re-stamping metadata on all objects...") + run( + f"aws --endpoint-url {endpoint} s3 cp {html_dir}/ s3://{site_name}/ " + f"--recursive --only-show-errors " + f"--cache-control '{CACHE_CONTROL}'" + ) + + +def garage_admin(method, path, token, body=None): + url = f"{GARAGE_ADMIN_ENDPOINT}{path}" + data = json.dumps(body).encode() if body is not None else None + headers = {"Authorization": f"Bearer {token}"} + if data is not None: + headers["Content-Type"] = "application/json" + req = Request(url, data=data, method=method, headers=headers) + with urlopen(req) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + + +def ensure_bucket_aliases(site_name, aliases, admin_token): + """Add cfg['aliases'] as Garage globalAliases on the site bucket. + + Idempotent: skips aliases already present. Never removes aliases not in + the desired set (safety — orphan removal is manual). + """ + if not aliases: + return + if not admin_token: + print(" (no GARAGE_ADMIN_TOKEN — skipping bucket alias reconcile)") + return + + try: + info = garage_admin("GET", f"/v2/GetBucketInfo?globalAlias={site_name}", + admin_token) + except (HTTPError, URLError) as e: + print(f" WARNING: bucket lookup failed: {e}") + return + + bucket_id = info.get("id") + existing = set(info.get("globalAliases") or []) + print(f" Bucket {site_name} ({bucket_id[:12]}…) currently aliases: {sorted(existing)}") + + for alias in aliases: + if alias in existing: + continue + print(f" Adding globalAlias: {alias}") + try: + garage_admin("POST", "/v2/AddBucketAlias", admin_token, + {"bucketId": bucket_id, "globalAlias": alias}) + except HTTPError as e: + body = e.read().decode(errors="replace") if hasattr(e, "read") else "" + print(f" ERROR adding alias {alias}: {e} {body}") + raise + + +def docker_push(cfg): + image = cfg["image"] + run_number = env("GITHUB_RUN_NUMBER", "0") + for tag in [f"{image}:latest", f"{image}:{run_number}"]: + run(f"docker push {tag}") + + +def docker_tag_cleanup(cfg, token): + """Keep the 3 newest numeric tags, delete the rest via Gitea API.""" + image = cfg["image"] + parts = image.split("/") + if len(parts) < 3: + print(f"Cannot parse image for tag cleanup: {image}") + return + org = parts[1] + pkg_name = parts[2] + + url = f"https://{GITEA_HOST}/api/v1/packages/{org}?type=container" + req = Request(url, headers={"Authorization": f"token {token}"}) + try: + with urlopen(req) as resp: + packages = json.loads(resp.read()) + except Exception as e: + print(f"Warning: failed to list packages for cleanup: {e}") + return + + numeric_versions = [] + for pkg in packages: + if pkg.get("name") == pkg_name: + ver = pkg.get("version", "") + if ver.isdigit(): + numeric_versions.append(int(ver)) + + numeric_versions.sort() + to_delete = numeric_versions[:-3] if len(numeric_versions) > 3 else [] + + for ver in to_delete: + del_url = f"https://{GITEA_HOST}/api/v1/packages/{org}/container/{pkg_name}/{ver}" + print(f" Deleting {pkg_name}:{ver}") + req = Request(del_url, method="DELETE", headers={"Authorization": f"token {token}"}) + try: + urlopen(req) + except Exception as e: + print(f" Warning: failed to delete {pkg_name}:{ver}: {e}") + + if to_delete: + print(f" Cleaned up {len(to_delete)} old tags") + else: + print(" No old tags to clean up") + + +def docker_cleanup(): + run("docker system prune --all --force") + + +def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, extra=None): + """Always re-render manifests from current site.yaml. Templates own + domain + aliases, so changes propagate without manual edits.""" + manifests_dir.mkdir(parents=True, exist_ok=True) + template_vars = { + "site": site_name, + "site_k8s": k8s_name(site_name), + "domain": cfg["domain"], + "aliases": cfg["aliases"], + "namespace": NAMESPACE, + "site_type": cfg["type"], + } + if extra: + template_vars.update(extra) + render_templates(action_dir, template_vars, app_dir, manifests_dir, cfg["type"]) + + +def deploy_static(site_name, site_dir, action_dir, token, cfg): + s3_sync(site_name, site_dir) + ensure_bucket_aliases(site_name, cfg["aliases"], os.environ.get("GARAGE_ADMIN_TOKEN")) + + apps_dir = clone_apps(token) + app_dir = apps_dir / "sjc001" / "websites" / site_name + manifests_dir = app_dir / "manifests" + + render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg) + + commit_and_push(apps_dir, f"Deploy {site_name}") + + +def deploy_docker(site_name, site_dir, action_dir, token, cfg): + docker_push(cfg) + + run_number = env("GITHUB_RUN_NUMBER", "0") + image = cfg["image"] + apps_dir = clone_apps(token) + app_dir = apps_dir / "sjc001" / "websites" / site_name + manifests_dir = app_dir / "manifests" + + render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg, extra={ + "image": image, + "port": cfg["port"], + "health_path": cfg["health_path"], + "replicas": cfg["replicas"], + }) + # Pin to this build's tag (kustomize edit appends an `images:` override). + run(f"cd {manifests_dir} && kustomize edit set image {image}={image}:{run_number}") + + commit_and_push(apps_dir, f"Deploy {site_name} #{run_number}") + + docker_tag_cleanup(cfg, token) + docker_cleanup() + + +def decommission(site_name, token): + """Remove manifests from apps repo.""" + user = env("CI_BOT_USER", "ci-bot") + with tempfile.TemporaryDirectory() as tmp: + apps_dir = Path(tmp) + run(f"git clone --depth 1 https://{user}:{token}@{GITEA_HOST}/fritzlab/apps.git {apps_dir}") + site_path = apps_dir / "sjc001" / "websites" / site_name + if not site_path.exists(): + print(f"No manifests for {site_name} — nothing to remove") + return + shutil.rmtree(site_path) + run(f"git -C {apps_dir} config user.name {user}") + run(f"git -C {apps_dir} config user.email {user}@fritzlab.net") + commit_and_push(apps_dir, f"Decommission {site_name}") + print(f"Bucket {site_name} and its objects are NOT purged automatically.") + print(f" garage bucket delete {site_name} --yes") + + +def cmd_deploy(): + site_repo = env("SITE_REPO") + site_dir = Path(env("SITE_DIR")) + action_dir = Path(env("ACTION_DIR")) + token = env("CI_BOT_TOKEN") + site_name = site_repo.split("/", 1)[1] + + cfg = parse_site_yaml(site_dir) + + if not cfg["enabled"]: + print("Site disabled — running decommission...") + decommission(site_name, token) + return + + if cfg["type"] == "docker": + deploy_docker(site_name, site_dir, action_dir, token, cfg) + else: + deploy_static(site_name, site_dir, action_dir, token, cfg) diff --git a/scripts/publish.py b/scripts/publish.py new file mode 100644 index 0000000..283b67c --- /dev/null +++ b/scripts/publish.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Entry point for publish-site action. + +Usage: + python3 publish.py build + python3 publish.py deploy + python3 publish.py decommission +""" + +import os +import sys + +from utils import die + + +def main(): + if len(sys.argv) < 2: + die("Usage: publish.py {build|deploy|decommission}") + + cmd = sys.argv[1] + + if cmd == "build": + from build import cmd_build + cmd_build() + elif cmd == "deploy": + from deploy import cmd_deploy + cmd_deploy() + elif cmd == "decommission": + if len(sys.argv) < 3: + die("Usage: publish.py decommission ") + from deploy import decommission + token = os.environ.get("GITEA_TOKEN") or os.environ.get("CI_BOT_TOKEN") + if not token: + die("Set GITEA_TOKEN or CI_BOT_TOKEN") + decommission(sys.argv[2], token) + else: + die(f"Unknown command: {cmd}") + + +if __name__ == "__main__": + main() diff --git a/scripts/setup.py b/scripts/setup.py new file mode 100644 index 0000000..528ac0f --- /dev/null +++ b/scripts/setup.py @@ -0,0 +1,49 @@ +"""Ensure required CLI tools are installed.""" + +import os +import shutil +import subprocess +import sys + + +def ensure_aws(): + if shutil.which("aws"): + subprocess.run(["aws", "--version"], check=True) + return + print("Installing awscli...") + subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "--break-system-packages", "awscli"], + check=False, + ) + local_bin = os.path.expanduser("~/.local/bin") + os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}" + github_path = os.environ.get("GITHUB_PATH") + if github_path: + with open(github_path, "a") as f: + f.write(f"{local_bin}\n") + subprocess.run(["aws", "--version"], check=True) + + +def ensure_docker(): + if shutil.which("docker"): + subprocess.run(["docker", "version", "--format", "{{.Client.Version}}"], check=True) + return + print("WARNING: docker CLI not found — docker builds will fail", file=sys.stderr) + + +def ensure_jinja2(): + try: + import jinja2 + except ImportError: + print("Installing jinja2 + pyyaml...") + subprocess.run( + [sys.executable, "-m", "pip", "install", "--quiet", "--break-system-packages", "jinja2", "pyyaml"], + check=True, + ) + + +if __name__ == "__main__": + ensure_jinja2() + ensure_aws() + ensure_docker() + print("Setup complete") diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..75ba94e --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,140 @@ +"""Shared utilities for the publish-site action.""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import yaml +from jinja2 import Environment, FileSystemLoader + +APPS_REPO = "fritzlab/apps" +GITEA_HOST = "code.fritzlab.net" +NAMESPACE = "websites" +DEFAULT_S3_ENDPOINT = "http://garage.storage.svc:3900" + +EXCLUDE_FILES = { + ".git", ".gitea", ".gitignore", "site.yaml", + "build", "Makefile", "README.md", "CLAUDE.md", + "Dockerfile", ".dockerignore", "go.mod", "go.sum", +} + +VALID_TYPES = {"static", "hugo", "mkdocs", "docker"} + + +def k8s_name(name): + """Sanitize for DNS-1035 label (dots → dashes).""" + return name.replace(".", "-") + + +def env(key, default=None): + val = os.environ.get(key, default) + if val is None: + die(f"Missing required env var: {key}") + return val + + +def die(msg): + print(f"ERROR: {msg}", file=sys.stderr) + sys.exit(1) + + +def run(cmd, **kwargs): + print(f" $ {cmd}") + return subprocess.run(cmd, shell=True, check=True, **kwargs) + + +def parse_site_yaml(site_dir): + path = Path(site_dir) / "site.yaml" + if not path.exists(): + die("site.yaml not found in repo root") + + with open(path) as f: + cfg = yaml.safe_load(f) + + if not cfg.get("domain"): + die("domain is required in site.yaml") + + site_type = cfg.get("type", "static") + if site_type not in VALID_TYPES: + die(f"Unknown site type: {site_type} (valid: {', '.join(sorted(VALID_TYPES))})") + + site = { + "domain": cfg["domain"], + "type": site_type, + "enabled": cfg.get("enabled", True), + "aliases": cfg.get("aliases") or [], + } + + if site_type == "docker": + if not cfg.get("image"): + die("image is required in site.yaml for type: docker") + site["image"] = cfg["image"] + site["port"] = cfg.get("port", 8080) + site["build_args"] = cfg.get("build_args") or {} + site["health_path"] = cfg.get("health_path", "/healthz") + site["replicas"] = cfg.get("replicas", 1) + else: + site["content_dir"] = cfg.get("content_dir", "") + site["tidy"] = cfg.get("tidy", True) + + 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") + 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") + return apps_dir + + +def render_templates(action_dir, template_vars, app_dir, manifests_dir, site_type): + """Render Jinja2 templates, selecting the right set for the site type.""" + templates_dir = Path(action_dir) / "templates" + jinja_env = Environment( + loader=FileSystemLoader(str(templates_dir)), + keep_trailing_newline=True, + ) + + if site_type == "docker": + service_tmpl = "service-docker.yaml.j2" + tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2", + "kustomization.yaml.j2", service_tmpl, "deployment.yaml.j2"] + else: + service_tmpl = "service-static.yaml.j2" + tmpl_names = ["app.yaml.j2", "certificate.yaml.j2", "ingress.yaml.j2", + "kustomization.yaml.j2", service_tmpl] + + for tmpl_name in tmpl_names: + tmpl = jinja_env.get_template(tmpl_name) + rendered = tmpl.render(**template_vars) + out_name = tmpl_name.replace(".j2", "") + # service-docker.yaml / service-static.yaml → service.yaml + if out_name.startswith("service-"): + out_name = "service.yaml" + 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): + run(f"git -C {apps_dir} add -A") + result = subprocess.run( + f"git -C {apps_dir} diff --cached --quiet", + shell=True, 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") + print("Manifests pushed — ArgoCD will sync") + return True diff --git a/templates/app.yaml.j2 b/templates/app.yaml.j2 new file mode 100644 index 0000000..25f4bee --- /dev/null +++ b/templates/app.yaml.j2 @@ -0,0 +1,22 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ site }} + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + source: + repoURL: ssh://git@code.fritzlab.net/fritzlab/apps.git + targetRevision: main + path: sjc001/websites/{{ site }}/manifests + destination: + server: https://kubernetes.default.svc + namespace: {{ namespace }} + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true diff --git a/templates/certificate.yaml.j2 b/templates/certificate.yaml.j2 new file mode 100644 index 0000000..fb9b1ac --- /dev/null +++ b/templates/certificate.yaml.j2 @@ -0,0 +1,15 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ site_k8s }}-tls + namespace: {{ namespace }} +spec: + secretName: {{ site_k8s }}-tls + issuerRef: + name: letsencrypt + kind: ClusterIssuer + dnsNames: + - {{ domain }} +{%- for alias in aliases %} + - {{ alias }} +{%- endfor %} diff --git a/templates/deployment.yaml.j2 b/templates/deployment.yaml.j2 new file mode 100644 index 0000000..b49f94b --- /dev/null +++ b/templates/deployment.yaml.j2 @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ site_k8s }} + namespace: {{ namespace }} + labels: + app: {{ site_k8s }} +spec: + replicas: {{ replicas }} + strategy: + type: Recreate + selector: + matchLabels: + app: {{ site_k8s }} + template: + metadata: + labels: + app: {{ site_k8s }} + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: {{ site_k8s }} + image: {{ image }}:latest + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ port }} + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 256Mi + livenessProbe: + httpGet: {path: {{ health_path }}, port: http} + periodSeconds: 30 + timeoutSeconds: 3 + readinessProbe: + httpGet: {path: {{ health_path }}, port: http} + periodSeconds: 10 + timeoutSeconds: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] diff --git a/templates/ingress.yaml.j2 b/templates/ingress.yaml.j2 new file mode 100644 index 0000000..d4e3478 --- /dev/null +++ b/templates/ingress.yaml.j2 @@ -0,0 +1,41 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ site_k8s }} + namespace: {{ namespace }} +{%- if site_type != "docker" %} + annotations: + traefik.ingress.kubernetes.io/router.middlewares: retry-upstream@file +{%- endif %} +spec: + ingressClassName: traefik + tls: + - hosts: + - {{ domain }} +{%- for alias in aliases %} + - {{ alias }} +{%- endfor %} + secretName: {{ site_k8s }}-tls + rules: + - host: {{ domain }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ site_k8s }} + port: + number: 80 +{%- for alias in aliases %} + - host: {{ alias }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ site_k8s }} + port: + number: 80 +{%- endfor %} diff --git a/templates/kustomization.yaml.j2 b/templates/kustomization.yaml.j2 new file mode 100644 index 0000000..eff8360 --- /dev/null +++ b/templates/kustomization.yaml.j2 @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +{% if site_type == "docker" %} +- deployment.yaml +{% endif %} +- service.yaml +- ingress.yaml +- certificate.yaml diff --git a/templates/service-docker.yaml.j2 b/templates/service-docker.yaml.j2 new file mode 100644 index 0000000..e8703dc --- /dev/null +++ b/templates/service-docker.yaml.j2 @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ site_k8s }} + namespace: {{ namespace }} +spec: + clusterIP: None + selector: + app: {{ site_k8s }} + ports: + - name: http + port: 80 + targetPort: http diff --git a/templates/service-static.yaml.j2 b/templates/service-static.yaml.j2 new file mode 100644 index 0000000..d425207 --- /dev/null +++ b/templates/service-static.yaml.j2 @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ site_k8s }} + namespace: {{ namespace }} +spec: + type: ExternalName + externalName: garage.storage.svc.k8s.sjc001.fritzlab.net + ports: + - port: 80 + targetPort: 80