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

Commit fd403f3

Browse files
add per-method description and timeout to shell commands
introducing new format in addition to the simple methods: method: "command" you can now specify per-method customization with: methods: method: description: "This shows up in the CLI" method: "echo Hello" timeout: 5
1 parent c6ecc55 commit fd403f3

File tree

4 files changed

+286
-83
lines changed

4 files changed

+286
-83
lines changed

packages/jumpstarter-driver-shell/README.md

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ $ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-shell
1111

1212
## Configuration
1313

14-
Example configuration:
14+
The shell driver supports two configuration formats for methods:
15+
16+
### Format 1: Simple String e.g. for self-descriptive short commands
1517

1618
```yaml
1719
export:
@@ -20,12 +22,40 @@ export:
2022
config:
2123
methods:
2224
ls: "ls"
23-
method2: "echo 'Hello World 2'"
24-
#multi line method
25-
method3: |
26-
echo 'Hello World $1'
27-
echo 'Hello World $2'
28-
env_var: "echo $1,$2,$ENV_VAR"
25+
echo_hello: "echo 'Hello World'"
26+
```
27+
28+
### Format 2: Unified Format with Descriptions
29+
30+
```yaml
31+
export:
32+
shell:
33+
type: jumpstarter_driver_shell.driver.Shell
34+
config:
35+
methods:
36+
ls:
37+
command: "ls -la"
38+
description: "List directory contents with details"
39+
deploy:
40+
command: "ansible-playbook deploy.yml"
41+
description: "Deploy application using Ansible"
42+
# Multi-line commands work too
43+
setup:
44+
command: |
45+
echo 'Setting up environment'
46+
export PATH=$PATH:/usr/local/bin
47+
./setup.sh
48+
description: "Set up the development environment"
49+
# Description-only (uses default "echo Hello" command)
50+
placeholder:
51+
description: "Placeholder method for testing"
52+
# Custom timeout for long-running operations
53+
long_backup:
54+
command: "tar -czf backup.tar.gz /data && rsync backup.tar.gz remote:/backups/"
55+
description: "Create and sync backup (may take a while)"
56+
timeout: 1800 # 30 minutes instead of default 5 minutes
57+
# You can mix both formats
58+
simple_echo: "echo 'simple'"
2959
# optional parameters
3060
cwd: "/tmp"
3161
log_level: "INFO"
@@ -34,6 +64,25 @@ export:
3464
- "-c"
3565
```
3666
67+
### Configuration Parameters
68+
69+
| Parameter | Description | Type | Required | Default |
70+
|-----------|-------------|------|----------|---------|
71+
| `methods` | Dictionary of methods. Values can be:<br/>- String: just the command<br/>- Dict: `{command: "...", description: "..."}` | `dict[str, str \| dict]` | Yes | - |
72+
| `cwd` | Working directory for shell commands | `str` | No | `None` |
73+
| `log_level` | Logging level | `str` | No | `"INFO"` |
74+
| `shell` | Shell command to execute scripts | `list[str]` | No | `["bash", "-c"]` |
75+
| `timeout` | Command timeout in seconds | `int` | No | `300` |
76+
77+
**Method Configuration Options:**
78+
79+
For the dict format, each method supports:
80+
- `command`: The shell command to execute (optional, defaults to `"echo Hello"`)
81+
- `description`: CLI help text (optional, defaults to `"Execute the {method_name} shell method"`)
82+
- `timeout`: Command-specific timeout in seconds (optional, defaults to global `timeout` value)
83+
84+
**Note:** You can mix both formats in the same configuration - use string format for simple commands and dict format when you want custom descriptions or timeouts.
85+
3786
## API Reference
3887

3988
Assuming the exporter driver is configured as in the example above, the client
@@ -66,7 +115,11 @@ methods will be generated dynamically, and they will be available as follows:
66115

67116
## CLI Usage
68117

