Files
Nexus/scripts/publish_release_bundle.py
2026-07-08 22:31:31 +08:00

150 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Upload and optionally apply a Nexus release bundle over SSH.
The script deliberately avoids local shell pipelines so it behaves consistently
on Windows, Git Bash, WSL, and Linux/macOS. By default it only uploads the
tarball and runs the remote preflight; applying the release requires --apply.
"""
from __future__ import annotations
import argparse
import hashlib
import posixpath
import shlex
import subprocess
import sys
from pathlib import Path
DEFAULT_DEPLOY_PATH = "/opt/nexus"
DEFAULT_REMOTE_TARBALL = "/tmp/nexus-release-20260708.tar.gz"
DEFAULT_REMOTE_EXTRACT_DIR = "/tmp"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run(cmd: list[str], *, dry_run: bool = False) -> None:
printable = " ".join(shlex.quote(part) for part in cmd)
print(f"\n$ {printable}", flush=True)
if dry_run:
return
subprocess.run(cmd, check=True)
def build_ssh_base(args: argparse.Namespace) -> list[str]:
cmd = ["ssh"]
if args.identity_file:
cmd.extend(["-i", str(args.identity_file)])
for option in args.ssh_option:
cmd.extend(["-o", option])
return cmd
def build_scp_base(args: argparse.Namespace) -> list[str]:
cmd = ["scp"]
if args.identity_file:
cmd.extend(["-i", str(args.identity_file)])
for option in args.ssh_option:
cmd.extend(["-o", option])
return cmd
def remote_bash(ssh_base: list[str], remote: str, script: str, *, dry_run: bool = False) -> None:
run([*ssh_base, remote, "bash", "-lc", script], dry_run=dry_run)
def q(value: str | Path) -> str:
return shlex.quote(str(value))
def remote_join(*parts: str) -> str:
return posixpath.join(*parts)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--remote", required=True, help="SSH target, for example 'nexus' or 'root@1.2.3.4'.")
parser.add_argument("--tarball", required=True, type=Path, help="Local release tarball path.")
parser.add_argument("--sha256", required=True, help="Expected tarball SHA256.")
parser.add_argument("--deploy-path", default=DEFAULT_DEPLOY_PATH, help=f"Remote deploy path. Default: {DEFAULT_DEPLOY_PATH}")
parser.add_argument("--remote-tarball", default=DEFAULT_REMOTE_TARBALL, help=f"Remote tarball path. Default: {DEFAULT_REMOTE_TARBALL}")
parser.add_argument("--remote-extract-dir", default=DEFAULT_REMOTE_EXTRACT_DIR, help=f"Remote extract dir. Default: {DEFAULT_REMOTE_EXTRACT_DIR}")
parser.add_argument("--identity-file", type=Path, help="Optional SSH private key.")
parser.add_argument("--ssh-option", action="append", default=[], help="Extra ssh/scp -o option. Can be repeated.")
parser.add_argument("--no-upload", action="store_true", help="Skip scp upload and use --remote-tarball already on the host.")
parser.add_argument("--apply", action="store_true", help="Apply the release after preflight. Without this flag, only preflight runs.")
parser.add_argument("--no-sudo", action="store_true", help="Do not prefix the apply command with sudo.")
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them.")
args = parser.parse_args()
tarball = args.tarball.resolve()
if not tarball.exists():
print(f"ERROR: local tarball not found: {tarball}", file=sys.stderr)
return 2
actual_sha = sha256_file(tarball)
if actual_sha.lower() != args.sha256.lower():
print("ERROR: local tarball SHA256 mismatch", file=sys.stderr)
print(f"expected: {args.sha256}", file=sys.stderr)
print(f"actual: {actual_sha}", file=sys.stderr)
return 2
ssh_base = build_ssh_base(args)
scp_base = build_scp_base(args)
print(f"Local tarball SHA256 OK: {actual_sha}")
print(f"Remote target: {args.remote}")
print(f"Remote tarball: {args.remote_tarball}")
print(f"Remote deploy path: {args.deploy_path}")
print(f"Mode: {'apply release' if args.apply else 'preflight only'}")
if not args.no_upload:
remote_target = f"{args.remote}:{args.remote_tarball}"
run([*scp_base, str(tarball), remote_target], dry_run=args.dry_run)
extract_script = " && ".join(
[
f"test -f {q(args.remote_tarball)}",
f"mkdir -p {q(args.remote_extract_dir)}",
(
f"tar xzf {q(args.remote_tarball)} -C {q(args.remote_extract_dir)} "
"nexus-release/deploy/preflight-release-host.sh "
"nexus-release/deploy/apply-release-bundle.sh"
),
]
)
remote_bash(ssh_base, args.remote, extract_script, dry_run=args.dry_run)
preflight = (
f"bash {q(remote_join(args.remote_extract_dir, 'nexus-release/deploy/preflight-release-host.sh'))} "
f"--tarball {q(args.remote_tarball)} "
f"--sha256 {q(args.sha256)} "
f"--deploy-path {q(args.deploy_path)}"
)
remote_bash(ssh_base, args.remote, preflight, dry_run=args.dry_run)
if args.apply:
apply_prefix = "" if args.no_sudo else "sudo "
apply_cmd = (
f"{apply_prefix}bash {q(remote_join(args.remote_extract_dir, 'nexus-release/deploy/apply-release-bundle.sh'))} "
f"--tarball {q(args.remote_tarball)} "
f"--sha256 {q(args.sha256)} "
f"--deploy-path {q(args.deploy_path)}"
)
remote_bash(ssh_base, args.remote, apply_cmd, dry_run=args.dry_run)
else:
print("\nPreflight finished. Re-run with --apply to apply the release after reviewing the output.")
return 0
if __name__ == "__main__":
raise SystemExit(main())