Skip to content

Task Best Practices

This guide covers best practices for writing DRP tasks, starting with the basics of task structure and building up to advanced topics like multi-OS support and permission escalation. Whether you are writing your first task or refining an existing content pack, these patterns will help you produce reliable, maintainable automation.

Task YAML Structure

Every task is defined as a YAML object with a standard set of fields.

Field Purpose
Name Kebab-case identifier (e.g., set-hostname, configure-network). This is the unique key for the task.
Description One-line summary of what the task does.
Documentation Multi-line detailed explanation. Supports full markdown.
RequiredParams List of params that must exist on the machine (or its profiles). The task fails immediately if any are missing.
OptionalParams List of params the task uses when present but does not require.
ExtraClaims Additional API permissions the task needs beyond the default machine token (see ExtraClaims).
Templates List of template entries — scripts to render and/or files to write (see Template Entries).
Meta Key-value metadata: icon (Noun Project icon name), color, title (content pack name), feature-flags (e.g., "sane-exit-codes").

Skeleton Example

YAML
---
Name: "my-example-task"
Description: "One-line summary of the task"
Documentation: |
  This is the long-form documentation for the task.

  It supports **markdown** formatting and can span
  multiple paragraphs.
RequiredParams:
  - "my/required-param"
OptionalParams:
  - "my/optional-param"
ExtraClaims: []
Meta:
  icon: "terminal"
  color: "blue"
  title: "My Content Pack"
  feature-flags: "sane-exit-codes"
Templates:
  - Name: "my-example-task.sh"
    Contents: |
      #!/usr/bin/env bash
      {{ template "prelude.tmpl" . }}

      echo "Hello from my-example-task"

Template Entries

Each entry in the Templates list has these key fields:

Field Description
Name Display name for the template entry.
Contents Inline template content (Go-template-expanded at render time). Use for simple, self-contained scripts.
ID Reference to a shared template file by its registered ID (e.g., "esxi-params.py.tmpl"). Use instead of Contents when the template is shared across multiple tasks or is complex enough to warrant its own file.
Path Filesystem path where the rendered content is written on the target.
Meta Metadata including OS: for platform selection.

Path Behavior

The Path field determines whether the template is executed as a script or written as a file:

  • Path is empty (or omitted) — the rendered content is treated as a script to execute. This is the default for task scripts.
  • Path is set — the rendered content is written to the specified file on the target filesystem. This is useful for configuration files, helper scripts, or data files that the main script will consume.
    • Absolute paths (e.g., /etc/myapp/config.yaml) write to that exact location.
    • Relative paths (e.g., config.yaml) write to the runner's working directory.

Content vs ID

Use Contents (inline) when:

  • The script is specific to this one task
  • It is short enough to read in context
  • No other task needs to reuse it

Use ID (external reference) when:

  • The template is shared across multiple tasks (e.g., esxi-params.py.tmpl)
  • The template is large or complex
  • You want to version the template independently

You can combine both patterns in a single task — for example, an ID reference for a helper file (with a Path) and an inline Contents entry for the main script.

Go Template Rendering

Task templates go through a two-phase process:

  1. Phase 1 — Render-time: When a job is created, DRP expands all Go template expressions. Functions like .Param "name", .ParamExists "name", .ParamExpand "name", and .ComposeParam "name" are resolved at this time. The result is a plain script with literal values substituted in.

  2. Phase 2 — Execution-time: The rendered script is sent to the machine's agent and executed. By this point every {{ ... }} expression has already been replaced with its value.

The key distinction: a template expression like {{ .Param "foo" }} becomes a literal value in the script before it ever runs on the target machine.

Sprig Functions

The full Sprig function library is available in templates. Common examples:

  • {{ .Param "name" | upper }} — uppercase a value
  • {{ .Param "name" | lower }} — lowercase a value
  • {{ hasKey .Param "map-param" "key" }} — check for a map key

Control Flow