69-
The shell driver also provides a CLI when using `jmp shell`. All configured methods become available as CLI commands, except for methods starting with `_` which are considered private and hidden from the end user:
118+
The shell driver also provides a CLI when using `jmp shell`. All configured methods become available as CLI commands, except for methods starting with `_` which are considered private and hidden from the end user.
119+
120+
### CLI Help Output
121+
122+
With unified format (custom descriptions):
70123

71124
```console
72125
$ jmp shell --exporter shell-exporter
@@ -76,10 +129,40 @@ Usage: j shell [OPTIONS] COMMAND [ARGS]...
76129
Shell command executor
77130
78131
Commands:
79-
env_var Execute the env_var shell method
80-
ls Execute the ls shell method
81-
method2 Execute the method2 shell method
82-
method3 Execute the method3 shell method
132+
deploy Deploy application using Ansible
133+
ls List directory contents with details
134+
setup Set up the development environment
135+
```
136+
137+
With simple string format (default descriptions):
138+
139+
```console
140+
$ j shell
141+
Usage: j shell [OPTIONS] COMMAND [ARGS]...
142+
143+
Shell command executor
144+
145+
Commands:
146+
deploy Execute the deploy shell method
147+
ls Execute the ls shell method
148+
setup Execute the setup shell method
149+
```
150+
151+
**Mixed format example:**
152+
153+
```yaml
154+
methods:
155+
deploy:
156+
command: "ansible-playbook deploy.yml"
157+
description: "Deploy using Ansible"
158+
restart: "systemctl restart myapp" # Simple format
159+
```
160+
161+
Results in:
162+
```console
163+
Commands:
164+
deploy Deploy using Ansible
165+
restart Execute the restart shell method
83166
```
84167

85168
### CLI Command Usage

packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,6 @@ def base():
5858

5959
def _add_method_command(self, group, method_name):
6060
"""Add a Click command for a specific shell method"""
61-
@group.command(
62-
name=method_name,
63-
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
64-
)
65-
@click.argument('args', nargs=-1, type=click.UNPROCESSED)
66-
@click.option('--env', '-e', multiple=True,
67-
help='Environment variables in KEY=VALUE format')
6861
def method_command(args, env):
6962
# Parse environment variables
7063
env_dict = {}
@@ -81,5 +74,18 @@ def method_command(args, env):
8174
if returncode != 0:
8275
raise click.exceptions.Exit(returncode)
8376

84-
# Update the docstring dynamically
85-
method_command.__doc__ = f"Execute the {method_name} shell method"
77+
# Try to get custom description, fall back to default for older than 0.7 servers
78+
try:
79+
description = self.call("get_method_description", method_name)
80+
except Exception:
81+
description = f"Execute {method_name}"
82+
83+
# Decorate and register the command
84+
method_command.__doc__ = description
85+
method_command = click.argument('args', nargs=-1, type=click.UNPROCESSED)(method_command)
86+
method_command = click.option('--env', '-e', multiple=True,
87+
help='Environment variables in KEY=VALUE format')(method_command)
88+
method_command = group.command(
89+
name=method_name,
90+
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
91+
)(method_command)

packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,36 @@
1212
class Shell(Driver):
1313
"""shell driver for Jumpstarter"""
1414

15-
# methods field is used to define the methods exported, and the shell script
16-
# to be executed by each method
17-
methods: dict[str, str]
15+
# methods field defines the methods exported and their shell scripts
16+
# Supports two formats:
17+
# 1. Simple string: method_name: "command"
18+
# 2. Dict with description: method_name: {command: "...", description: "..."}
19+
methods: dict[str, str | dict[str, str]]
1820
shell: list[str] = field(default_factory=lambda: ["bash", "-c"])
1921
timeout: int = 300
2022
cwd: str | None = None
2123

