Skip to content

Commit

Permalink
Shell driver for the exporter.
Browse files Browse the repository at this point in the history
This drivers enables shell methods on exporters, so local tools
can be used remotelly when no specific drivers are available.
  • Loading branch information
mangelajo committed Dec 21, 2024
1 parent cef4b63 commit 5b7e35e
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 1 deletion.
3 changes: 3 additions & 0 deletions contrib/drivers/shell/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
.coverage
coverage.xml
5 changes: 5 additions & 0 deletions contrib/drivers/shell/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Jumpstarter Driver for shell access

This driver provides a simple shell access to the target exporter, and it is
intended to be used when command line tools exist to manage existing interfaces
or hardware, but no drivers exist yet in Jumpstarter.
17 changes: 17 additions & 0 deletions contrib/drivers/shell/examples/exporter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apiVersion: jumpstarter.dev/v1alpha1
kind: ExporterConfig
endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082
token: "<token>"
export:
example:
type: jumpstarter_driver_shell.driver.Shell
config:
methods:
ls: "ls"
method2: "echo 'Hello World 2'"
#multi line method
method3: |
echo 'Hello World $1'
echo 'Hello World $2'
env_var: "echo $ENV_VAR"

Empty file.
26 changes: 26 additions & 0 deletions contrib/drivers/shell/jumpstarter_driver_shell/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from dataclasses import dataclass

from jumpstarter.client import DriverClient


@dataclass(kw_only=True)
class ShellClient(DriverClient):
_methods: list[str] = None

"""
Client interface for Shell driver.
This client dynamically checks that the method is configured
on the driver, and if it is, it will call it and get the results
in the form of (stdout, stderr, returncode).
"""
def _check_method_exists(self, method):
if self._methods is None:
self._methods = self.call("get_methods")
if method not in self._methods:
raise AttributeError(f"method {method} not found in {self._methods}")

## capture any method calls dynamically
def __getattr__(self, name):
self._check_method_exists(name)
return lambda *args, **kwargs: tuple(self.call("call_method", name, kwargs, *args))
79 changes: 79 additions & 0 deletions contrib/drivers/shell/jumpstarter_driver_shell/driver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import logging
import os
import subprocess
from dataclasses import dataclass, field

from jumpstarter.driver import Driver, export

logger = logging.getLogger(__name__)

@dataclass(kw_only=True)
class Shell(Driver):
"""shell driver for Jumpstarter"""

# methods field is used to define the methods exported, and the shell script
# to be executed by each method
methods: dict[str, str]
shell: list[str] = field(default_factory=lambda: ["bash", "-c"])
log_level: str = "INFO"
cwd: str | None = None

def __post_init__(self):
super().__post_init__()
# set logger log level
logger.setLevel(self.log_level)

@classmethod
def client(cls) -> str:
return "jumpstarter_driver_shell.client.ShellClient"

@export
def get_methods(self) -> list[str]:
methods = list(self.methods.keys())
logger.debug(f"get_methods called, returning methods: {methods}")
return methods

@export
def call_method(self, method: str, env, *args):
logger.info(f"calling {method} with args: {args} and kwargs as env: {env}")
script = self.methods[method]
logger.debug(f"running script: {script}")
result = self._run_inline_shell_script(method, script, *args, env_vars=env)
if result.returncode != 0:
logger.info(f"{method} return code: {result.returncode}")
if result.stderr != "":
logger.debug(f"{method} stderr:\n{result.stderr.rstrip("\n")}")
if result.stdout != "":
logger.debug(f"{method} stdout:\n{result.stdout.rstrip("\n")}")
return result.stdout, result.stderr, result.returncode

def _run_inline_shell_script(self, method, script, *args, env_vars=None):
"""
Run the given shell script (as a string) with optional arguments and
environment variables. Returns a CompletedProcess with stdout, stderr, and returncode.
:param script: The shell script contents as a string.
:param args: Arguments to pass to the script (mapped to $1, $2, etc. in the script).
:param env_vars: A dict of environment variables to make available to the script.
:return: A subprocess.CompletedProcess object (Python 3.5+).
"""

