Files
metin-release-cli/src/metin_release/storage/rsync.py
Jan Nedbal e70fc300e2 cli: scaffold phase 1 asset release commands
Add metin-release CLI with argparse dispatcher, result envelope and
error hierarchy, and the full Phase 1 release subcommand set:

  release inspect         - scan source root
  release build-manifest  - wraps make-manifest.py
  release sign            - wraps sign-manifest.py, enforces mode 600
  release diff-remote     - HEAD each blob hash against a base URL
  release upload-blobs    - rsync release dir minus manifest
  release promote         - rsync manifest.json + signature
  release verify-public   - GET + Ed25519 verify, optional blob sampling
  release publish         - composite of the above with per-stage timings

Respects --json / --verbose / --quiet. Exit codes follow the plan
(1 validation, 2 remote, 3 integrity, 4 reserved for ERP).
2026-04-14 18:59:50 +02:00

101 lines
2.8 KiB
Python

"""rsync-based remote storage backend."""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from ..errors import UploadError, ValidationError
from ..log import get_logger
COMMON_FLAGS = [
"-av",
"--delay-updates",
"--checksum",
"--omit-dir-times",
"--no-perms",
]
@dataclass
class RsyncResult:
cmd: list[str]
returncode: int
stdout: str
stderr: str
bytes_transferred: int | None = None
def _ensure_rsync() -> str:
path = shutil.which("rsync")
if not path:
raise ValidationError("rsync binary not found in PATH")
return path
def _normalise_source_dir(source_dir: Path) -> str:
s = str(source_dir.resolve())
if not s.endswith("/"):
s += "/"
return s
def push_blobs(
release_dir: Path,
target: str,
*,
dry_run: bool = False,
) -> RsyncResult:
"""Upload everything in release_dir except manifest.json{,.sig}."""
log = get_logger()
if not release_dir.is_dir():
raise ValidationError(f"release dir not found: {release_dir}")
rsync = _ensure_rsync()
cmd = [rsync, *COMMON_FLAGS]
if dry_run:
cmd.append("--dry-run")
cmd += [
"--exclude", "manifest.json",
"--exclude", "manifest.json.sig",
_normalise_source_dir(release_dir),
target,
]
log.debug("rsync blobs: %s", cmd)
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
raise UploadError(
f"rsync blobs failed ({proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
)
return RsyncResult(cmd=cmd, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
def push_manifest(
release_dir: Path,
target: str,
*,
dry_run: bool = False,
) -> RsyncResult:
"""Upload only manifest.json and manifest.json.sig."""
log = get_logger()
manifest = release_dir / "manifest.json"
sig = release_dir / "manifest.json.sig"
if not manifest.is_file() or not sig.is_file():
raise ValidationError(
f"manifest or signature missing in {release_dir}: need manifest.json + manifest.json.sig"
)
rsync = _ensure_rsync()
cmd = [rsync, "-av", "--checksum", "--omit-dir-times", "--no-perms"]
if dry_run:
cmd.append("--dry-run")
cmd += [str(manifest), str(sig), target]
log.debug("rsync manifest: %s", cmd)
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
raise UploadError(
f"rsync manifest failed ({proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}"
)
return RsyncResult(cmd=cmd, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)