Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit dcc4adb

Browse files
committed
Shell driver for the exporter.
This drivers enables shell methods on exporters, so local tools can be used remotelly when no specific drivers are available.
1 parent c0467ff commit dcc4adb

File tree

10 files changed

+261
-1
lines changed

10 files changed

+261
-1
lines changed

contrib/drivers/shell/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
.coverage
3+
coverage.xml

contrib/drivers/shell/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Jumpstarter Driver for shell access
2+
3+
This driver provides a simple shell access to the target exporter, and it is
4+
intended to be used when command line tools exist to manage existing interfaces
5+
or hardware, but no drivers exist yet in Jumpstarter.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
apiVersion: jumpstarter.dev/v1alpha1
2+
kind: ExporterConfig
3+
endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082
4+
token: "<token>"
5+
export:
6+
example:
7+
type: jumpstarter_driver_shell.driver.Shell
8+
config:
9+
methods:
10+
ls: "ls"
11+
method2: "echo 'Hello World 2'"
12+
#multi line method
13+
method3: |
14+
echo 'Hello World $1'
15+
echo 'Hello World $2'
16+
env_var: "echo $ENV_VAR"
17+

contrib/drivers/shell/jumpstarter_driver_shell/__init__.py

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from dataclasses import dataclass
2+
3+
from jumpstarter.client import DriverClient
4+
5+
6+
@dataclass(kw_only=True)
7+
class ShellClient(DriverClient):
8+
_methods: list[str] = None
9+
10+
"""
11+
Client interface for Shell driver.
12+
13+
This client dynamically checks that the method is configured
14+
on the driver, and if it is, it will call it and get the results
15+
in the form of (stdout, stderr, returncode).
16+
"""
17+
def _check_method_exists(self, method):
18+
if self._methods is None:
19+
self._methods = self.call("get_methods")
20+
if method not in self._methods:
21+
raise AttributeError(f"method {method} not found in {self._methods}")
22+
23+
## capture any method calls dynamically
24+
def __getattr__(self, name):
25+
self._check_method_exists(name)
26+
return lambda *args, **kwargs: tuple(self.call("call_method", name, kwargs, *args))
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import logging
2+
import os
3+
import subprocess
4+
from dataclasses import dataclass, field
5+
6+
from jumpstarter.driver import Driver, export
7+
8+
logger = logging.getLogger(__name__)
9+
10+
@dataclass(kw_only=True)
11+
class Shell(Driver):
12+
"""shell driver for Jumpstarter"""
13+
14+
# methods field is used to define the methods exported, and the shell script
15+
# to be executed by each method
16+
methods: dict[str, str]
17+
shell: list[str] = field(default_factory=lambda: ["bash", "-c"])
18+
log_level: str = "INFO"
19+
20+
def __post_init__(self):
21+
super().__post_init__()
22+
# set logger log level
23+
logger.setLevel(self.log_level)
24+
25+
@classmethod
26+
def client(cls) -> str:
27+
return "jumpstarter_driver_shell.client.ShellClient"
28+
29+
@export
30+
def get_methods(self) -> list[str]:
31+
methods = list(self.methods.keys())
32+
logger.debug(f"get_methods called, returning methods: {methods}")
33+
return methods
34+
35+
@export
36+
def call_method(self, method: str, env, *args):
37+
logger.info(f"calling {method} with args: {args} and kwargs as env: {env}")
38+
script = self.methods[method]
39+
logger.debug(f"running script: {script}")
40+
result = self._run_inline_shell_script(method, script, *args, env_vars=env)
41+
if result.returncode != 0:
42+
logger.info(f"{method} return code: {result.returncode}")
43+
if result.stderr != "":
44+
logger.debug(f"{method} stderr:\n{result.stderr.rstrip("\n")}")
45+
if result.stdout != "":
46+
logger.debug(f"{method} stdout:\n{result.stdout.rstrip("\n")}")
47+
return result.stdout, result.stderr, result.returncode
48+
49+
def _run_inline_shell_script(self, method, script, *args, env_vars=None):
50+
"""
51+
Run the given shell script (as a string) with optional arguments and
52+
environment variables. Returns a CompletedProcess with stdout, stderr, and returncode.
53+
54+
:param script: The shell script contents as a string.
55+
:param args: Arguments to pass to the script (mapped to $1, $2, etc. in the script).
56+
:param env_vars: A dict of environment variables to make available to the script.
57+
58+
:return: A subprocess.CompletedProcess object (Python 3.5+).
59+
"""
60+
61+
# Merge parent environment with the user-supplied env_vars
62+
# so that we don't lose existing environment variables.
63+
combined_env = os.environ.copy()
64+
if env_vars:
65+
combined_env.update(env_vars)
66+
67+
cmd = self.shell + [script, method] + list(args)
68+
69+
# Run the command
70+
result = subprocess.run(
71+
cmd,
72+
capture_output=True, # Captures stdout and stderr
73+
text=True, # Returns stdout/stderr as strings (not bytes)
74+
env=combined_env # Pass our merged environment
75+
)
76+
77+
return result
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import pytest
2+
3+
from jumpstarter.common.utils import serve
4+
5+
from .driver import Shell
6+
7+
8+
@pytest.fixture
9+
def client():
10+
instance = Shell(log_level = "DEBUG",
11+
methods={"echo": "echo $1",
12+
"env": "echo $ENV1",
13+
"multi_line": "echo $1\necho $2\necho $3",
14+
"exit1": "exit 1",
15+
"stderr": "echo $1 >&2"})
16+
with serve(instance) as client:
17+
yield client
18+
19+
def test_normal_args(client):
20+
assert client.echo("hello") == ("hello\n", "", 0)
21+
22+
23+
def test_env_vars(client):
24+
assert client.env(ENV1="world") == ("world\n", "", 0)
25+
26+
def test_multi_line_scripts(client):
27+
assert client.multi_line("a", "b", "c") == ("a\nb\nc\n", "", 0)
28+
29+
def test_return_codes(client):
30+
assert client.exit1() == ("", "", 1)
31+
32+
def test_stderr(client):
33+
assert client.stderr("error") == ("", "error\n", 0)
34+
35+
def test_unknown_method(client):
36+
try:
37+
client.unknown()
38+
except AttributeError as e:
39+
assert "method unknown not found in" in str(e)
40+
else:
41+
raise AssertionError("Expected AttributeError")
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[project]
2+
name = "jumpstarter-driver-shell"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
authors = [
7+
{ name = "Miguel Angel Ajo", email = "miguelangel@ajo.es" }
8+
]
9+
requires-python = ">=3.11"
10+
dependencies = [
11+
"anyio>=4.6.2.post1",
12+
"jumpstarter",
13+
]
14+
15+
[tool.hatch.version]
16+
source = "vcs"
17+
raw-options = { 'root' = '../../../'}
18+
19+
[tool.hatch.metadata.hooks.vcs.urls]
20+
Homepage = "https://jumpstarter.dev"
21+
source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip"
22+
23+
[tool.pytest.ini_options]
24+
addopts = "--cov --cov-report=html --cov-report=xml"
25+
log_cli = true
26+
log_cli_level = "INFO"
27+
testpaths = ["jumpstarter_driver_shell"]
28+
29+
[build-system]
30+
requires = ["hatchling", "hatch-vcs"]
31+
build-backend = "hatchling.build"
32+
33+
[dependency-groups]
34+
dev = [
35+
"pytest-cov>=6.0.0",
36+
"pytest>=8.3.3",
37+
"ruff>=0.7.1",
38+
]

