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¶
---
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:
Pathis empty (or omitted) — the rendered content is treated as a script to execute. This is the default for task scripts.Pathis 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.
- Absolute paths (e.g.,
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:
-
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. -
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:
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:
{{/* 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:
{{/* 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:
{{ 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:
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:
This sets up:
- Shell options:
set -e -o pipefailfor 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:
This provides:
$ErrorActionPreference = "Stop"for strict error handlingRS_MC_ROLE,RS_MC_TYPE,RS_MC_NAMEvariables- 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.
#!/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 -pinstead ofmkdir. - Use
installor 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:
| 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 |
{{ 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:
# 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:
# 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
ParamExistschecks in the task. - Use descriptive names with a namespace prefix (e.g.,
mypack/service-port, not justport). - Choose the correct Schema type —
string,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.
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¶
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:
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:
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:
- The job completes with exit code 0.
- Expected params are set or updated.
- The system state matches expectations (packages installed, files created, services running).
- Running the task a second time is safe (idempotency).