Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 95 additions & 12 deletions packages/jumpstarter-driver-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ $ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-shell

## Configuration

Example configuration:
The shell driver supports two configuration formats for methods:

### Format 1: Simple String e.g. for self-descriptive short commands

```yaml
export:
Expand All @@ -20,12 +22,40 @@ export:
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"
echo_hello: "echo 'Hello World'"
```

### Format 2: Unified Format with Descriptions

```yaml
export:
shell:
type: jumpstarter_driver_shell.driver.Shell
config:
methods:
ls:
command: "ls -la"
description: "List directory contents with details"
deploy:
command: "ansible-playbook deploy.yml"
description: "Deploy application using Ansible"
# Multi-line commands work too
setup:
command: |
echo 'Setting up environment'
export PATH=$PATH:/usr/local/bin
./setup.sh
description: "Set up the development environment"
# Description-only (uses default "echo Hello" command)
placeholder:
description: "Placeholder method for testing"
# Custom timeout for long-running operations
long_backup:
command: "tar -czf backup.tar.gz /data && rsync backup.tar.gz remote:/backups/"
description: "Create and sync backup (may take a while)"
timeout: 1800 # 30 minutes instead of default 5 minutes
# You can mix both formats
simple_echo: "echo 'simple'"
# optional parameters
cwd: "/tmp"
log_level: "INFO"
Expand All @@ -34,6 +64,25 @@ export:
- "-c"
```

### Configuration Parameters

| Parameter | Description | Type | Required | Default |
|-----------|-------------|------|----------|---------|
| `methods` | Dictionary of methods. Values can be:<br/>- String: just the command<br/>- Dict: `{command: "...", description: "...", timeout: ...}` | `dict[str, str \| dict]` | Yes | - |
| `cwd` | Working directory for shell commands | `str` | No | `None` |
| `log_level` | Logging level | `str` | No | `"INFO"` |
| `shell` | Shell command to execute scripts | `list[str]` | No | `["bash", "-c"]` |
| `timeout` | Command timeout in seconds | `int` | No | `300` |

**Method Configuration Options:**

For the dict format, each method supports:
- `command`: The shell command to execute (optional, defaults to `"echo Hello"`)
- `description`: CLI help text (optional, defaults to `"Execute the {method_name} shell method"`)
- `timeout`: Command-specific timeout in seconds (optional, defaults to global `timeout` value)

**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.

## API Reference

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

## CLI Usage

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:
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.

### CLI Help Output

With unified format (custom descriptions):

```console
$ jmp shell --exporter shell-exporter
Expand All @@ -76,10 +129,40 @@ Usage: j shell [OPTIONS] COMMAND [ARGS]...
Shell command executor

Commands:
env_var Execute the env_var shell method
ls Execute the ls shell method
method2 Execute the method2 shell method
method3 Execute the method3 shell method
deploy Deploy application using Ansible
ls List directory contents with details
setup Set up the development environment
```

With simple string format (default descriptions):

```console
$ j shell
Usage: j shell [OPTIONS] COMMAND [ARGS]...

Shell command executor

Commands:
deploy Execute the deploy shell method
ls Execute the ls shell method
setup Execute the setup shell method
```

**Mixed format example:**

```yaml
methods:
deploy:
command: "ansible-playbook deploy.yml"
description: "Deploy using Ansible"
restart: "systemctl restart myapp" # Simple format
```

Results in:
```console
Commands:
deploy Deploy using Ansible
restart Execute the restart shell method
```

### CLI Command Usage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,6 @@ def base():

def _add_method_command(self, group, method_name):
"""Add a Click command for a specific shell method"""
@group.command(
name=method_name,
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
)
@click.argument('args', nargs=-1, type=click.UNPROCESSED)
@click.option('--env', '-e', multiple=True,
help='Environment variables in KEY=VALUE format')
def method_command(args, env):
# Parse environment variables
env_dict = {}
Expand All @@ -81,5 +74,18 @@ def method_command(args, env):
if returncode != 0:
raise click.exceptions.Exit(returncode)

# Update the docstring dynamically
method_command.__doc__ = f"Execute the {method_name} shell method"
# Try to get custom description, fall back to default for older than 0.7 servers
try:
description = self.call("get_method_description", method_name)
except Exception:
description = f"Execute the {method_name} shell method"