# Merge parent environment with the user-supplied env_vars
# so that we don't lose existing environment variables.
combined_env = os.environ.copy()
if env_vars:
combined_env.update(env_vars)

cmd = self.shell + [script, method] + list(args)

# Run the command
result = subprocess.run(
cmd,
capture_output=True, # Captures stdout and stderr
text=True, # Returns stdout/stderr as strings (not bytes)
env=combined_env, # Pass our merged environment
cwd=self.cwd # Run in the working directory (if set)
)

return result
41 changes: 41 additions & 0 deletions contrib/drivers/shell/jumpstarter_driver_shell/driver_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import pytest

from jumpstarter.common.utils import serve

from .driver import Shell


@pytest.fixture
def client():
instance = Shell(log_level = "DEBUG",
methods={"echo": "echo $1",
"env": "echo $ENV1",
"multi_line": "echo $1\necho $2\necho $3",
"exit1": "exit 1",
"stderr": "echo $1 >&2"})
with serve(instance) as client:
yield client

def test_normal_args(client):
assert client.echo("hello") == ("hello\n", "", 0)


def test_env_vars(client):
assert client.env(ENV1="world") == ("world\n", "", 0)

def test_multi_line_scripts(client):
assert client.multi_line("a", "b", "c") == ("a\nb\nc\n", "", 0)

def test_return_codes(client):
assert client.exit1() == ("", "", 1)

def test_stderr(client):
assert client.stderr("error") == ("", "error\n", 0)

def test_unknown_method(client):
try:
client.unknown()
except AttributeError as e:
assert "method unknown not found in" in str(e)
else:
raise AssertionError("Expected AttributeError")
38 changes: 38 additions & 0 deletions contrib/drivers/shell/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[project]
name = "jumpstarter-driver-shell"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Miguel Angel Ajo", email = "[email protected]" }
]
requires-python = ">=3.11"
dependencies = [
"anyio>=4.6.2.post1",
"jumpstarter",
]

[tool.hatch.version]
source = "vcs"
raw-options = { 'root' = '../../../'}

[tool.hatch.metadata.hooks.vcs.urls]
Homepage = "https://jumpstarter.dev"
source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip"

[tool.pytest.ini_options]
addopts = "--cov --cov-report=html --cov-report=xml"
log_cli = true
log_cli_level = "INFO"
testpaths = ["jumpstarter_driver_shell"]

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[dependency-groups]
dev = [
"pytest-cov>=6.0.0",
"pytest>=8.3.3",
"ruff>=0.7.1",
]
3 changes: 2 additions & 1 deletion docs/source/api-reference/contrib/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ the jumpstarter core package, we package them separately to keep the core as
lightweight and dependency-free as possible.

```{toctree}
ustreamer.md
can.md
sdwire.md
shell.md
ustreamer.md
```
58 changes: 58 additions & 0 deletions docs/source/api-reference/contrib/shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Shell driver

**driver**: `jumpstarter_driver_shell.driver.Shell`

The methods of this client are dynamic, and they are generated from
the `methods` field of the exporter driver configuration.

## Driver configuration
```yaml
export:
example:
type: jumpstarter_driver_shell.driver.Shell
config:
methods:
ls: "ls"
method2: "echo 'Hello World 2'"
#multi line method
method3: |
echo 'Hello World $1'
echo 'Hello World $2'
env_var: "echo $1,$2,$ENV_VAR"
# optional parameters
cwd: "/tmp"
log_level: "INFO"
shell:
- "/bin/bash"
- "-c"
```
## ShellClient API
Assuming the exporter driver is configured as in the example above, the client
methods will be generated dynamically, and they will be available as follows:
```{eval-rst}
.. autoclass:: jumpstarter_driver_shell.client.ShellClient
:members:

.. function:: ls()
:noindex:

:returns: A tuple(stdout, stderr, return_code)

.. function:: method2()
:noindex:

:returns: A tuple(stdout, stderr, return_code)

.. function:: method3(arg1, arg2)
:noindex:

:returns: A tuple(stdout, stderr, return_code)

.. function:: env_var(arg1, arg2, ENV_VAR="value")
:noindex:

:returns: A tuple(stdout, stderr, return_code)
```

0 comments on commit 5b7e35e

Please sign in to comment.