Files
site-publish/scripts/deploy.py
T

229 lines
9.4 KiB
Python
Raw Normal View History

2026-08-29 21:21:47 +00:00
"""Deploy phase — artifact publication and split-route manifest rendering."""
2026-05-06 08:07:28 -05:00
2026-08-29 21:21:47 +00:00
import fnmatch
import hashlib
2026-05-06 08:07:28 -05:00
import json
2026-08-29 21:21:47 +00:00
import mimetypes
2026-05-06 08:07:28 -05:00
import os
import shutil
2026-08-29 21:21:47 +00:00
import subprocess
2026-05-06 08:07:28 -05:00
from pathlib import Path
from urllib.error import HTTPError, URLError
2026-08-29 21:21:47 +00:00
from urllib.parse import quote
2026-05-06 08:07:28 -05:00
from urllib.request import Request, urlopen
2026-08-29 21:21:47 +00:00
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
2026-05-06 08:07:28 -05:00
from utils import (
2026-08-29 21:21:47 +00:00
DEFAULT_S3_ENDPOINT, GARAGE_WEBSITE_HOST, NAMESPACE, clone_apps,
commit_and_push, die, env, k8s_name, parse_site_yaml, render_templates, run,
2026-05-06 08:07:28 -05:00
)
GARAGE_ADMIN_ENDPOINT = os.environ.get(
2026-08-29 21:21:47 +00:00
"GARAGE_ADMIN_ENDPOINT", "http://garage.storage.svc:3903")
2026-05-06 08:07:28 -05:00
2026-08-29 21:21:47 +00:00
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
2026-05-06 08:07:28 -05:00
2026-08-29 21:21:47 +00:00
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):
2026-05-06 08:07:28 -05:00
endpoint = os.environ.get("GARAGE_S3_ENDPOINT", DEFAULT_S3_ENDPOINT)
2026-08-29 21:21:47 +00:00
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)
2026-05-06 08:07:28 -05:00
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"
2026-08-29 21:21:47 +00:00
request = Request(url, data=data, method=method, headers=headers)
with urlopen(request) as response:
raw = response.read()
2026-05-06 08:07:28 -05:00
return json.loads(raw) if raw else {}
def ensure_bucket_aliases(site_name, aliases, admin_token):
2026-08-29 21:21:47 +00:00
"""Fail closed while reconciling legacy Garage global aliases."""
2026-05-06 08:07:28 -05:00
if not aliases:
return
if not admin_token:
2026-08-29 21:21:47 +00:00
die("GARAGE_ADMIN_TOKEN is required when aliases are configured")
2026-05-06 08:07:28 -05:00
try:
2026-08-29 21:21:47 +00:00
info = garage_admin("GET", f"/v2/GetBucketInfo?globalAlias={quote(site_name)}",
2026-05-06 08:07:28 -05:00
admin_token)
2026-08-29 21:21:47 +00:00
except (HTTPError, URLError) as error:
die(f"bucket lookup failed: {error}")
2026-05-06 08:07:28 -05:00
bucket_id = info.get("id")
2026-08-29 21:21:47 +00:00
if not bucket_id:
die(f"bucket lookup returned no id for {site_name}")
2026-05-06 08:07:28 -05:00
existing = set(info.get("globalAliases") or [])
for alias in aliases:
2026-08-29 21:21:47 +00:00
if alias not in existing:
2026-05-06 08:07:28 -05:00
garage_admin("POST", "/v2/AddBucketAlias", admin_token,
{"bucketId": bucket_id, "globalAlias": alias})
def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg):
2026-08-29 21:21:47 +00:00
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, {
2026-05-06 08:07:28 -05:00
"site": site_name,
"site_k8s": k8s_name(site_name),
"domain": cfg["domain"],
"aliases": cfg["aliases"],
"namespace": NAMESPACE,
2026-08-29 21:21:47 +00:00
"website_host": GARAGE_WEBSITE_HOST,
"routes": routes,
}, app_dir, manifests_dir)
2026-05-06 08:07:28 -05:00
def deploy_static(site_name, site_dir, action_dir, token, cfg):
2026-08-29 21:21:47 +00:00
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"))
2026-05-06 08:07:28 -05:00
apps_dir = clone_apps(token)
app_dir = apps_dir / "sjc001" / "websites" / site_name
2026-08-29 21:21:47 +00:00
render_site_manifests(site_name, action_dir, app_dir, app_dir / "manifests", cfg)
commit_and_push(apps_dir, f"Deploy {site_name}", token)
2026-05-06 08:07:28 -05:00
def decommission(site_name, token):
2026-08-29 21:21:47 +00:00
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)
2026-05-06 08:07:28 -05:00
print(f"Bucket {site_name} and its objects are NOT purged automatically.")
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]
2026-08-29 21:21:47 +00:00
cfg = parse_site_yaml(site_dir, site_name)
2026-05-06 08:07:28 -05:00
if not cfg["enabled"]:
print("Site disabled — running decommission...")
decommission(site_name, token)
return
deploy_static(site_name, site_dir, action_dir, token, cfg)