Authored-By: OpenAI (GPT-5) <noreply@openai.com>
This commit is contained in:
+158
-109
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user