"""scripts/deploy_release.py — pull a release from S3 and roll it out. Runs on the production server. Downloads a release zip from S3, unpacks it next to the current install, swaps directories atomically, runs ``pip install -r requirements.txt`` if dependencies changed, and restarts the courseware service. Designed to be safely re-run: if the swap fails midway, the previous install is preserved as ``code_previous``. Default deploy root assumes the Phase 4 server layout: /opt/courseware/ ├── code/ ← currently-installed release ├── code_new/ ← staging during deploy (created, then moved) ├── code_previous/ ← previous release, kept for one-click rollback └── venv/ ← shared virtualenv (so swap doesn't reinstall) Override paths via DEPLOY_ROOT and SERVICE_NAME env vars. Config (.env or env vars): S3_BUCKET, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY S3_RELEASES_PREFIX default 'releases/' DEPLOY_ROOT default '/opt/courseware' SERVICE_NAME default 'courseware' VENV_DIR default '/venv' Usage on the server: python scripts/deploy_release.py # deploy latest.zip python scripts/deploy_release.py release-2026-05-21-77fe437.zip python scripts/deploy_release.py --rollback # restore code_previous """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) try: from dotenv import load_dotenv load_dotenv(ROOT / ".env") except ImportError: pass BUCKET = os.environ.get("S3_BUCKET") REGION = os.environ.get("AWS_REGION") PREFIX = os.environ.get("S3_RELEASES_PREFIX", "releases/") DEPLOY_ROOT = Path(os.environ.get("DEPLOY_ROOT", "/opt/courseware")) SERVICE_NAME = os.environ.get("SERVICE_NAME", "courseware") VENV_DIR = Path(os.environ.get("VENV_DIR", str(DEPLOY_ROOT / "venv"))) CODE_DIR = DEPLOY_ROOT / "code" NEW_DIR = DEPLOY_ROOT / "code_new" PREV_DIR = DEPLOY_ROOT / "code_previous" def _restart_service() -> None: print(f"Restarting service: {SERVICE_NAME}") res = subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME]) if res.returncode != 0: print(f" [warn] systemctl restart returned {res.returncode}") def _install_requirements(code_dir: Path) -> None: pip = VENV_DIR / "bin" / "pip" if not pip.exists(): pip = VENV_DIR / "Scripts" / "pip.exe" # Windows venv layout if not pip.exists(): print(f" [warn] no pip at {VENV_DIR}; skipping pip install") return req = code_dir / "requirements.txt" if not req.exists(): print(f" [warn] no requirements.txt at {req}; skipping pip install") return print(f"Installing requirements via {pip}") res = subprocess.run([str(pip), "install", "-r", str(req)], check=False) if res.returncode != 0: print(f" [warn] pip install returned {res.returncode}") def deploy(version: str) -> int: if not BUCKET or not REGION: print("ERROR: S3_BUCKET and AWS_REGION required.", file=sys.stderr) return 1 import boto3 from botocore.config import Config from botocore.exceptions import ClientError s3 = boto3.client( "s3", region_name=REGION, aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"], aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"], endpoint_url=f"https://s3.{REGION}.amazonaws.com", config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}), ) DEPLOY_ROOT.mkdir(parents=True, exist_ok=True) key = f"{PREFIX}{version}" local_zip = DEPLOY_ROOT / version print(f"Downloading s3://{BUCKET}/{key}") try: s3.download_file(BUCKET, key, str(local_zip)) except ClientError as e: print(f"ERROR downloading: {e}", file=sys.stderr) return 1 # Unpack into a fresh staging dir. if NEW_DIR.exists(): shutil.rmtree(NEW_DIR) NEW_DIR.mkdir(parents=True) print(f"Unpacking into {NEW_DIR}") with zipfile.ZipFile(local_zip) as zf: zf.extractall(NEW_DIR) local_zip.unlink(missing_ok=True) # Install dependencies (cheap if unchanged). _install_requirements(NEW_DIR) # Atomic-ish swap: previous → trash, current → previous, new → current. if PREV_DIR.exists(): print(f"Removing older previous: {PREV_DIR}") shutil.rmtree(PREV_DIR) if CODE_DIR.exists(): print(f"Moving current to previous: {CODE_DIR} -> {PREV_DIR}") CODE_DIR.rename(PREV_DIR) print(f"Promoting new to current: {NEW_DIR} -> {CODE_DIR}") NEW_DIR.rename(CODE_DIR) _restart_service() print() print(f"Deployed: {version}") return 0 def rollback() -> int: if not PREV_DIR.exists(): print(f"ERROR: no previous install at {PREV_DIR}", file=sys.stderr) return 1 # Swap current and previous. if CODE_DIR.exists(): tmp = DEPLOY_ROOT / "code_swap_tmp" CODE_DIR.rename(tmp) PREV_DIR.rename(CODE_DIR) tmp.rename(PREV_DIR) print(f"Rolled back; previous and current swapped.") else: PREV_DIR.rename(CODE_DIR) print(f"Rolled back to {PREV_DIR.name} -> {CODE_DIR.name}.") _restart_service() return 0 def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("version", nargs="?", default="latest.zip", help="Release zip filename (default: latest.zip).") parser.add_argument("--rollback", action="store_true", help="Swap current and previous code dirs; restart service.") args = parser.parse_args() if args.rollback: return rollback() return deploy(args.version) if __name__ == "__main__": raise SystemExit(main())