24+
def _get_method_command(self, method: str) -> str:
25+
"""Extract the command string from a method configuration"""
26+
method_config = self.methods[method]
27+
if isinstance(method_config, str):
28+
return method_config
29+
return method_config.get("command", "echo Hello")
30+
31+
def _get_method_description(self, method: str) -> str:
32+
"""Extract the description from a method configuration"""
33+
method_config = self.methods[method]
34+
if isinstance(method_config, str):
35+
return f"Execute the {method} shell method"
36+
return method_config.get("description", f"Execute the {method} shell method")
37+
38+
def _get_method_timeout(self, method: str) -> int:
39+
"""Extract the timeout from a method configuration, fallback to global timeout"""
40+
method_config = self.methods[method]
41+
if isinstance(method_config, str):
42+
return self.timeout
43+
return method_config.get("timeout", self.timeout)
44+
2245
@classmethod
2346
def client(cls) -> str:
2447
return "jumpstarter_driver_shell.client.ShellClient"
@@ -29,6 +52,11 @@ def get_methods(self) -> list[str]:
2952
self.logger.debug(f"get_methods called, returning methods: {methods}")
3053
return methods
3154

55+
@export
56+
def get_method_description(self, method: str) -> str:
57+
"""Get the description for a specific method"""
58+
return self._get_method_description(method)
59+
3260
@export
3361
async def call_method(self, method: str, env, *args) -> AsyncGenerator[tuple[str, str, int | None], None]:
3462
"""
@@ -39,12 +67,13 @@ async def call_method(self, method: str, env, *args) -> AsyncGenerator[tuple[str
3967
self.logger.info(f"calling {method} with args: {args} and kwargs as env: {env}")
4068
if method not in self.methods:
4169
raise ValueError(f"Method '{method}' not found in available methods: {list(self.methods.keys())}")
42-
script = self.methods[method]
43-
self.logger.debug(f"running script: {script}")
70+
script = self._get_method_command(method)
71+
timeout = self._get_method_timeout(method)
72+
self.logger.debug(f"running script: {script} with timeout: {timeout}")
4473

4574
try:
4675
async for stdout_chunk, stderr_chunk, returncode in self._run_inline_shell_script(
47-
method, script, *args, env_vars=env
76+
method, script, *args, env_vars=env, timeout=timeout
4877
):
4978
if stdout_chunk:
5079
self.logger.debug(f"{method} stdout:\n{stdout_chunk.rstrip()}")
@@ -121,7 +150,7 @@ async def _read_process_output(self, process, read_all=False):
121150
return stdout_data, stderr_data
122151

123152
async def _run_inline_shell_script(
124-
self, method, script, *args, env_vars=None
153+
self, method, script, *args, env_vars=None, timeout=None
125154
) -> AsyncGenerator[tuple[str, str, int | None], None]:
126155
"""
127156
Run the given shell script with live streaming output.
@@ -130,6 +159,7 @@ async def _run_inline_shell_script(
130159
:param script: The shell script contents as a string.
131160
:param args: Arguments to pass to the script (mapped to $1, $2, etc. in the script).
132161
:param env_vars: A dict of environment variables to make available to the script.
162+
:param timeout: Customized command timeout in seconds. If None, uses global timeout.
133163
134164
:yields: Tuples of (stdout_chunk, stderr_chunk, returncode).
135165
returncode is None until the process completes.
@@ -151,9 +181,12 @@ async def _run_inline_shell_script(
151181
# Create a task to monitor the process timeout
152182
start_time = asyncio.get_event_loop().time()
153183

184+
if timeout is None:
185+
timeout = self.timeout
186+
154187
# Read output in real-time
155188
while process.returncode is None:
156-
if asyncio.get_event_loop().time() - start_time > self.timeout:
189+
if asyncio.get_event_loop().time() - start_time > timeout:
157190
# Send SIGTERM to entire process group for graceful termination
158191
try:
159192
os.killpg(process.pid, signal.SIGTERM)
@@ -168,7 +201,7 @@ async def _run_inline_shell_script(
168201
self.logger.warning(f"SIGTERM failed to terminate {process.pid}, sending SIGKILL")
169202
except (ProcessLookupError, OSError):
170203
pass
171-
raise subprocess.TimeoutExpired(cmd, self.timeout) from None
204+
raise subprocess.TimeoutExpired(cmd, timeout) from None
172205

173206
try:
174207
stdout_data, stderr_data = await self._read_process_output(process, read_all=False)

0 commit comments

Comments
 (0)