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).
101 lines
2.8 KiB
Python
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)
|