yanger: Add support for connecting to remote hosts

Allow all host commands to be run with a prefix (e.g. `ssh
user@remotehost sudo`), such that yanger can be tested against the
current state of a remote system.

If a command wrapper _and_ a test directory is specified, the output
of all commands and files are recorded for use by a future test
execution.

Example:

1. Launch a `make run` instance and setup some config for which we
   want to test yanger and cli-pretty

2. Capture the output of all commands needed to produce the data for
   some YANG model:
   `yanger -t /tmp/ifs -w "ixll -A ssh qtap1 sudo" ietf-interfaces`

3. Kill the instance (we do not need it any more)

4. Test cli-pretty using the captured state:
   `yanger -t /tmp/ifs ietf-interfaces | cli-pretty show-interfaces
`
This commit is contained in:
Tobias Waldekranz
2025-01-20 10:56:56 +01:00
parent be1b7cdf45
commit e397012394
2 changed files with 68 additions and 4 deletions
+4 -1
View File
@@ -13,6 +13,7 @@ def main():
parser = argparse.ArgumentParser(description="YANG data creator")
parser.add_argument("model", help="YANG Model")
parser.add_argument("-p", "--param", default=None, help="Model dependent parameter")
parser.add_argument("-w", "--wrap-commands", default=None, help="Command execution wrapper")
parser.add_argument("-t", "--test", default=None, help="Test data base path")
args = parser.parse_args()
@@ -30,7 +31,9 @@ def main():
common.LOG.setLevel(logging.INFO)
common.LOG.addHandler(log)
if args.test:
if args.wrap_commands:
host.HOST = host.Remotehost(args.wrap_commands, args.test)
elif args.test:
host.HOST = host.Testhost(args.test)
else:
host.HOST = host.Localhost()
+64 -3
View File
@@ -1,13 +1,16 @@
import abc
import datetime
import functools
import json
import os
import subprocess
from . import common
HOST = None
class Host(abc.ABC):
"""Host system API"""
@@ -66,10 +69,12 @@ class Host(abc.ABC):
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,
@@ -98,16 +103,72 @@ class Localhost(Host):
return None
class Remotehost(Localhost):
def __init__(self, wrap, basedir):
super().__init__()
self.wrap = wrap.split()
self.basedir = basedir
if basedir:
for subdir in ("rootfs", "run"):
os.makedirs(os.path.join(basedir, subdir), exist_ok=True)
def now(self):
timestamp = self._run(["date", "-u", "+%s"], default=None, log=True)
if self.basedir:
with open(os.path.join(self.basedir, "timestamp"), "w") 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.wrap + (cmd,), default, log)
def run(self, cmd, default=None, log=True):
if not self.basedir:
return self._run(cmd, default, log)
storedpath = os.path.join(self.basedir, "run", Testhost.SlugOf(cmd))
if os.path.exists(storedpath):
with open(storedpath) as f:
return f.read()
out = self._run(cmd, default, log)
with open(storedpath, "w") as f:
f.write(out)
return out
def read(self, path):
out = self._run(["cat", path], default="", log=False)
if self.basedir:
dirname = os.path.join(self.basedir, "rootfs", os.path.dirname(path[1:]))
os.makedirs(dirname, exist_ok=True)
with open(os.path.join(self.basedir, "rootfs", path[1:]), "w") as f:
f.write(out)
class Testhost(Host):
def SlugOf(cmd):
return "_".join(cmd).replace("/", "+").replace(" ", "-")
def __init__(self, basedir):
self.basedir = basedir
def now(self):
return datetime.datetime(2023, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
with open(os.path.join(self.basedir, "timestamp")) as f:
timestamp = f.read().strip()
return datetime.datetime.fromtimestamp(int(timestamp), datetime.timezone.utc)
def run(self, cmd, default=None, log=True):
slug = "_".join(cmd).replace("/", "+").replace(" ", "-")
path = os.path.join(self.basedir, "run", slug)
path = os.path.join(self.basedir, "run", Testhost.SlugOf(cmd))
try:
with open(path, 'r') as f: