feat(delivery): publish multiple surfaces
Test / test (pull_request) Failing after 5s

Authored-By: OpenAI (GPT-5) <noreply@openai.com>
This commit is contained in:
Dave Kowalski
2026-08-29 21:21:47 +00:00
parent f1f780f5a3
commit 099a48f2b5
13 changed files with 826 additions and 309 deletions
+158 -109
View File
@@ -1,71 +1,132 @@
"""Deploy phase — S3 sync, manifest rendering, alias reconcile."""
"""Deploy phase — artifact publication and split-route manifest rendering."""
import fnmatch
import hashlib
import json
import mimetypes
import os
import shlex
import shutil
import tempfile
import subprocess
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
try:
from botocore.auth import S3SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
except ModuleNotFoundError: # aws-cli v1's bundled botocore layout
from awscli.botocore.auth import S3SigV4Auth
from awscli.botocore.awsrequest import AWSRequest
from awscli.botocore.credentials import Credentials
from utils import (
DEFAULT_S3_ENDPOINT,
GITEA_HOST,
NAMESPACE,
clone_apps,
commit_and_push,
die,
env,
k8s_name,
parse_site_yaml,
render_templates,
run,
DEFAULT_S3_ENDPOINT, GARAGE_WEBSITE_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"
)
"GARAGE_ADMIN_ENDPOINT", "http://garage.storage.svc:3903")
CACHE_CONTROL = "public, max-age=0, must-revalidate"
def _aws_env(artifact):
credentials = artifact["credentials"]
access = env(credentials["access_key_env"])
secret = env(credentials["secret_key_env"])
child = os.environ.copy()
child.update({"AWS_ACCESS_KEY_ID": access, "AWS_SECRET_ACCESS_KEY": secret,
"AWS_DEFAULT_REGION": os.environ.get("AWS_DEFAULT_REGION", "sjc001")})
return child
def s3_sync(site_name, site_dir, excludes=None):
def _aws(endpoint, operation):
return ["aws", "--endpoint-url", endpoint, *operation]
def _excluded(relative, patterns):
return any(fnmatch.fnmatch(relative, pattern) for pattern in patterns)
def _conditional_put(endpoint, artifact, key, path, content_type, digest):
"""Atomically create one S3 object without exposing signing credentials."""
selectors = artifact["credentials"]
credentials = Credentials(env(selectors["access_key_env"]),
env(selectors["secret_key_env"]))
region = os.environ.get("AWS_DEFAULT_REGION", "sjc001")
url = f"{endpoint.rstrip('/')}/{quote(artifact['bucket'], safe='')}/{quote(key)}"
data = path.read_bytes()
request = AWSRequest(method="PUT", url=url, data=data, headers={
"Content-Type": content_type,
"Cache-Control": artifact["cache_control"],
"x-amz-meta-sha256": digest,
"If-None-Match": "*",
})
S3SigV4Auth(credentials, "s3", region).add_auth(request)
signed = Request(url, data=data, method="PUT", headers=dict(request.headers.items()))
try:
with urlopen(signed) as response:
response.read()
except HTTPError as error:
if error.code == 412:
die(f"immutable artifact {artifact['name']}: concurrent object creation: {key}; "
"rerun to verify identical content")
die(f"immutable artifact {artifact['name']}: upload failed for {key}: {error}")
def _immutable_sync(source, artifact, endpoint, excludes):
"""Publish write-once objects; identical retries are no-ops."""
aws_env = _aws_env(artifact)
bucket = artifact["bucket"]
for path in sorted(item for item in source.rglob("*") if item.is_file()):
relative = path.relative_to(source).as_posix()
if _excluded(relative, excludes):
continue
key = "/".join(part for part in (artifact.get("key_prefix"), relative) if part)
digest = hashlib.sha256(path.read_bytes()).hexdigest()
head = subprocess.run(
_aws(endpoint, ["s3api", "head-object", "--bucket", bucket, "--key", key]),
env=aws_env, check=False, text=True, capture_output=True)
if head.returncode == 0:
metadata = json.loads(head.stdout)
if (metadata.get("Metadata") or {}).get("sha256") != digest:
die(f"immutable artifact {artifact['name']}: object changed: {key}")
if metadata.get("CacheControl") != artifact["cache_control"]:
die(f"immutable artifact {artifact['name']}: cache metadata changed: {key}")
print(f" Immutable object unchanged: s3://{bucket}/{key}")
continue
missing = head.stderr.lower()
if not any(marker in missing for marker in ("404", "not found", "nosuchkey")):
die(f"immutable artifact {artifact['name']}: head-object failed for {key}: "
f"{head.stderr.strip()}")
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
_conditional_put(endpoint, artifact, key, path, content_type, digest)
def _replaceable_sync(source, artifact, endpoint, excludes):
aws_env = _aws_env(artifact)
prefix = artifact.get("key_prefix")
destination = f"s3://{artifact['bucket']}/{prefix + '/' if prefix else ''}"
exclude_args = [part for pattern in excludes for part in ("--exclude", pattern)]
common = ["--only-show-errors", "--cache-control", artifact["cache_control"],
*exclude_args]
run(_aws(endpoint, ["s3", "sync", f"{source}/", destination,
"--delete", *common]), env=aws_env)
run(_aws(endpoint, ["s3", "cp", f"{source}/", destination,
"--recursive", *common]), env=aws_env)
def publish_artifact(build_root, artifact, excludes=None):
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")
# `excludes` are patterns (site.yaml `excludes:` list) that should never
# be uploaded *and* should never be deleted from the bucket — escape hatch
# for assets managed out-of-band (e.g. large PDFs uploaded via aws-cli).
exclude_flags = " ".join(f"--exclude {shlex.quote(p)}" for p in (excludes or []))
if excludes:
print(f"Excluding patterns: {excludes}")
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}' "
f"{exclude_flags}".rstrip()
)
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}' "
f"{exclude_flags}".rstrip()
)
source = (build_root / artifact["source"]).resolve()
if not source.is_relative_to(build_root.resolve()) or not source.is_dir():
die(f"artifact {artifact['name']}: source directory not found: {source}")
excludes = excludes or []
print(f"Publishing {artifact['name']} from {source} to s3://{artifact['bucket']}")
if artifact["immutable"]:
_immutable_sync(source, artifact, endpoint, excludes)
else:
_replaceable_sync(source, artifact, endpoint, excludes)
def garage_admin(method, path, token, body=None):
@@ -74,92 +135,83 @@ def garage_admin(method, path, token, body=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()
request = Request(url, data=data, method=method, headers=headers)
with urlopen(request) as response:
raw = response.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).
"""
"""Fail closed while reconciling legacy Garage global aliases."""
if not aliases:
return
if not admin_token:
print(" (no GARAGE_ADMIN_TOKEN — skipping bucket alias reconcile)")
return
die("GARAGE_ADMIN_TOKEN is required when aliases are configured")
try:
info = garage_admin("GET", f"/v2/GetBucketInfo?globalAlias={site_name}",
info = garage_admin("GET", f"/v2/GetBucketInfo?globalAlias={quote(site_name)}",
admin_token)
except (HTTPError, URLError) as e:
print(f" WARNING: bucket lookup failed: {e}")
return
except (HTTPError, URLError) as error:
die(f"bucket lookup failed: {error}")
bucket_id = info.get("id")
if not bucket_id:
die(f"bucket lookup returned no id for {site_name}")
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:
if alias not in existing:
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 = {
if manifests_dir.exists():
shutil.rmtree(manifests_dir)
routes = []
for route in cfg["routes"]:
artifact = cfg["artifacts"][route["artifact"]]
routes.append({
**route,
"artifact_config": artifact,
"resource_name": k8s_name(site_name, route["artifact"]),
"backend_host": (GARAGE_WEBSITE_HOST if cfg["compatibility"] else
f"{artifact['bucket']}.web.sjc001.fritzlab.net"),
"pass_host_header": cfg["compatibility"],
})
render_templates(action_dir, {
"site": site_name,
"site_k8s": k8s_name(site_name),
"domain": cfg["domain"],
"aliases": cfg["aliases"],
"namespace": NAMESPACE,
"middlewares": cfg["middlewares"],
}
render_templates(action_dir, template_vars, app_dir, manifests_dir)
"website_host": GARAGE_WEBSITE_HOST,
"routes": routes,
}, app_dir, manifests_dir)
def deploy_static(site_name, site_dir, action_dir, token, cfg):
s3_sync(site_name, site_dir, excludes=cfg.get("excludes"))
ensure_bucket_aliases(site_name, cfg["aliases"], os.environ.get("GARAGE_ADMIN_TOKEN"))
build_root = (site_dir / "build" / "html").resolve()
if not build_root.is_dir():
die(f"build/html not found — did the build step run? ({build_root})")
for artifact in cfg["artifacts"].values():
publish_artifact(build_root, artifact, cfg.get("excludes"))
if cfg["compatibility"]:
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}")
render_site_manifests(site_name, action_dir, app_dir, app_dir / "manifests", cfg)
commit_and_push(apps_dir, f"Deploy {site_name}", token)
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}")
apps_dir = clone_apps(token)
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)
commit_and_push(apps_dir, f"Decommission {site_name}", token)
print(f"Bucket {site_name} and its objects are NOT purged automatically.")
print(f" garage bucket delete {site_name} --yes")
def cmd_deploy():
@@ -168,12 +220,9 @@ def cmd_deploy():
action_dir = Path(env("ACTION_DIR"))
token = env("CI_BOT_TOKEN")
site_name = site_repo.split("/", 1)[1]
cfg = parse_site_yaml(site_dir)
cfg = parse_site_yaml(site_dir, site_name)
if not cfg["enabled"]:
print("Site disabled — running decommission...")
decommission(site_name, token)
return
deploy_static(site_name, site_dir, action_dir, token, cfg)