Use range, if, and eq for conditional logic:

YAML
Contents: |
    #!/usr/bin/env bash
    {{ template "prelude.tmpl" . }}

    {{ if eq (.Param "my/mode") "fast" }}
    echo "Running in fast mode"
    {{ else }}
    echo "Running in normal mode"
    {{ end }}

    {{ range $idx, $item := (.Param "my/list-param") }}
    echo "Item {{ $idx }}: {{ $item }}"
    {{ end }}

No Short-Circuit Evaluation

Go templates evaluate all arguments to and / or before the function runs. There is no short-circuit evaluation. This means patterns that look safe in most languages will fail at render time:

Text Only
{{/* WRONG — .Param "my/setting" is evaluated even when the param
     does not exist, causing a render-time error */}}
{{ if and (.ParamExists "my/setting") (eq (.Param "my/setting") "enabled") }}

{{/* WRONG — same problem with `or` and nil checks */}}
{{ if or (not (.ParamExists "my/setting")) (eq (.Param "my/setting") "") }}

The fix is to nest your conditionals so the inner expression is only reached when it is safe:

Text Only
{{/* CORRECT — .Param is only called when the param exists */}}
{{ if .ParamExists "my/setting" }}
{{   if eq (.Param "my/setting") "enabled" }}
...do work...
{{   end }}
{{ end }}

For the inverse (do something when a param is missing or has a specific value), use an else branch:

Text Only
{{ if .ParamExists "my/setting" }}
{{   if eq (.Param "my/setting") "disabled" }}
...handle disabled...
{{   end }}
{{ else }}
...handle missing (same as disabled)...
{{ end }}

This nested pattern applies any time the inner check depends on the outer check being true — not just .ParamExists, but also nil map lookups, optional object fields, and similar cases.

Escaping Pitfalls

