3 Customize Commands
mathew edited this page 2026-02-22 22:18:35 +00:00

Commands

Storm Pulse agents execute commands from a strict whitelist. Every command runs via subprocess.run(shell=False) with absolute binary paths and parameters sourced entirely from local config -- never from the wire. The dashboard sends the command name; the agent resolves it locally.


Built-in commands

Two commands ship with every agent:

Name Group Description Timeout Confirmation
git_pull deploy Pull latest changes from remote 60s No
docker_logs diagnostics Show recent service logs 30s No

The built-in docker_logs command has two overridable parameters:

Parameter Default Pattern Description
docker_service_name (from config) [a-zA-Z0-9_-]+ Docker Compose service name
tail_lines 100 [0-9]{1,5} Number of log lines to show

When docker_service_name has no runtime override, the value from project.docker_service_name in the config is used automatically.

Deploy-specific commands (docker build/down/up, django migrate, etc.) are project-specific. Define them in your config — see the example config for ready-to-paste TOML.

Template placeholders

Commands use placeholders that resolve from your [project] config:

Placeholder Source
{project_dir} project.project_dir
{compose_file} project.compose_file
{docker_service_name} project.docker_service_name
{env_file} project.env_file (optional)

When env_file is not set in the TOML, the --env-file flag and its value are automatically stripped from the resolved command. You don't need two config variants.


Disabling commands

To remove a built-in command from the registry, add it to disabled_commands in the [agent] section:

[agent]
id = "stormdevelopments.ca"
pulse_token = "..."
disabled_commands = ["docker_logs"]

The command is removed at startup. It doesn't exist in the registry, so:

  • HMAC-signed requests for it return "Unknown command"
  • The dashboard sees it missing from the agent's commands list in the register message and can hide the button
  • A deploy sequence will fail upfront validation if it includes a disabled command

To re-enable, remove the name from the list (or remove disabled_commands entirely).


Custom commands

Add new commands in the [commands.<name>] section of your TOML config. Each command is a whitelisted shell-free subprocess call.

Required fields

Field Type Description
group string Category for the dashboard UI (e.g. "maintenance", "deploy"). Must not be empty.
command array of strings The executable and arguments. First element must be an absolute path.
timeout integer Maximum execution time in seconds. Must be positive.

Optional fields

Field Type Default Description
requires_confirmation boolean false If true, the dashboard should show a confirmation dialog before sending.
description string "" Human-readable description shown in the dashboard.

Example: restart Caddy

[commands.restart_caddy]
group = "maintenance"
command = ["/usr/bin/systemctl", "restart", "caddy.service"]
timeout = 30
requires_confirmation = true
description = "Restart Caddy reverse proxy"

This adds restart_caddy to the registry alongside the built-ins. The dashboard will see it in the register message and can render a button for it.

Example: override a built-in

Custom commands override built-ins on name collision. If your git binary is in a different location:

[commands.git_pull]
group = "deploy"
command = ["/usr/local/bin/git", "-C", "{project_dir}", "pull"]
timeout = 120

This replaces the built-in git_pull with your version. Placeholders like {project_dir} work in custom commands too.

Using placeholders in custom commands

Custom commands can use the same placeholders as built-ins:

[commands.collectstatic]
group = "deploy"
command = [
    "/usr/bin/docker", "compose",
    "--env-file", "{env_file}",
    "-f", "{compose_file}",
    "exec", "{docker_service_name}",
    "python", "manage.py", "collectstatic", "--noinput"
]
timeout = 60
description = "Collect Django static files"

Validation rules

  • command[0] must be an absolute path (starts with /). Relative paths are rejected at config load time.
  • All elements in command must be strings.
  • timeout must be a positive integer.
  • group must be a non-empty string.
  • requires_confirmation must be a boolean if present.
  • description must be a string if present.

Parameters

Commands can declare overridable parameters using [commands.<name>.params.<param>] sub-tables. This lets the dashboard send runtime overrides (e.g. {"service": "celery"}) instead of requiring a separate command per variation.

Declaring parameters

Each parameter is a sub-table under params with these fields:

Field Type Required Description
placeholder string Yes The placeholder name. Must match the sub-table key.
default string No Static default value. Omit to let the config provide the fallback.
pattern string Yes Regex pattern for validation (matched with re.fullmatch).
description string No Human-readable description.

Example

[commands.service_logs]
group = "diagnostics"
command = ["/usr/bin/docker", "compose", "-f", "{compose_file}", "logs", "--tail", "{lines}", "{service}"]
timeout = 30
description = "Show recent logs for a specific service"