# Decorate and register the command
method_command.__doc__ = description
method_command = click.argument('args', nargs=-1, type=click.UNPROCESSED)(method_command)
method_command = click.option('--env', '-e', multiple=True,
help='Environment variables in KEY=VALUE format')(method_command)
method_command = group.command(
name=method_name,
context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
)(method_command)
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,36 @@
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]
# methods field defines the methods exported and their shell scripts
# Supports two formats:
# 1. Simple string: method_name: "command"
# 2. Dict with description: method_name: {command: "...", description: "...", timeout: ...}
methods: dict[str, str | dict[str, str | int]]
shell: list[str] = field(default_factory=lambda: ["bash", "-c"])
timeout: int = 300
cwd: str | None = None

def _get_method_command(self, method: str) -> str:
"""Extract the command string from a method configuration"""
method_config = self.methods[method]
if isinstance(method_config, str):
return method_config
return method_config.get("command", "echo Hello")

def _get_method_description(self, method: str) -> str:
"""Extract the description from a method configuration"""
method_config = self.methods[method]
if isinstance(method_config, str):
return f"Execute the {method} shell method"
return method_config.get("description", f"Execute the {method} shell method")

def _get_method_timeout(self, method: str) -> int:
"""Extract the timeout from a method configuration, fallback to global timeout"""
method_config = self.methods[method]
if isinstance(method_config, str):
return self.timeout
return method_config.get("timeout", self.timeout)

@classmethod
def client(cls) -> str:
return "jumpstarter_driver_shell.client.ShellClient"
Expand All @@ -29,6 +52,11 @@ def get_methods(self) -> list[str]:
self.logger.debug(f"get_methods called, returning methods: {methods}")
return methods

@export
def get_method_description(self, method: str) -> str:
"""Get the description for a specific method"""
return self._get_method_description(method)

@export
async def call_method(self, method: str, env, *args) -> AsyncGenerator[tuple[str, str, int | None], None]:
"""
Expand All @@ -39,12 +67,13 @@ async def call_method(self, method: str, env, *args) -> AsyncGenerator[tuple[str
self.logger.info(f"calling {method} with args: {args} and kwargs as env: {env}")
if method not in self.methods:
raise ValueError(f"Method '{method}' not found in available methods: {list(self.methods.keys())}")
script = self.methods[method]
self.logger.debug(f"running script: {script}")
script = self._get_method_command(method)
timeout = self._get_method_timeout(method)
self.logger.debug(f"running script: {script} with timeout: {timeout}")

try:
async for stdout_chunk, stderr_chunk, returncode in self._run_inline_shell_script(
method, script, *args, env_vars=env
method, script, *args, env_vars=env, timeout=timeout
):
if stdout_chunk:
self.logger.debug(f"{method} stdout:\n{stdout_chunk.rstrip()}")
Expand Down Expand Up @@ -121,7 +150,7 @@ async def _read_process_output(self, process, read_all=False):
return stdout_data, stderr_data

async def _run_inline_shell_script(
self, method, script, *args, env_vars=None
self, method, script, *args, env_vars=None, timeout=None
) -> AsyncGenerator[tuple[str, str, int | None], None]:
"""
Run the given shell script with live streaming output.
Expand All @@ -130,6 +159,7 @@ async def _run_inline_shell_script(
: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.
:param timeout: Customized command timeout in seconds. If None, uses global timeout.

:yields: Tuples of (stdout_chunk, stderr_chunk, returncode).
returncode is None until the process completes.
Expand All @@ -151,9 +181,12 @@ async def _run_inline_shell_script(
# Create a task to monitor the process timeout
start_time = asyncio.get_event_loop().time()

if timeout is None:
timeout = self.timeout

# Read output in real-time
while process.returncode is None:
if asyncio.get_event_loop().time() - start_time > self.timeout:
if asyncio.get_event_loop().time() - start_time > timeout:
# Send SIGTERM to entire process group for graceful termination
try:
os.killpg(process.pid, signal.SIGTERM)
Expand All @@ -168,7 +201,7 @@ async def _run_inline_shell_script(
self.logger.warning(f"SIGTERM failed to terminate {process.pid}, sending SIGKILL")
except (ProcessLookupError, OSError):
pass
raise subprocess.TimeoutExpired(cmd, self.timeout) from None
raise subprocess.TimeoutExpired(cmd, timeout) from None

try:
stdout_data, stderr_data = await self._read_process_output(process, read_all=False)
Expand Down
Loading
Loading