97 lines
3.1 KiB
Python
97 lines
3.1 KiB
Python
"""Local command execution helpers.
|
|
|
|
These mirror the remote SSH helpers but execute commands on the API host
|
|
itself. They are used for the built-in local monitoring machine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import posixpath
|
|
import shlex
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class CommandResult:
|
|
"""Plain result object returned by local command execution."""
|
|
|
|
command: str
|
|
exit_status: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
class LocalCommandClient:
|
|
"""Execute the same POSIX shell snippets used by the SSH client locally."""
|
|
|
|
def __init__(self, timeout: int = 20):
|
|
self.timeout = timeout
|
|
|
|
def run(self, command: str, timeout: int | None = None) -> CommandResult:
|
|
shell_command = ["/bin/sh", "-c", command]
|
|
logger.debug("Local run timeout=%s command=%s", timeout or self.timeout, command)
|
|
proc = subprocess.run(
|
|
shell_command,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout or self.timeout,
|
|
)
|
|
result = CommandResult(
|
|
command=command,
|
|
exit_status=proc.returncode,
|
|
stdout=proc.stdout,
|
|
stderr=proc.stderr,
|
|
)
|
|
if result.exit_status == 0:
|
|
logger.debug("Local command ok exit_status=%s", result.exit_status)
|
|
else:
|
|
logger.warning(
|
|
"Local command failed exit_status=%s stderr=%s",
|
|
result.exit_status,
|
|
result.stderr.strip() or result.stdout.strip(),
|
|
)
|
|
return result
|
|
|
|
def list_dir(self, path: str) -> CommandResult:
|
|
quoted = shlex.quote(path)
|
|
not_dir_message = shlex.quote(f"Not a directory: {path}")
|
|
command = (
|
|
f"test -d {quoted} || "
|
|
f"{{ echo {not_dir_message} >&2; exit 20; }}; "
|
|
f"find {quoted} -maxdepth 1 -mindepth 1 -printf "
|
|
"'%y\\t%s\\t%T@\\t%f\\0' | python3 -c "
|
|
+ shlex.quote(
|
|
"import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); "
|
|
"rows=[]\n"
|
|
"for row in data:\n"
|
|
" if not row: continue\n"
|
|
" t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n"
|
|
" rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n"
|
|
"print(json.dumps(rows))"
|
|
)
|
|
)
|
|
return self.run(command)
|
|
|
|
def stat_path(self, path: str) -> CommandResult:
|
|
quoted = shlex.quote(path)
|
|
return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
|
|
|
|
def ffprobe_json(self, path: str) -> dict[str, object]:
|
|
quoted = shlex.quote(path)
|
|
result = self.run(
|
|
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
|
|
timeout=60,
|
|
)
|
|
if result.exit_status != 0:
|
|
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
|
return json.loads(result.stdout)
|
|
|
|
@staticmethod
|
|
def join(parent: str, child: str) -> str:
|
|
return posixpath.normpath(posixpath.join(parent, child))
|