Files
site-publish/scripts/deploy.py
T

256 lines
8.6 KiB
Python
Raw Normal View History

2026-05-06 08:07:28 -05:00
"""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)