Files

325 lines
10 KiB
Python

import typer
from loguru import logger
from plumbum import FG, local
from plumbum.cmd import docker, git, uv
from cli.config import config
from cli.utils import docker_compose, get_project_root, get_repo_dir
app = typer.Typer(help="Deployment Management Tool")
ROOT_DIR = get_project_root()
REPO_DIR = get_repo_dir()
VERSIONS_DIR = ROOT_DIR / "versions"
import time
import requests
def healthcheck():
"""Poll the localhost health endpoint to verify the stack is up."""
logger.info("Running healthcheck...")
for _ in range(3):
try:
response = requests.get("http://localhost/health", timeout=5)
if response.status_code == 200:
logger.success("Healthcheck passed.")
return
except requests.RequestException:
pass
logger.info("Waiting 5 seconds before retrying...")
time.sleep(5)
logger.error("Healthcheck failed.")
raise typer.Exit(1)
@app.command()
def tag_version():
"""Tag current version using bumpver and save docker images state."""
VERSIONS_DIR.mkdir(exist_ok=True)
logger.info("Bumping version with bumpver...")
try:
uv("run", "bumpver", "update")
except Exception as e:
logger.error(f"Bumpver failed: {e}")
try:
current_tag = git("describe", "--tags", "--abbrev=0").strip()
except Exception:
import datetime
current_tag = "v" + datetime.datetime.now(datetime.UTC).strftime("%Y%m%d%H%M%S")
logger.warning(f"No git tags found, using timestamp {current_tag}")
logger.info(f"Current tag is {current_tag}")
with local.cwd(REPO_DIR):
try:
images_output = docker_compose("images")()
lines_to_write = [
line for line in images_output.splitlines() if "REPOSITORY" not in line
]
except Exception as e:
logger.warning(f"docker-compose images failed: {e}")
logger.info("Extracting images directly from docker-services.yml")
try:
import yaml
with open("docker-services.yml", "r") as f:
services_data = yaml.safe_load(f).get("services", {})
lines_to_write = []
for srv_data in services_data.values():
image = srv_data.get("image")
if image:
parts = image.rsplit(":", 1)
if len(parts) == 2 and "/" not in parts[1]:
lines_to_write.append(f"{parts[0]} {parts[1]}")
else:
lines_to_write.append(f"{image} latest")
except Exception as e2:
logger.warning(f"Failed to parse docker-services.yml: {e2}")
lines_to_write = []
version_file = VERSIONS_DIR / f"{current_tag}.txt"
with open(version_file, "w") as f:
f.writelines(line + "\n" for line in lines_to_write)
logger.success(f"Saved image states to {version_file}")
@app.command()
def rollback(version: str = typer.Option(None, help="Specific tag to rollback to")):
"""Rollback to a specific version or latest."""
current_branch = git("branch", "--show-current").strip()
if not version:
tags = git("tag", "--sort=-v:refname").splitlines()
if not tags:
logger.error("No tags found.")
raise typer.Exit(1)
version = tags[0]
version_file = VERSIONS_DIR / f"{version}.txt"
if not version_file.exists():
logger.error(f"No version file found for {version} at {version_file}")
raise typer.Exit(1)
logger.info(f"Pulling old images for {version}...")
with open(version_file, "r") as f:
for line in f:
parts = line.split()
if len(parts) >= 2:
repo, tag = parts[0], parts[1]
logger.info(f"Pulling {repo}:{tag}")
docker("pull", f"{repo}:{tag}")
logger.info(f"Rolling back to {version}...")
git("checkout", version)
try:
with local.cwd(REPO_DIR):
docker_compose("down")
docker_compose("build")
docker_compose("up", "-d")
finally:
if current_branch:
git("switch", current_branch)
logger.success(
f"Rollback to {version} complete. Returned git to {current_branch}."
)
else:
logger.success(
f"Rollback to {version} complete. Remained in detached HEAD."
)
@app.command()
def cleanup_versions():
"""Keep only the latest 5 version tags and delete older ones."""
tags = git("tag", "--sort=-v:refname").splitlines()
tags_to_delete = tags[5:]
for tag in tags_to_delete:
if not tag:
continue
git("tag", "-d", tag)
version_file = VERSIONS_DIR / f"{tag}.txt"
if version_file.exists():
version_file.unlink()
logger.info(f"Cleaned up {tag}")
@app.command()
def prod_deploy():
"""First-time deployment to production."""
logger.info("Deploying to production (first-time)...")
git["switch", "prod"] & FG
git["pull", "origin", "prod"] & FG
with local.env(INVENIO_THEME_IFORM_PRODUCTION="true"), local.cwd(REPO_DIR):
docker[
"build",
"-t",
config.docker_image_name,
"--build-arg",
"INSTALL_LOCAL_WHEELS=false",
".",
] & FG
docker_compose("up", "-d", "--wait")
setup_cmd = "invenio db init && invenio db create && invenio alembic upgrade heads && (invenio roles create iform_authenticated -d 'Allows uploading research data' || true) && invenio collect -v && invenio index init"
docker_compose("exec", "worker", "bash", "-c", setup_cmd)
logger.success("Production deployment complete!")
@app.command()
def prod_update(
auto_rollback: bool = typer.Option(
False, help="Automatically rollback if healthcheck fails"
),
):
"""Update the application stack, with optional auto-rollback."""
lock_file = ROOT_DIR / "update.lock"
if lock_file.exists():
logger.error("Update already in progress (update.lock exists).")
raise typer.Exit(1)
logger.info("Updating production deployment...")
try:
lock_file.touch()
logger.info("Tagging current version for potential rollback...")
tag_version()
git["switch", "prod"] & FG
git["pull", "origin", "prod"] & FG
with local.env(INVENIO_THEME_IFORM_PRODUCTION="true"), local.cwd(REPO_DIR):
docker_compose("pull", "--ignore-pull-failures")
docker[
"build",
"-t",
config.docker_image_name,
"--build-arg",
"INSTALL_LOCAL_WHEELS=false",
".",
] & FG
docker_compose("up", "-d", "--wait")
# Restart nginx so it picks up the new container IPs for web-ui and web-api
docker_compose("restart", "frontend")
update_cmd = "invenio alembic upgrade heads && (invenio roles create iform_authenticated -d 'Allows uploading research data' || true) && invenio collect -v"
docker_compose("exec", "worker", "bash", "-c", update_cmd)
try:
healthcheck()
except typer.Exit:
if auto_rollback:
logger.warning("Healthcheck failed, triggering auto-rollback...")
rollback(None)
else:
logger.error(
"Healthcheck failed. Consider running 'uv run cli deploy rollback' manually."
)
raise
cleanup_versions()
logger.success("Production update complete!")
finally:
if lock_file.exists():
lock_file.unlink()
@app.command()
def merge_and_push_prod():
"""Merge main into prod and push all branches."""
logger.info("Merging main into prod...")
current = git("branch", "--show-current").strip()
try:
git["switch", "prod"] & FG
git["merge", "main"] & FG
git["switch", "main"] & FG
logger.info("Pushing all branches...")
git["push", "--all"] & FG
logger.success("Merged and pushed to production.")
finally:
if current:
git("switch", current)
@app.command()
def prod_clean():
"""Clean all production build artifacts."""
logger.info("Cleaning production build artifacts...")
from plumbum.cmd import find, rm
root = get_project_root()
with local.cwd(root):
rm("-rf", ".venv", "build", "dist")
for egg in local.path(".") // "*.egg-info":
rm("-rf", egg)
try:
find(
".",
"-type",
"d",
"-name",
"__pycache__",
"-exec",
"rm",
"-r",
"{}",
"+",
)
except Exception:
pass
rm("-rf", "static", "node_modules")
logger.success("Clean complete. Re-create environment before next deploy.")
@app.command()
def init_ssl(
email: str = typer.Argument(
..., help="Email address for Let's Encrypt registration"
),
domain: str = typer.Argument(..., help="Domain name to fetch certificate for"),
):
"""Initialize Let's Encrypt SSL certificates for the first time."""
logger.info(f"Initializing Let's Encrypt SSL for {domain}...")
with local.cwd(REPO_DIR):
logger.info("Requesting certificate via certbot webroot...")
cmd = f"certbot certonly --webroot -w /var/www/certbot -d {domain} --email {email} --agree-tos --no-eff-email --force-renewal"
docker_compose("exec", "certbot", "sh", "-c", cmd)
logger.info("Restarting frontend to apply new certificates...")
docker_compose("restart", "frontend")
logger.success("SSL initialization complete!")
@app.command()
def renew_ssl():
"""Manually force a renewal check for Let's Encrypt and reload Nginx."""
logger.info("Triggering manual SSL renewal check...")
with local.cwd(REPO_DIR):
docker_compose("exec", "certbot", "certbot", "renew")
logger.info("Reloading Nginx...")
docker_compose("exec", "frontend", "nginx", "-s", "reload")
logger.success("SSL renewal check complete!")