Exec Type

This document describes the design of the exec resource type for executing commands.

Overview

The exec resource executes commands with idempotency controls:

  • Creates: Skip execution if a file exists
  • Refresh Only: Only execute when triggered by a subscribed resource
  • Exit Codes: Validate success via configurable return codes

Provider Interface

Exec providers must implement the ExecProvider interface:

type ExecProvider interface {
    model.Provider

    Execute(ctx context.Context, properties *model.ExecResourceProperties, log model.Logger) (int, error)
    Status(ctx context.Context, properties *model.ExecResourceProperties) (*model.ExecState, error)
}

Method Responsibilities

MethodResponsibility
StatusCheck if creates file exists, return current state
ExecuteRun the command, return exit code

Status Response

The Status method returns an ExecState containing:

type ExecState struct {
    CommonResourceState

    ExitCode         *int // Exit code from last execution (nil if not run)
    CreatesSatisfied bool // Whether creates file exists
}

The Ensure field in CommonResourceState is set to:

  • present if the creates file exists
  • absent if the creates file does not exist (or not specified)

Available Providers

ProviderExecution MethodDocumentation
posixDirect exec (no shell)Posix
shellVia /bin/sh -cShell

Properties

PropertyTypeDescription
commandstringCommand to run (defaults to name if not set)
cwdstringWorking directory for command execution
environment[]stringAdditional environment variables (KEY=value)
pathstringSearch path for executables (colon-separated)
returns[]intAcceptable exit codes (default: [0])
timeoutstringMaximum execution time (e.g., 30s, 5m)
createsstringFile path; skip execution if exists
refresh_onlyboolOnly execute via subscribe refresh
subscribe[]stringResources to watch for changes (type#name)
logoutputboolLog command output

Apply Logic

┌─────────────────────────────────────────┐
│ Get current state via Status()          │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│ Check for subscribe refresh             │
└─────────────────┬───────────────────────┘
                  │
    ┌─────────────┴─────────────┐
    │ Subscribed resource       │
    │ changed?                  │
    └─────────────┬─────────────┘
              Yes │         No
                  ▼         │
          ┌───────────┐     │
          │ Execute   │     │
          └───────────┘     │
                            ▼
              ┌─────────────────────────┐
              │ Is desired state met?   │
              │ (creates file exists OR │
              │  refresh_only is true)  │
              └─────────────┬───────────┘
                        Yes │         No
                            ▼         │
                    ┌───────────┐     │
                    │ Skip      │     │
                    └───────────┘     │
                                      ▼
                        ┌─────────────────────────┐
                        │ Is refresh_only = true? │
                        └─────────────┬───────────┘
                                  Yes │         No
                                      ▼         │
                              ┌───────────┐     │
                              │ Skip      │     │
                              └───────────┘     │
                                                ▼
                                        ┌───────────┐
                                        │ Execute   │
                                        └───────────┘

Idempotency

The exec resource provides idempotency through two mechanisms:

Creates Property

The creates property specifies a file that indicates successful prior execution:

- exec:
    - extract-archive:
        command: tar xzf /tmp/app.tar.gz -C /opt
        creates: /opt/app/bin/app

Behavior:

  • If /opt/app/bin/app exists, skip execution
  • Useful for one-time setup commands
  • Provider checks file existence via Status()

Refresh Only Property

The refresh_only property limits execution to subscribe refreshes:

- exec:
    - reload-nginx:
        command: systemctl reload nginx
        refresh_only: true
        subscribe:
          - file#/etc/nginx/nginx.conf

Behavior:

  • Command only runs when subscribed resource changes
  • Without a subscribe trigger, command is skipped
  • Useful for reload/restart commands

Decision Table

ConditionAction
Subscribe triggeredExecute
creates file existsSkip
refresh_only: true + no triggerSkip
refresh_only: false + no createsExecute

Subscribe Behavior

Exec resources can subscribe to other resources and execute when they change:

- file:
    - /etc/app/config.yaml:
        ensure: present
        content: "..."

- exec:
    - reload-app:
        command: systemctl reload app
        refresh_only: true
        subscribe:
          - file#/etc/app/config.yaml

Subscribe takes precedence over other idempotency checks - if a subscribed resource changed, the command executes regardless of creates file existence.

Exit Code Validation

By default, exit code 0 indicates success. The returns property customizes acceptable codes:

- exec:
    - check-status:
        command: /usr/local/bin/check-health
        returns:
          - 0
          - 1
          - 2

Behavior:

  • Command succeeds if exit code is in returns list
  • Command fails if exit code is not in returns list
  • Used for desired state validation after execution

Noop Mode

In noop mode, the exec type:

  1. Queries current state normally (checks creates file)
  2. Evaluates subscribe triggers
  3. Logs what actions would be taken
  4. Sets appropriate NoopMessage:
    • “Would have executed”
    • “Would have executed via subscribe”
  5. Reports Changed: true if execution would occur
  6. Does not call provider Execute method

Desired State Validation

After execution (in non-noop mode), the type verifies success:

func (t *Type) isDesiredState(properties, status) bool {
    // Creates file check takes precedence
    if properties.Creates != "" && status.CreatesSatisfied {
        return true
    }

    // Refresh-only without execution is stable
    if status.ExitCode == nil && properties.RefreshOnly {
        return true
    }

    // Check exit code against acceptable returns
    returns := []int{0}
    if len(properties.Returns) > 0 {
        returns = properties.Returns
    }

    if status.ExitCode != nil {
        return slices.Contains(returns, *status.ExitCode)
    }

    return false
}

If the exit code is not in the acceptable returns list, an ErrDesiredStateFailed error is returned.

Command vs Name

The command property is optional. If not specified, the name is used as the command:

# These are equivalent:
- exec:
    - /usr/bin/myapp --config /etc/myapp.conf:

- exec:
    - run-myapp:
        command: /usr/bin/myapp --config /etc/myapp.conf

Using a descriptive name with explicit command is recommended for clarity.

Environment and Path

Commands can be configured with custom environment:

- exec:
    - build-app:
        command: make build
        cwd: /opt/app
        environment:
          - CC=gcc
          - CFLAGS=-O2
        path: /usr/local/bin:/usr/bin:/bin

Environment:

  • Added to the command’s environment
  • Format: KEY=value
  • Does not replace existing environment

Path:

  • Sets the PATH for executable lookup
  • Must be absolute directories
  • Colon-separated list

Subsections of Exec Type

Posix Provider

This document describes the implementation details of the Posix exec provider for executing commands without a shell.

Provider Selection

The Posix provider is the default exec provider. It is always available and returns priority 1 for all exec resources unless a different provider is explicitly requested via the provider property.

To use the shell provider instead, specify provider: shell in the resource properties.

Comparison with Shell Provider

FeaturePosixShell
Shell invocationNoYes (/bin/sh -c)
Pipes (|)Not supportedSupported
Redirections (>, <)Not supportedSupported
Shell builtins (cd, export)Not supportedSupported
Glob expansionNot supportedSupported
Command substitution ($(...))Not supportedSupported
Argument parsingshellquote.Split()Passed as single string
SecurityLower attack surfaceShell injection possible

When to use Posix (default):

  • Simple commands with arguments
  • When shell features are not needed
  • For better security (no shell injection risk)

When to use Shell:

  • Commands with pipes, redirections, or shell builtins
  • Complex command strings
  • When shell expansion is required

Operations

Execute

Process:

  1. Determine command source (Command property or Name if Command is empty)
  2. Parse command string into words using shellquote.Split()
  3. Extract command (first word) and arguments (remaining words)
  4. Execute via CommandRunner.ExecuteWithOptions()
  5. Optionally log output line-by-line if LogOutput is enabled

Command Parsing:

The command string is parsed using github.com/kballard/go-shellquote, which handles:

SyntaxExampleResult
Simple wordsecho hello world["echo", "hello", "world"]
Single quotesecho 'hello world'["echo", "hello world"]
Double quotesecho "hello world"["echo", "hello world"]
Escaped spacesecho hello\ world["echo", "hello world"]
Mixed quotingecho "it's a test"["echo", "it's a test"]

Execution Options:

OptionSourceDescription
CommandFirst word after parsingExecutable path or name
ArgsRemaining wordsCommand arguments
Cwdproperties.CwdWorking directory
Environmentproperties.EnvironmentAdditional env vars (KEY=VALUE format)
Pathproperties.PathSearch path for executables
Timeoutproperties.ParsedTimeoutMaximum execution time

Output Logging:

When LogOutput: true is set and a user logger is provided:

scanner := bufio.NewScanner(bytes.NewReader(stdout))
for scanner.Scan() {
    log.Info(scanner.Text())
}

Each line of stdout is logged as a separate Info message.

Error Handling:

ConditionBehavior
Empty command stringReturn error: “no command specified”
Invalid shell quotingReturn parsing error (e.g., “Unterminated single quote”)
Runner not configuredReturn error: “no command runner configured”
Command execution failsReturn error from runner
Non-zero exit codeReturn exit code (not an error by itself)

Status

Process:

  1. Create state with EnsurePresent (exec resources are always “present”)
  2. Check if Creates file exists via util.FileExists()
  3. Set CreatesSatisfied accordingly

State Fields:

FieldValue
Protocolio.choria.ccm.v1.resource.exec.state
ResourceTypeexec
NameResource name
Ensurepresent (always)
CreatesSatisfiedtrue if Creates file exists

Idempotency

The exec resource achieves idempotency through multiple mechanisms:

Creates File

If creates is specified and the file exists, the command does not run:

- exec:
    - /usr/bin/tar -xzf app.tar.gz:
        creates: /opt/app/bin/app
        cwd: /opt

RefreshOnly Mode

When refreshonly: true, the command only runs when triggered by a subscribed resource:

- exec:
    - systemctl reload httpd:
        refreshonly: true
        subscribe:
          - file#/etc/httpd/conf/httpd.conf

Exit Code Validation

The returns property specifies acceptable exit codes (default: [0]):

- exec:
    - /opt/app/healthcheck:
        returns: [0, 1, 2]  # 0=healthy, 1=degraded, 2=warning

Decision Flow

┌─────────────────────────────────────────┐
│ Should resource be applied?             │
└─────────────────┬───────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────┐
│ Subscribe triggered?                     │
│ (subscribed resource changed)            │
└─────────────┬───────────────┬───────────┘
              │ Yes           │ No
              ▼               ▼
┌─────────────────┐   ┌───────────────────┐
│ Execute command │   │ Creates satisfied? │
└─────────────────┘   └─────────┬─────────┘
                                │
                      ┌─────────┴─────────┐
                      │ Yes               │ No
                      ▼                   ▼
              ┌───────────────┐   ┌───────────────────┐
              │ Skip (stable) │   │ RefreshOnly mode? │
              └───────────────┘   └─────────┬─────────┘
                                            │
                                  ┌─────────┴─────────┐
                                  │ Yes               │ No
                                  ▼                   ▼
                          ┌───────────────┐   ┌───────────────┐
                          │ Skip (stable) │   │ Execute       │
                          └───────────────┘   └───────────────┘

Properties Validation

The model validates exec properties before execution:

PropertyValidation
nameMust be parseable by shellquote (balanced quotes)
timeoutMust be valid duration format (e.g., 30s, 5m)
subscribeEach entry must be type#name format
pathEach directory must be absolute (start with /)
environmentEach entry must be KEY=VALUE format with non-empty key and value

Platform Support

The Posix provider works on all platforms supported by Go’s os/exec package. It does not use any platform-specific system calls directly.

The command runner (model.CommandRunner) handles the actual process execution, which may have platform-specific implementations.

Security Considerations

No Shell Injection

Unlike the shell provider, the posix provider does not invoke a shell. Arguments are passed directly to the executable, preventing shell injection attacks:

# Safe with posix provider - $USER is passed literally, not expanded
- exec:
    - /bin/echo $USER:
        provider: posix  # Default

# Potentially dangerous with shell provider - $USER is expanded
- exec:
    - /bin/echo $USER:
        provider: shell

Path Validation

The path property only accepts absolute directory paths, preventing path traversal via relative paths.

Environment Validation

Environment variables must have non-empty keys and values, preventing injection of empty or malformed entries.

Shell Provider

This document describes the implementation details of the Shell exec provider for executing commands via /bin/sh.

Provider Selection

The Shell provider is selected when provider: shell is explicitly specified in the resource properties. It has a lower priority (99) than the Posix provider (1), so it is never automatically selected.

Availability: The provider checks for the existence of /bin/sh via util.FileExists(). If /bin/sh does not exist, the provider is not available.

Comparison with Posix Provider

FeatureShellPosix
Shell invocationYes (/bin/sh -c)No
Pipes (|)SupportedNot supported
Redirections (>, <, >>)SupportedNot supported
Shell builtins (cd, export, source)SupportedNot supported
Glob expansion (*.txt, ?)SupportedNot supported
Command substitution ($(...), `...`)SupportedNot supported
Variable expansion ($VAR, ${VAR})SupportedNot supported
Logical operators (&&, ||)SupportedNot supported
Argument parsingPassed as single stringshellquote.Split()
SecurityShell injection possibleLower attack surface

When to use Shell:

  • Commands with pipes: cat file.txt | grep pattern | sort
  • Commands with redirections: echo "data" > /tmp/file
  • Commands with shell builtins: cd /tmp && pwd
  • Commands with variable expansion: echo $HOME
  • Complex one-liners with logical operators

When to use Posix (default):

  • Simple commands with arguments
  • When shell features are not needed
  • For better security (no shell injection risk)

Operations

Execute

Process:

  1. Determine command source (Command property or Name if Command is empty)
  2. Validate command is not empty
  3. Execute via CommandRunner.ExecuteWithOptions() with /bin/sh -c "<command>"
  4. Optionally log output line-by-line if LogOutput is enabled

Execution Method:

The entire command string is passed to the shell as a single argument:

/bin/sh -c "<entire command string>"

This allows the shell to interpret all shell syntax, including:

  • Pipes and redirections
  • Variable expansion
  • Glob patterns
  • Command substitution
  • Logical operators

Execution Options:

OptionValueDescription
Command/bin/shShell executable path
Args["-c", "<command>"]Shell flag and command string
Cwdproperties.CwdWorking directory
Environmentproperties.EnvironmentAdditional env vars (KEY=VALUE format)
Pathproperties.PathSearch path for executables
Timeoutproperties.ParsedTimeoutMaximum execution time

Output Logging:

When LogOutput: true is set and a user logger is provided:

scanner := bufio.NewScanner(bytes.NewReader(stdout))
for scanner.Scan() {
    log.Info(scanner.Text())
}

Each line of stdout is logged as a separate Info message.

Error Handling:

ConditionBehavior
Empty command stringReturn error: “no command to execute”
Runner not configuredReturn error: “no command runner configured”
Shell not foundProvider not available (checked at selection time)
Command execution failsReturn error from runner
Non-zero exit codeReturn exit code (not an error by itself)

Status

Process:

  1. Create state with EnsurePresent (exec resources are always “present”)
  2. Check if Creates file exists via util.FileExists()
  3. Set CreatesSatisfied accordingly

State Fields:

FieldValue
Protocolio.choria.ccm.v1.resource.exec.state
ResourceTypeexec
NameResource name
Ensurepresent (always)
CreatesSatisfiedtrue if Creates file exists

Use Cases

Pipes and Filters

- exec:
    - filter-logs:
        command: cat /var/log/app.log | grep ERROR | tail -100 > /tmp/errors.txt
        provider: shell
        creates: /tmp/errors.txt

Conditional Execution

- exec:
    - ensure-running:
        command: pgrep myapp || /opt/myapp/bin/start
        provider: shell

Complex Scripts

- exec:
    - deploy-app:
        command: |
          cd /opt/app &&
          git pull origin main &&
          npm install &&
          npm run build &&
          systemctl restart app
        provider: shell
        timeout: 5m

Variable Expansion

- exec:
    - backup-home:
        command: tar -czf /backup/home-$(date +%Y%m%d).tar.gz $HOME
        provider: shell

Idempotency

The shell provider uses the same idempotency mechanisms as the posix provider:

Creates File

If creates is specified and the file exists, the command does not run:

- exec:
    - extract-archive:
        command: cd /opt && tar -xzf /tmp/app.tar.gz
        provider: shell
        creates: /opt/app/bin/app

RefreshOnly Mode

When refreshonly: true, the command only runs when triggered by a subscribed resource:

- exec:
    - reload-nginx:
        command: nginx -t && systemctl reload nginx
        provider: shell
        refreshonly: true
        subscribe:
          - file#/etc/nginx/nginx.conf

Exit Code Validation

The returns property specifies acceptable exit codes (default: [0]):

- exec:
    - check-service:
        command: systemctl is-active myapp || true
        provider: shell
        returns: [0]

Security Considerations

Shell Injection Risk

The shell provider passes the command string directly to /bin/sh, making it vulnerable to shell injection if user input is incorporated:

# DANGEROUS if filename comes from untrusted input
- exec:
    - process-file:
        command: cat {{ user_provided_filename }} | process
        provider: shell

Mitigations:

  • Validate and sanitize any templated values
  • Use the posix provider when shell features aren’t needed
  • Prefer explicit file paths over user-provided values

Environment Variable Exposure

Shell commands can access environment variables, including sensitive ones:

# $SECRET_KEY will be expanded by the shell
- exec:
    - use-secret:
        command: myapp --key=$SECRET_KEY
        provider: shell

Consider using the environment property to explicitly pass required variables rather than relying on inherited environment.

Command Logging

The full command string (including any expanded variables) may appear in logs. Avoid embedding secrets directly in commands:

# BAD - password visible in logs
- exec:
    - bad-example:
        command: mysql -p'secret123' -e 'SELECT 1'
        provider: shell

# BETTER - use environment variable
- exec:
    - better-example:
        command: mysql -p"$MYSQL_PWD" -e 'SELECT 1'
        provider: shell
        environment:
          - MYSQL_PWD={{ lookup('data.mysql_password') }}

Platform Support

The shell provider requires /bin/sh to be available. This is standard on:

  • Linux distributions
  • macOS
  • BSD variants
  • Most Unix-like systems

On Windows, the provider will not be available unless /bin/sh exists (e.g., via WSL or Cygwin).

Shell Compatibility

The provider uses /bin/sh, which is typically:

  • Linux: Often a symlink to bash, dash, or another POSIX-compliant shell
  • macOS: /bin/sh is bash (older) or zsh (newer) in POSIX mode
  • BSD: Usually ash or similar

For maximum portability, use POSIX shell syntax and avoid bash-specific features like:

  • Arrays (arr=(1 2 3))
  • [[ conditionals (use [ instead)
  • source (use . instead)
  • Process substitution (<(command))