docs/source/api-reference/contrib/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ the jumpstarter core package, we package them separately to keep the core as
66
lightweight and dependency-free as possible.
77

88
```{toctree}
9-
ustreamer.md
109
can.md
1110
sdwire.md
11+
shell.md
12+
ustreamer.md
1213
```
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Shell driver
2+
3+
**driver**: `jumpstarter_driver_shell.driver.Shell`
4+
5+
The methods of this client are dynamic, and they are generated from
6+
the `methods` field of the exporter driver configuration.
7+
8+
## Driver configuration
9+
```yaml
10+
export:
11+
example:
12+
type: jumpstarter_driver_shell.driver.Shell
13+
config:
14+
methods:
15+
ls: "ls"
16+
method2: "echo 'Hello World 2'"
17+
#multi line method
18+
method3: |
19+
echo 'Hello World $1'
20+
echo 'Hello World $2'
21+
env_var: "echo $1,$2,$ENV_VAR"
22+
```
23+
24+
## ShellClient API
25+
26+
Assuming the exporter driver is configured as in the example above, the client
27+
methods will be generated dynamically, and they will be available as follows:
28+
29+
```{eval-rst}
30+
.. autoclass:: jumpstarter_driver_shell.client.ShellClient
31+
:members:
32+
33+
.. function:: ls()
34+
:noindex:
35+
36+
:returns: A tuple(stdout, stderr, return_code)
37+
38+
.. function:: method2()
39+
:noindex:
40+
41+
:returns: A tuple(stdout, stderr, return_code)
42+
43+
.. function:: method3(arg1, arg2)
44+
:noindex:
45+
46+
:returns: A tuple(stdout, stderr, return_code)
47+
48+
.. function:: env_var(arg1, arg2, ENV_VAR="value")
49+
:noindex:
50+
51+
:returns: A tuple(stdout, stderr, return_code)
52+
```

0 commit comments

Comments
 (0)