initial: action/site-publish @v1

This commit is contained in:
Donavan Fritz
2026-05-06 08:07:28 -05:00
commit d01c3bcc43
15 changed files with 1087 additions and 0 deletions
+107
View File
@@ -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 <site>`** — remove a site's manifests from apps repo. Bucket purge is manual.
## Architecture
```
push to websites/<repo>
→ 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.
+64
View File
@@ -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 }}
Executable
+170
View File
@@ -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 <name> --domain <domain> [--type static|hugo|mkdocs]
#
# Requires: tea CLI configured; kubectl with sjc001 context (for bucket setup).
usage() {
echo "Usage: $0 --name <name> --domain <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 <<EOF | kubectl --context sjc001 -n storage apply -f - >/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'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome</title>
</head>
<body>
<h1>Hello from fritzlab</h1>
<p>Served from Garage S3.</p>
</body>
</html>
HTML
cat > site.yaml <<YAML
domain: ${DOMAIN}
type: static
content_dir: html
YAML
;;
hugo)
hugo new site . --force
cat > site.yaml <<YAML
domain: ${DOMAIN}
type: hugo
YAML
;;
mkdocs)
cat > mkdocs.yml <<YAML
site_name: ${NAME}
theme:
name: material
YAML
mkdir -p docs
cat > docs/index.md <<MD
# ${NAME}
Welcome.
MD
cat > site.yaml <<YAML
domain: ${DOMAIN}
type: mkdocs
YAML
;;
esac
# --- CI workflow ---
mkdir -p .gitea/workflows
cat > .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."
+98
View File
@@ -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)
+255
View File
@@ -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)
+41
View File
@@ -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 <site-name>
"""
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 <site-name>")
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()
+49
View File
@@ -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")
+140
View File
@@ -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
+22
View File
@@ -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
+15
View File
@@ -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 %}
+52
View File
@@ -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"]
+41
View File
@@ -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 %}
+9
View File
@@ -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
+13
View File
@@ -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
+11
View File
@@ -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