[commands.service_logs.params.service]
placeholder = "service"
default = "web"
pattern = "[a-zA-Z0-9_-]+"
description = "Docker Compose service name"

[commands.service_logs.params.lines]
placeholder = "lines"
default = "100"
pattern = "[0-9]{1,5}"
description = "Number of log lines to show"

The dashboard sends {"service": "celery", "lines": "50"} in the command.request params to override the defaults. If a param is not sent, the default is used.

Resolution order

For each declared parameter:

  1. If the dashboard sends a runtime override, use it (after regex validation).
  2. If no override and the param has a default, use the default.
  3. If no override and no default (default key omitted in TOML), skip -- the config-level placeholder provides the value.

Config-level placeholders ({project_dir}, {compose_file}, {env_file}) are resolved separately and cannot be overridden from the wire.

Protected placeholders

These placeholders are reserved for config-level resolution and cannot be declared as parameters in custom commands:

  • project_dir
  • compose_file
  • env_file

Attempting to declare a parameter with a protected placeholder name will fail at config load time.

Note: docker_service_name is not protected. Built-in commands declare it as a parameter with default=null, which lets the config provide the default while still allowing runtime overrides from the dashboard (e.g. {"docker_service_name": "celery"}).

HMAC signing

Runtime params are included in the HMAC canonical string. The canonical format for command.request is:

v1\n{command}\n{params_canonical}\n{nonce}\n{timestamp}

Where params_canonical is the sorted key=value pairs joined by & (e.g. lines=50&service=celery). Both the dashboard and agent must produce the same canonical string for the signature to verify.

Validation

The agent validates all runtime params before execution:

  • Unknown params (not declared in ParamDef) are rejected.
  • Values must match the declared pattern regex (fullmatch).
  • All param values must be strings.

How the dashboard knows what's available

On every WebSocket connection, the agent sends a register message that includes a commands field -- a dict of all available commands with full metadata from the final registry (built-ins + custom - disabled):

{
  "type": "register",
  "payload": {
    "version": "0.1.0",
    "pulse_token": "...",
    "commands": {
      "git_pull": {
        "group": "deploy",
        "description": "Pull latest changes from remote",
        "template": ["git", "-C", "{project_dir}", "pull"],
        "timeout": 60,
        "requires_confirmation": false,
        "params": {}
      },
      "docker_logs": {
        "group": "diagnostics",
        "description": "Show recent service logs",
        "template": ["docker", "compose", "--env-file", "{env_file}", "-f", "{compose_file}", "logs", "--tail", "{tail_lines}", "{docker_service_name}"],
        "timeout": 30,
        "requires_confirmation": false,
        "params": {
          "docker_service_name": {
            "default": null,
            "pattern": "[a-zA-Z0-9_-]+",
            "description": "Docker Compose service name"
          },
          "tail_lines": {
            "default": "100",
            "pattern": "[0-9]{1,5}",
            "description": "Number of log lines to show"
          }
        }
      }
    }
  }
}

The dashboard stores this metadata on the server record and uses it to:

  • Render command buttons grouped by group
  • Show description in tooltips
  • Display the template so operators can see what runs
  • Show a confirmation dialog when requires_confirmation is true
  • Render input fields for params with defaults and validation patterns
  • Disable the Deploy button if any sequence step is missing from the command keys
  • Return an error if someone tries to send a command the agent doesn't have

If the agent reconnects with a different set of commands (e.g. after a config change and restart), the dashboard updates automatically.

Older agents that don't send commands will have null -- the dashboard should treat this as "all built-in commands available" for backward compatibility.


Execution model

All commands run with:

  • subprocess.run(shell=False) -- no shell interpretation, no injection possible
  • capture_output=True -- stdout and stderr are captured and sent back to the dashboard
  • Per-command timeout -- the process is killed if it exceeds the configured timeout
  • Config-level placeholders from local config only -- the dashboard sends a command name and optional runtime params. Params are regex-validated against ParamDef declarations before substitution. Protected placeholders (project_dir, compose_file, env_file) cannot be overridden from the wire

Result reporting

Every command execution produces a command.result message with:

Field Description
success true if exit code is 0
exit_code The process exit code, or -1 for timeout/not_found/os_error
stdout Captured standard output
stderr Captured standard error
duration_ms Execution time in milliseconds
failure_reason null on success, or one of: exit_code, timeout, not_found, os_error

Failure modes

failure_reason What happened
exit_code Command ran but exited non-zero. Check exit_code and stderr.
timeout Command exceeded its timeout. Partial output may be available.
not_found Binary doesn't exist (e.g. Docker not installed).
os_error OS-level failure (permissions, resource limits).