#!/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())