153 lines
5.9 KiB
Python
153 lines
5.9 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:
|
|
# OpenSSH concatenates remote command argv with spaces before sending it
|
|
# to the remote shell. Quote the bash -lc payload so operators such as
|
|
# && stay inside bash rather than being interpreted by the login shell.
|
|
run([*ssh_base, remote, "bash", "-lc", q(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())
|