Files
site-publish/scripts/deploy.py
T
Donavan Fritz 8cc34552c6 strip docker type — site-publish is static-content only
Removes type: docker handling from action.yaml, scripts (build/deploy/utils/setup),
and templates (deployment.yaml.j2, service-docker.yaml.j2). Renamed
service-static.yaml.j2 -> service.yaml.j2.

If site.yaml has type: docker, parse_site_yaml() now dies with a clear message
pointing to action/image-build + action/image-push + action/image-deploy with
hand-authored apps-repo manifests. rainsounds.vino.network was the only docker
consumer and has already migrated.

Drops registry-password input from action.yaml (no longer needed).
2026-05-06 10:01:09 -05:00

170 lines
5.9 KiB
Python

"""Deploy phase — S3 sync, manifest rendering, alias reconcile."""
import json
import os
import shutil
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 render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg):
"""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,
}
render_templates(action_dir, template_vars, app_dir, manifests_dir)
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 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
deploy_static(site_name, site_dir, action_dir, token, cfg)