Files
iform-invenio/cli/deploy.py
T

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!")