Files
infix/src/statd/python/yanger/host.py
T
Joachim Wiberg 4a37369750 yanger: add HOST.read_multiline() methods + missing file encoding
Similar to the run_multiline() method, sometimes files contain more than
one line and a common read_multiline() quickly becomes useful.  This is
currently only used by ietf-system for reading /etc/resolv.conf.head.

Also, fix pylint warning for missing encoding to all open() calls.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2025-01-31 13:55:35 +01:00

213 lines
6.2 KiB
Python

import abc
import datetime
import functools
import json
import os
import subprocess
from . import common
HOST = None
class Host(abc.ABC):
"""Host system API"""
@abc.abstractmethod
def now(self):
"""Get the current time as a `datetime`"""
pass
@abc.abstractmethod
def run(self, cmd, default=None, log=True):
"""Get stdout of cmd
Run cmd, provided as an argv, and return the output it
produces. On error, return the default value if provided,
otherwise raise an exception.
"""
pass
def run_multiline(self, cmd, default=None):
"""Get lines of stdout of cmd"""
try:
txt = self.run(tuple(cmd), log=(default is None))
return txt.splitlines()
except:
if default is not None:
return default
raise
def run_json(self, cmd, default=None):
"""Get JSON object from stdout of cmd"""
try:
txt = self.run(tuple(cmd), log=(default is None))
return json.loads(txt)
except:
if default is not None:
return default
raise
@abc.abstractmethod
def read(self, path):
"""Get the contents of path
Returns `None` if the file is not readable for any reason.
"""
pass
def read_multiline(self, path, default=None):
"""Get lines of content from path
Returns a list of lines from the file, or default if not readable.
"""
try:
txt = self.read(path)
return txt.splitlines() if txt is not None else default
except:
if default is not None:
return default
raise
def read_json(self, path, default=None):
"""Get JSON object from path """
try:
txt = self.read(path)
return json.loads(txt)
except:
if default is not None:
return default
raise
class Localhost(Host):
def now(self):
return datetime.datetime.now(tz=datetime.timezone.utc)
@functools.cache
def run(self, cmd, default=None, log=True):
try:
result = subprocess.run(cmd, check=True, text=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
return result.stdout
except subprocess.CalledProcessError as err:
if default is not None:
return default
if log:
common.LOG.error(f"Failed to run {err}")
raise
def read(self, path):
try:
with open(path, 'r', encoding='utf-8') as f:
data = f.read().strip()
return data
except FileNotFoundError:
# This is considered OK
pass
except IOError:
common.LOG.error(f"Failed to read \"{path}\"")
return None
class Remotehost(Localhost):
def __init__(self, prefix, capdir):
super().__init__()
self.prefix = tuple(prefix.split()) if prefix else tuple()
self.capdir = capdir
if capdir:
for subdir in ("rootfs", "run"):
os.makedirs(os.path.join(capdir, subdir), exist_ok=True)
def now(self):
timestamp = self._run(["date", "-u", "+%s"], default=None, log=True)
if self.capdir:
path = os.path.join(self.capdir, "timestamp")
with open(path, "w", encoding='utf-8') as f:
f.write(f"{timestamp}\n")
pass
return datetime.datetime.fromtimestamp(int(timestamp), datetime.timezone.utc)
def _run(self, cmd, default, log):
# Assume that the wrapper acts like ssh(1) and simply concats
# arguments to a single string. Therefore, we must quoute
# arguments containing spaces so that commands like `vtysh -c
# "show ip route json"` work as expected.
cmd = " ".join([arg if " " not in arg else f"\"{arg}\"" for arg in cmd])
return super().run(self.prefix + (cmd,), default, log)
def run(self, cmd, default=None, log=True):
if not self.capdir:
return self._run(cmd, default, log)
storedpath = os.path.join(self.capdir, "run", Replayhost.SlugOf(cmd))
if os.path.exists(storedpath):
with open(storedpath, 'r', encoding='utf-8') as f:
return f.read()
out = self._run(cmd, default, log)
with open(storedpath, "w", encoding='utf-8') as f:
f.write(out)
return out
def read(self, path):
out = self._run(("cat", path), default="", log=False)
if self.capdir:
dirname = os.path.join(self.capdir, "rootfs", os.path.dirname(path[1:]))
filname = os.path.join(self.capdir, "rootfs", path[1:])
os.makedirs(dirname, exist_ok=True)
with open(filname, "w", encoding='utf-8') as f:
f.write(out)
return out
class Replayhost(Host):
def SlugOf(cmd):
return "_".join(cmd).replace("/", "+").replace(" ", "-")
def __init__(self, replaydir):
self.replaydir = replaydir
def now(self):
path = os.path.join(self.replaydir, "timestamp")
with open(path, 'r', encoding='utf-8') as f:
timestamp = f.read().strip()
return datetime.datetime.fromtimestamp(int(timestamp), datetime.timezone.utc)
def run(self, cmd, default=None, log=True):
path = os.path.join(self.replaydir, "run", Replayhost.SlugOf(cmd))
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except:
if default is not None:
return default
if log:
common.LOG.error(f"No recording found for run \"{path}\"")
raise
def read(self, path):
path = os.path.join(self.replaydir, "rootfs", path[1:])
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
# This is considered OK
pass
except:
common.LOG.error(f"No recording found for file \"{path}\"")
raise