If your script needs a literal {{ (for example, in a Jinja2 template or a Go program), you must escape it so the Go template engine does not try to interpret it:

Text Only
{{ "{{" }} .some_jinja_var {{ "}}" }}

For more details on data rendering, see Data Rendering.

Prelude Templates

Prelude templates provide a standard runtime environment for your task scripts. Always include the appropriate prelude as the first line after the shebang.

Bash — prelude.tmpl

Include with:

Bash
#!/usr/bin/env bash
{{ template "prelude.tmpl" . }}

This sets up:

  • Shell options: set -e -o pipefail for strict error handling
  • Environment variables: RS_UUID, RS_TOKEN, RS_ENDPOINT, RS_MC_ROLE, RS_MC_TYPE, RS_MC_NAME
  • Error helpers: job_fail(), job_error(), exit_reboot(), exit_shutdown(), exit_incomplete()
  • Distro detection: OS_TYPE, OS_VER, OS_FAMILY, OS_NAME
  • Package management: install(), install_lookup(), clean_pkg()
  • Service management: service()
  • Utilities: fixup_path(), get_param(), set_param(), architecture detection, debug support

PowerShell — prelude.ps1.tmpl

Include with:

PowerShell
{{template "prelude.ps1.tmpl" .}}

This provides:

  • $ErrorActionPreference = "Stop" for strict error handling
  • RS_MC_ROLE, RS_MC_TYPE, RS_MC_NAME variables
  • Path setup and debug support

ESXi Tasks

ESXi native tasks do not use a prelude. They inline their own error handling (typically a local xiterr function definition).

setup.tmpl Is Deprecated

Do not use setup.tmpl for new tasks. It has a wrong shebang (#!/usr/local/bin/env bash) and lacks the comprehensive features of prelude.tmpl. Always use prelude.tmpl instead.

Idempotency

Tasks should be safe to run multiple times without causing harm. Use a check-before-act pattern: verify whether the work has already been done before doing it.

Bash
#!/usr/bin/env bash
{{ template "prelude.tmpl" . }}

# Check if the key already exists before generating a new one
if [[ "$(drpcli profiles params $id | jq 'has("rsa/key-private")')" == "true" ]]; then
    echo "Key already exists - skipping"
    exit 0
fi

# Generate the key since it does not exist
ssh-keygen -t rsa -b 4096 -f /tmp/id_rsa -N ""
drpcli profiles set "$id" param "rsa/key-private" to "$(cat /tmp/id_rsa)"
drpcli profiles set "$id" param "rsa/key-public" to "$(cat /tmp/id_rsa.pub)"

General guidelines:

  • Check for the existence of files, params, or resources before creating them.
  • Use mkdir -p instead of mkdir.
  • Use install or package-manager idempotent operations rather than raw file copies.
  • If a task writes a config file, compare the desired content with the current content before overwriting.

Error Handling

Sane Exit Codes

Enable the sane-exit-codes feature flag in the task Meta to use special exit codes that control machine workflow behavior:

YAML
Meta:
  feature-flags: "sane-exit-codes"
Exit Code Meaning Helper Function
0 Success job_success "message" (prints message and exits 0)
1 Failure job_fail "message" (prints message and exits 1)
16 Stop (halt workflow) exit_stop()
32 Shutdown exit_shutdown()
64 Reboot exit_reboot()
128 Incomplete (re-run this task) exit_incomplete()

The __exit function (provided by the prelude) writes the sane-exit-codes marker file that the agent reads to interpret these codes.

Helper Functions

  • job_fail "message" — Logs a failure message and exits with code 1. Use this when the task cannot continue.
  • job_error "message" — Logs an error but does not exit. Use this for non-fatal problems you want recorded.
  • job_success "message" — Logs a success message and exits with code 0. Use this as the final statement when the task succeeds.

job_fatal and xiterr Are Deprecated

job_fatal is deprecated — use job_error (non-fatal log) or job_fail (log and exit 1) instead. xiterr() is a legacy error-and-exit pattern found in older ESXi tasks; new tasks should use job_fail.

Logging

Use the structured job helper functions (provided by task-helpers.tmpl, which is included via the prelude) instead of raw echo statements. These helpers produce structured log output that integrates with the DRP job log viewer.

Function Purpose
job_info "message" Informational messages
job_warn "message" Warnings
job_error "message" Errors (non-fatal)
job_debug "message" Debug output (only when rs-debug-enable is true)
job_success "message" Success — prints message and exits 0
job_fail "message" Failure — prints message and exits 1
Bash
{{ template "prelude.tmpl" . }}

job_info "Starting network configuration"

if ! ip link show eth0 &>/dev/null; then
    job_warn "eth0 not found, falling back to ens3"
fi

job_success "Network configuration complete"

Do not use raw echo for important status information. The helper functions ensure that messages are properly tagged and visible in the job log.

Parameter Access

There are two distinct ways to access parameters, and choosing the right one matters.

Render-Time Access (Go Template)

Values are baked into the script when the job is created:

Bash
# The value of "my/hostname" is substituted at render time
HOSTNAME="{{ .Param "my/hostname" }}"

# For optional params: prefer defining a safe default on the parameter
# itself. Only use the ParamExists if/else pattern when no sensible
# default is possible (e.g., API keys, site-specific URLs).
{{ if .ParamExists "my/api-key" }}
API_KEY="{{ .Param "my/api-key" }}"
{{ else }}
job_fail "my/api-key must be set — no default is possible"
{{ end }}

Define Defaults on the Parameter

When a parameter has a reasonable default value (e.g., a port number, a boolean flag), set that default in the parameter definition itself rather than using ParamExists with an in-script fallback. This keeps the task simpler and makes the default visible to operators in the DRP UI.

Use render-time access for values that are static for the duration of the task — hostnames, IP addresses, configuration flags.

Runtime Access (During Execution)

Values are fetched live from the DRP API while the script runs:

Bash
# Using the prelude helper (simple, but no --decode or --compose)
CURRENT_STATE=$(get_param "my/state")

# Using drpcli directly (supports full API options)
CURRENT_STATE=$(drpcli machines get $RS_UUID param "my/state")

# drpcli returns JSON — use jq -r to strip extra quotes from strings
HOSTNAME=$(drpcli machines get $RS_UUID param "my/hostname" | jq -r .)

# Setting a param at runtime
drpcli machines set $RS_UUID param "my/result" to '"completed"'

# Using the prelude helper
set_param "my/result" '"completed"'

get_param vs drpcli ... get ... param

The prelude helper get_param is a simple wrapper that does not support --decode or --compose flags. When you need decoded or composed parameter values at runtime, use the full drpcli command directly. Remember that drpcli returns JSON, so string values come back with extra double quotes — pipe through jq -r . to get the raw string.

Use runtime access for values that may change during execution or that are set by the task itself for downstream tasks to consume.

Parameter Definition Best Practices

Well-defined parameters make tasks self-documenting and easier to use:

  • Always include Documentation on the parameter definition — explain what the parameter does, not just its type.
  • Set a safe default whenever possible. If a port, boolean, or mode has a natural default, put it in the parameter definition. This avoids the need for ParamExists checks in the task.
  • Use descriptive names with a namespace prefix (e.g., mypack/service-port, not just port).
  • Choose the correct Schema typestring, integer, boolean, object, array. This enables UI validation and prevents common errors.

For a reference to how .Param differs from .ParamExpand and .ComposeParam, see Data Rendering.

Multi-OS Tasks

A single task definition can support multiple operating systems by including multiple template entries with Meta: OS: annotations. The DRP agent selects the template that matches the machine's OS.

YAML
Templates:
  - Name: my-task.sh
    Meta:
      OS: "linux"
    Contents: |
      #!/usr/bin/env bash
      {{ template "prelude.tmpl" . }}
      # Linux implementation
      job_info "Running on Linux"

  - Name: my-task.ps1
    Meta:
      OS: "windows"
    Contents: |
      {{template "prelude.ps1.tmpl" .}}
      # Windows implementation
      Write-Host "Running on Windows"

  - Name: my-task-esxi.sh
    Meta:
      OS: "esxi"
    Contents: |
      #!/bin/sh
      # ESXi implementation — no prelude, inline error handling
      xiterr() { echo "FATAL: $1"; exit 1; }
      echo "Running on ESXi"

Keep platform-specific logic in its own template. Do not try to handle all operating systems in a single script with conditionals.

ExtraClaims

By default, a running task has a machine token with limited permissions. If the task needs to modify other objects (profiles, other machines, work orders, alerts, etc.), declare ExtraClaims.

Each claim has three fields:

Field Description
scope Object type: machines, profiles, work_orders, alerts, etc.
action Operation: "*" for all, or a specific verb like "create", "update", "get"
specific Which objects: "*" for all, or a specific name like "global"

Example

YAML
ExtraClaims:
  - scope: "machines"
    action: "*"
    specific: "*"
  - scope: "profiles"
    action: "*"
    specific: "global"

Follow the principle of least privilege: request only the permissions the task actually needs. Avoid scope: "*" / action: "*" / specific: "*" unless truly required.

Testing

Validate YAML Structure

Before uploading a content pack, check that the YAML is well-formed:

Bash
drpcli contents document content.yaml

This catches syntax errors, missing required fields, and structural problems before they reach a running DRP endpoint.

Check Rendered Output

To see exactly what script a machine will receive, render the template without running it:

Bash
drpcli machines render $UUID "template-name"

This is invaluable for debugging Go template logic — you can verify that parameters are substituted correctly and control flow produces the expected output.

Manual Testing

For a final check, apply the task to a test machine and verify:

  1. The job completes with exit code 0.
  2. Expected params are set or updated.
  3. The system state matches expectations (packages installed, files created, services running).
  4. Running the task a second time is safe (idempotency).