"""Wrapper for the abra CLI. Encapsulates TTY wrapping, --chaos flag, --no-input, timeout handling, and the Linux `timeout` command guard. All abra commands go through this module so that learnings (TTY requirements, --chaos caveats, timeout behaviors) are encoded in one place. """ import json import subprocess from dataclasses import dataclass # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @dataclass class AbraResult: """Result of a shell/abra command.""" returncode: int stdout: str stderr: str timed_out: bool command: str @property def ok(self) -> bool: return self.returncode == 0 and not self.timed_out def json(self) -> list | dict | None: """Try to parse stdout as JSON.""" try: return json.loads(self.stdout) except (json.JSONDecodeError, ValueError): return None def jsonl(self) -> list[list | dict]: """Parse stdout as JSON Lines (one JSON document per line).""" results = [] for line in self.stdout.strip().split("\n"): if line.strip(): try: results.append(json.loads(line)) except (json.JSONDecodeError, ValueError): pass return results # --------------------------------------------------------------------------- # TTY requirements from learnings.md # --------------------------------------------------------------------------- # These abra subcommands require a TTY wrapper (script -qefc). # Without it they fail with "not a TTY" or hang indefinitely. _TTY_COMMANDS = { "secret insert", "secret remove", "secret generate", "volume remove", "cmd", "backup create", "backup snapshots", "backup list", "restore", "recipe lint", } def _needs_tty(args: str) -> bool: """Check if an abra command needs the TTY wrapper.""" # Normalize: "app secret insert ..." → check "secret insert" parts = args.strip() # Strip leading "app " if present if parts.startswith("app "): parts = parts[4:] for cmd in _TTY_COMMANDS: if parts.startswith(cmd): return True return False # --------------------------------------------------------------------------- # Core execution # --------------------------------------------------------------------------- def run(cmd: str, *, check: bool = True, timeout: int = 120) -> AbraResult: """Run a shell command with a hard timeout. Uses the Linux `timeout` command to guarantee the process tree is killed after `timeout` seconds, even if the process ignores signals. subprocess.run gets a slightly longer timeout as a fallback. """ wrapped = f"timeout --kill-after=5 {timeout} {cmd}" print(f" $ {cmd}", flush=True) try: result = subprocess.run( wrapped, shell=True, capture_output=True, text=True, timeout=timeout + 15, ) except subprocess.TimeoutExpired: print(f" TIMEOUT after {timeout}s (subprocess fallback)", flush=True) if check: raise RuntimeError(f"Command timed out after {timeout}s: {cmd}") return AbraResult( returncode=124, stdout="", stderr="", timed_out=True, command=cmd, ) timed_out = result.returncode == 124 if result.stdout.strip(): for line in result.stdout.strip().split("\n"): print(f" {line}", flush=True) if result.returncode != 0: if result.stderr.strip(): for line in result.stderr.strip().split("\n"): print(f" stderr: {line}", flush=True) if timed_out: print(f" TIMEOUT after {timeout}s", flush=True) if check: raise RuntimeError(f"Command timed out after {timeout}s: {cmd}") elif check: raise RuntimeError( f"Command failed (exit {result.returncode}): {cmd}" ) return AbraResult( returncode=result.returncode, stdout=result.stdout, stderr=result.stderr, timed_out=timed_out, command=cmd, ) def abra(args: str, *, tty_wrap: bool | None = None, check: bool = True, timeout: int = 120, chaos: bool = True) -> AbraResult: """Run an abra command. - tty_wrap: wrap with script -qefc for commands that need TTY. If None, auto-detects from the command. - chaos: append --chaos (default True for local dev) - Always appends --no-input """ flags = "--no-input" if chaos and "--chaos" not in args: flags += " --chaos" cmd = f"abra {args} {flags}".strip() if tty_wrap is None: tty_wrap = _needs_tty(args) if tty_wrap: cmd = f'script -qefc "{cmd}" /dev/null 2>&1' return run(cmd, check=check, timeout=timeout) # --------------------------------------------------------------------------- # High-level abra operations # --------------------------------------------------------------------------- def app_deploy(domain: str, *, force: bool = True, chaos: bool = True, timeout: int = 60) -> AbraResult: """Deploy an app.""" flags = f"app deploy {domain}" if force: flags += " --force" return abra(flags, chaos=chaos, check=False, timeout=timeout) def app_undeploy(domain: str, *, timeout: int = 60) -> AbraResult: """Undeploy an app.""" return abra(f"app undeploy {domain}", chaos=False, check=False, timeout=timeout) def app_new(recipe: str, server: str, domain: str, *, chaos: bool = True, timeout: int = 60) -> AbraResult: """Create a new app instance.""" return abra( f"app new {recipe} --server {server} --domain {domain}", chaos=chaos, timeout=timeout, ) def app_ps(domain: str, *, chaos: bool = True) -> AbraResult: """Get app process status (machine-readable).""" return abra(f"app ps {domain} -m", chaos=chaos, check=False, timeout=30) def app_secret_generate(domain: str, *, chaos: bool = True, timeout: int = 60) -> AbraResult: """Generate all secrets for an app.""" return abra( f"app secret generate {domain} --all", chaos=chaos, check=False, timeout=timeout, ) def app_secret_insert(domain: str, name: str, version: str, value: str, *, chaos: bool = True, timeout: int = 60) -> AbraResult: """Insert a specific secret.""" return abra( f"app secret insert {domain} {name} {version} {value}", chaos=chaos, check=False, timeout=timeout, ) def app_secret_remove_all(domain: str, *, chaos: bool = True, timeout: int = 60) -> AbraResult: """Remove all secrets for an app.""" return abra( f"app secret remove {domain} --all", chaos=chaos, check=False, timeout=timeout, ) def app_volume_remove(domain: str, *, timeout: int = 60) -> AbraResult: """Remove all volumes for an app.""" return abra( f"app volume remove {domain} --force", chaos=False, check=False, timeout=timeout, ) def app_cmd(domain: str, service: str, cmd_name: str, *, chaos: bool = True, timeout: int = 120) -> AbraResult: """Run an abra app cmd.""" return abra( f"app cmd {domain} {service} {cmd_name}", chaos=chaos, check=False, timeout=timeout, ) def app_ls(server: str, *, timeout: int = 30) -> AbraResult: """List all apps on a server (machine-readable).""" return abra(f"app ls -s {server} -S -m", chaos=False, check=False, timeout=timeout) def app_backup_create(domain: str, *, chaos: bool = True, timeout: int = 120) -> AbraResult: """Create a backup of an app.""" return abra( f"app backup create {domain}", chaos=chaos, check=False, timeout=timeout, ) def app_backup_restore(domain: str, *, chaos: bool = True, timeout: int = 120) -> AbraResult: """Restore an app from backup.""" return abra( f"app restore {domain}", chaos=chaos, check=False, timeout=timeout, ) def recipe_fetch(recipe: str, *, force: bool = True, timeout: int = 60) -> AbraResult: """Fetch a recipe from upstream.""" flags = f"recipe fetch {recipe}" if force: flags += " --force" return abra(flags, chaos=False, timeout=timeout) def recipe_versions(recipe: str) -> AbraResult: """List recipe versions (machine-readable).""" return abra(f"recipe versions {recipe} -m", chaos=False, check=False, timeout=30) def recipe_upgrade(recipe: str, *, dry_run: bool = True) -> AbraResult: """Check for recipe upgrades (machine-readable).""" flags = f"recipe upgrade {recipe} -m" if dry_run: flags += " -n" return abra(flags, chaos=False, check=False, timeout=30) def recipe_lint(recipe: str) -> AbraResult: """Lint a recipe.""" return abra(f"recipe lint {recipe} -C", chaos=False, check=False, timeout=30) def recipe_diff(recipe: str) -> AbraResult: """Show local changes in a recipe checkout.""" return abra(f"recipe diff {recipe}", chaos=False, check=False, timeout=15)