Rundeck jobs
runspec emit --rundeck generates Rundeck job
definitions from your installed runnables, so runspec.toml stays the single
source of truth for a Rundeck fleet instead of a hand-maintained job that drifts
whenever an arg changes.
- One job per leaf command. A plain runnable emits one job; a runnable with
subcommands flattens to one job per fully-pathed leaf —
cron_add,cron_list— exactly likerunspec serve, each carrying its merged global + command args. - JSON output, generated with the standard library alone (no YAML dependency). Safe on any Rundeck version, including those before 5.20 that don't escape option values (see How values reach the runnable).
- Files land in
{git_root}/jobs/{job}.json, safe to commit as deployment artefacts.
Quick start
# From your project (the venv where the runnables are installed), running each
# job's command as the venv's service account:
runspec emit --rundeck --venv /opt/app/.venv --run-as svc
# Wrote jobs/scan.json
# Wrote jobs/deploy_start.json
# ...
Then import into Rundeck (see below), or pipe straight to the rd CLI:
runspec emit --rundeck --venv /opt/app/.venv --run-as svc --stdout | rd jobs load -F json -p myproject
Importing into Rundeck
The emitted files are Rundeck job-definition JSON. Import them with the API
or the rd CLI:
rd jobs load -f jobs/scan.json -F json -p myproject
# or the API:
curl -H "X-Rundeck-Auth-Token: $TOKEN" -H "Content-Type: application/json" \
--data-binary @jobs/scan.json \
"$RUNDECK_URL/api/44/project/myproject/jobs/import?format=json"
The Rundeck web UI upload dialog historically offers only XML and YAML — import JSON through the API or
rd. Each job carries a stable UUID (derived from project + group + job name), so re-emitting and re-importing updates the same job in place rather than creating a duplicate.
Syncing jobs with runspec-rundeck
runspec emit --rundeck writes files; the separate
runspec-rundeck package pushes
them to a live instance over the REST API — a JVM-free replacement for
rd jobs load you can drop into a CI publish stage, with no Java on the
runner.
pip install runspec-rundeck
| Command | Autonomy | What it does |
|---|---|---|
rundeck sync |
confirm | Discover every runnable in the venv, emit a job per leaf command, and import them into a project |
rundeck push |
confirm | Import already-emitted jobs/*.json (or a whole jobs/ directory) |
rundeck list |
autonomous | List a project's jobs |
sync is the headline verb. It runs runspec emit --rundeck --stdout in its own
venv (so it sees exactly the runnables installed alongside it), skips hidden
(discoverable = false) helpers, and imports the rest with
dupeOption=update&uuidOption=preserve — create-when-missing,
update-when-present, rename-safe, because identity is the emitted UUID.
export RUNDECK_URL=https://rundeck.example.com
export RUNDECK_TOKEN=… # a Rundeck API token
rundeck sync --project ops
Because a publish stage re-emits every run, the platform's jobs stay in lockstep
with the installed runnables — arguments included. sync covers every runnable
in the venv by default; --runnable <name> scopes it to one.
Pruning removed runnables
Import never deletes, so --prune removes jobs in the managed group whose UUID
wasn't in this push — a runnable removed from the venv has its job cleaned up:
rundeck sync --project ops --prune
Prune is scoped to the groups the pushed jobs declare, so it never touches jobs the sync doesn't own.
Environments and regions
Auth is environment-variable first, with RUNDECK_<ENV>_* prefixes selected by
--env — a free-form label, so it carries dev/uat/prod and regional
labels like prod-eu:
export RUNDECK_PROD_EU_URL=https://rundeck.eu.example.com
export RUNDECK_PROD_EU_TOKEN=…
rundeck sync --env prod-eu --project ops
Run one invocation per instance from a CI matrix. UUIDs only need to be unique
within an instance, so the same emitted jobs push cleanly to every separate
instance. For private-CA or proxied instances, --ca-bundle / --proxy /
--insecure and the --token password arg (the runspec-console secret channel)
override the environment.
A GitHub Actions publish stage, one job per instance:
publish-rundeck:
strategy:
matrix:
target: [uat, prod-eu, prod-us]
steps:
- run: pip install ./dist/*.whl runspec-rundeck
- run: rundeck sync --env ${{ matrix.target }} --project ops --prune
env:
RUNDECK_UAT_URL: ${{ vars.RUNDECK_UAT_URL }}
RUNDECK_UAT_TOKEN: ${{ secrets.RUNDECK_UAT_TOKEN }}
RUNDECK_PROD_EU_URL: ${{ vars.RUNDECK_PROD_EU_URL }}
RUNDECK_PROD_EU_TOKEN: ${{ secrets.RUNDECK_PROD_EU_TOKEN }}
# …one URL/TOKEN pair per target…
Corporate wrapper: bake in instances, regions, and fan-out
runspec-rundeck-core holds all the logic with no runspec dependency, so a
private package can import it, hard-code your instance topology (URLs, proxy,
projects), and expose --region / --env that resolve to a concrete instance —
plus an optional fan-out that pushes to every matching instance in one run. Only
the generic runspec-rundeck is published; your topology stays in your package.
This is the same split the rest of the ecosystem uses (runspec-jsm /
runspec-snow bake in a service-desk id; here you bake in a fleet).
A minimal mycorp-rundeck wrapper — cli.py resolves a baked map and fans out
with the core sync_target:
import runspec as rs
from runspec_rundeck_core import config_from_env, sync_target
from runspec_rundeck.emit import discover_visibility, emit_jobs, filter_visible
# Your instance topology — the one thing a wrapper adds. Secrets stay in the
# environment / keychain; only URLs and projects are baked in.
INSTANCES = {
("eu", "prod"): {"url": "https://rundeck.eu.example.com", "project": "ops"},
("us", "prod"): {"url": "https://rundeck.us.example.com", "project": "ops"},
("eu", "uat"): {"url": "https://rundeck-uat.eu.example.com", "project": "ops"},
}
PROXY = "http://proxy.example.com:8080" # corporate egress, baked in
def targets(region, env):
"""One concrete pair, or every matching instance when either is 'all'."""
if region == "all" or env == "all":
return [(r, e) for (r, e) in INSTANCES if region in (r, "all") and env in (e, "all")]
return [(region, env)]
def main():
spec = rs.parse("rundeck")
jobs = _emit_visible(spec) # emit once, reuse per instance
results = []
for r, e in targets(spec.region.value, spec.env.value):
inst = INSTANCES[(r, e)]
cfg = config_from_env( # token/CA from the env; the rest baked
f"{r}-{e}", url=inst["url"], project=inst["project"], proxy=PROXY,
)
results.append(sync_target(cfg, jobs, prune=bool(spec.prune.value)))
ok = all(x.ok for x in results)
print({"ok": ok, "instances": len(results)})
def _emit_visible(spec):
jobs = emit_jobs("ops", venv=spec.venv.value)
names, hidden = discover_visibility()
kept, _dropped = filter_visible(jobs, names, hidden)
return kept
Its runspec.toml adds the two targeting args on top of the standard ones:
[rundeck]
description = "Sync runnables to our Rundeck fleet"
[rundeck.args]
region = {type = "choice", options = ["eu", "us", "all"], description = "Region to target"}
env = {type = "choice", options = ["uat", "prod", "all"], description = "Environment to target"}
--region all / --env all fans out to every matching instance behind one
command; a concrete pair targets one. Because the core stays single-target
(sync_target), the wrapper owns only the map and the loop — everything else
(emit, UUID identity, prune) comes from the published packages.
What's in a job
Every job runs a single Bash script step. The script reads each option's value into the runspec env var the runnable reads that arg from, through a quoted heredoc:
IFS= read -r RUNSPEC_DEPLOY_ARG_TOKEN <<'RUNSPEC_VALUE'
x@option.token@
RUNSPEC_VALUE
RUNSPEC_DEPLOY_ARG_TOKEN=${RUNSPEC_DEPLOY_ARG_TOKEN#x}
Rundeck replaces each @option.<name>@ token with the option's value before it
copies the script to the node. The script then runs the runnable with only the
binary and the subcommand path on its command line, for example
/opt/app/.venv/bin/deploy start. Every arg (named, flag, positional, rest,
password) reaches the runnable through the env var, which is how runspec
already resolves an arg that isn't on the command line.
- An option left empty isn't exported, so the runnable's own default applies.
- Flags arrive as
"true"/"false". - A
restarg arrives as one value (it isn't word-split into separate items). - A value can't contain a line break: the job refuses to run (exit code 64) rather than read one. See below for why.
- If the runnable isn't where the job expects it (
<venv>/bin/<runnable>, or on the Rundeck user'sPATHwithout a venv), the job stops with exit code 127 and a message naming the runnable, the place it looked, the account that looked and the node:runspec: deploy not found at /opt/app/.venv/bin/deploy (checked as rundeck on web-01). A venv whosebin/the Rundeck user can't open is left to the run itself.
How values reach the runnable
The emitter avoids the two usual ways of passing values, because neither works in a locked-down corporate fleet:
- No
RD_OPTION_*env vars. Those only reach a remote node when its sshd hasAcceptEnv RD_*, and Red Hat advises against setting that. - No arguments. A value on a command line is visible to every user through
ps(PII and secrets alike), and Red Hat advises against hiding it with thehidepidmount option of/proc.
That leaves the inline-script tokens. Rundeck before 5.20 substitutes them
without escaping (the
option-escaping advisory,
affecting 3.4.1 to 5.19), so a value written straight into a shell command
could run commands on the node. The script is built so that the shell never
parses a value, which makes it safe on every version, secure options included
(they can't carry a regex):
- Each token sits alone on its own line inside a heredoc whose delimiter is
quoted (
<<'RUNSPEC_VALUE'). Bash treats the body as plain data: quotes,$(...), backticks and;in a value are just characters. - A heredoc body ends only at a line that is exactly the delimiter. The
xin front of each token means a value can never make its own line equal it, so the only way out is a value containing a line break. - The script's third line counts the script file's own non-blank lines and exits with code 64 unless the count is the one the emitter wrote. A value with a line break adds a line, so the job stops before any value is read. Blank lines aren't counted, so a trailing newline changes nothing.
Use an option's regex / values to restrict what it accepts where you can.
Accepted tradeoff: values in the temp script
Rundeck writes the substituted script, secret values included, to a temp
file on the node (for example under /var/tmp) and deletes it when the step
ends. It copies the file with the node's default umask, so the script's
first command is chmod 700 on its own file: from then on only the Rundeck
SSH user (and root) can read it. That also covers a file Rundeck fails to
delete, such as after a killed job. Values never appear in ps, the sudo
log or the Rundeck job definition.
The run's label
Each job also sets RUNSPEC_LABEL=rundeck, so the run shows the label
rundeck in runspec history and the console's History tab. A job behind
sudo sends it with the option values on stdin. The one exception is a job
with no options, a run_as account and no known venv: it runs the binary
straight through sudo, because sending only the label would need a sudoers
rule that allows sh, so that run records cli. Jobs emitted before runspec
0.76.0 don't set the label; re-emit them (runspec emit --rundeck --check
shows the drift).
Running as the service account
Runnables should run as the service account that owns their venv, not as the
Rundeck SSH user. The job follows the same run_as rules as the runnable
itself, resolved in this order: --run-as, [<runnable>.meta.rundeck] run_as,
[config.meta.rundeck] run_as, the runnable's own run_as, [config] run_as,
and finally "@owner" when none of them is set (the core default).
run_as |
What the job does |
|---|---|
"@owner" (or unset) |
Looks up the owner of the venv on the node (stat). Runs directly when the Rundeck user already is that account or the venv is owned by root; otherwise runs through sudo -n -u <owner>. Without a venv, the venv is the one the runnable was found in on PATH. |
"svc" (an account) |
Runs through sudo -n -u svc, or directly when the Rundeck user already is svc. |
"" |
Runs as the Rundeck SSH user, no sudo. |
"$ENV", a per-host table |
Can't be resolved by a job, so it runs as the Rundeck SSH user. Set run_as in meta.rundeck instead. |
For example:
[config.meta.rundeck]
venv = "/opt/app/.venv"
run_as = "svc"
Because sudo resets the environment, the values don't travel as env vars
across it. The script writes them on the command's standard input as the
runspec env frame, and the fixed env prelude on the venv's own bin/python
reads them back before it starts the runnable (the same mechanism the console
uses; see SPEC → "Remote Execution → Command building"):
rs_frame RUNSPEC_DEPLOY_ARG_DEPTH RUNSPEC_DEPLOY_ARG_TOKEN \
| sudo -n -u "$rs_user" /opt/app/.venv/bin/python -c "$RS_PRELUDE" /opt/app/.venv/bin/deploy start
The sudoers rule therefore needs to allow the Rundeck SSH user to run the
venv's bin/ as svc without a password, for example:
rundeck ALL=(svc) NOPASSWD: /opt/app/.venv/bin/
Without a venv the prelude runs on sh instead.
Type mapping
Rundeck has no native int/float/bool/path option types, so runspec types map onto plain text options with light validation:
| runspec type | Rundeck option |
|---|---|
str, path |
text |
int, float |
text with a validating regex |
choice |
text, values: [...], enforced: true |
flag |
text, values: ["true","false"], enforced: true, defaulting to the flag's boolean default |
rest |
text (space-separated; word-split after --) |
password |
secure option (see below) |
multiple = true |
multi-valued option (multivalued: true, delimiter); with options, also values + enforced (checkboxes) |
Required args set required: true; defaults are stringified into value.
A multiple arg's list default is joined with the option's delimiter
(default = ["Missing", "Stale"] → value: "Missing,Stale"). The delimiter is
the arg's own delimiter when it has one, else ,; Rundeck joins the values
picked on the run form with it, and the job script turns each delimiter into a
line break, which is runspec's env form for a list, so the runnable gets
["Missing", "Stale"]. Emit fails if a default value contains the delimiter
(set a delimiter its values don't use). A multi-valued int/float option
has no regex: Rundeck would test the joined text, so runspec checks each
value instead. Range,
pattern, and positional/rest hints that Rundeck can't model are surfaced in the
option's description.
Secrets
A password arg becomes a Rundeck secure option (secure: true,
valueExposed: true): the operator enters it at run time, it is encrypted, and
it is kept out of the job log. It reaches the script through its
@option.<name>@ token like every other option, and the runnable reads it from
RUNSPEC_<RUNNABLE>_ARG_<NAME>, the env var runspec already reads passwords
from. It never appears on a command line. The temp-script tradeoff above
applies to it.
Nodes and load-balancing
By default a job has no node filter and runs on the Rundeck server. Give it a node filter and it dispatches to that node set with an orchestrator random subset of 1 — each run lands on one random node of the set, so a scheduled or triggered job spreads its load:
runspec emit --rundeck --venv /opt/app/.venv --run-as svc --node-filter 'tags: web'
produces, on each job:
"nodefilters": { "filter": "tags: web", "dispatch": { "threadcount": 1, "keepgoing": false } },
"orchestrator": { "type": "subset", "configuration": { "count": "1" } }
Schedules, notifications, and per-runnable settings
Rundeck-specific settings that aren't part of a runnable's interface live in a
[<runnable>.meta.rundeck] pass-through table (project-wide defaults go under
[config.meta.rundeck]). runspec never interprets meta; the emitter reads it.
[config.meta.rundeck]
venv = "/opt/app/.venv"
run_as = "svc" # sudo -n -u svc /opt/app/.venv/bin/…
group = "myteam"
[deploy]
description = "Deploy the app"
require-command = true
[deploy.meta.rundeck]
venv = "/opt/deploy/.venv"
run_as = "svc-deploy"
node_filter = "tags: deployers"
schedule = "0 0 2 * * ?" # Quartz crontab (6/7 fields), NOT Unix 5-field cron
[deploy.meta.rundeck.notifications.onfailure]
email = "ops@example.com"
[deploy.meta.rundeck.notifications.onsuccess]
webhook = "https://hooks.example.com/deploy"
meta.rundeck keys
| Key | Effect |
|---|---|
venv |
Venv whose bin/ holds the runnable binary (the script invocation path) |
run_as |
Account the command runs as: an account, "@owner" (default) or "" — see "Running as the service account" |
group |
Rundeck job group (default runspec) |
node_filter |
Node filter; presence enables orchestrator subset=1 |
orchestrator |
"subset" (default when a filter is set) or "none" |
threadcount |
Dispatch thread count (default 1) |
schedule |
Quartz crontab string → the job's schedule |
notifications |
onsuccess/onfailure/onstart → email (recipients) and/or webhook (urls) |
loglevel |
Job log level (default INFO) |
strategy / keepgoing |
Workflow strategy (default node-first) / keepgoing (default false) |
timeout / retry |
Job timeout / retry count |
Precedence: CLI flag → [<runnable>.meta.rundeck] → [config.meta.rundeck] →
built-in default.
Command reference
| Flag | Meaning |
|---|---|
--rundeck |
Select the Rundeck target (required) |
--venv <path> |
Venv bin/ for the job scripts (project-wide) |
-d, --output-dir <dir> |
Where to write (default {git_root}/jobs) |
-r, --runnable <name> |
Emit only this runnable |
--group <name> |
Rundeck job group (default runspec) |
--node-filter <expr> |
Node filter → orchestrator subset=1 load-balancing |
--project <name> |
Rundeck project (used for stable job UUIDs) |
--run-as <account> |
Run each job's command as this account, @owner or "" (sudo -n -u when needed) |
--check |
Verify emitted files are up to date; write nothing. Exit 1 if stale |
--stdout |
Print the combined JSON array to stdout instead of writing files |
Keeping jobs fresh in CI
Commit jobs/ and guard drift the same way runspec stubs --check does:
runspec emit --rundeck --venv /opt/app/.venv --run-as svc --check
# ✗ jobs is out of date — run 'runspec emit --rundeck' (exit 1)
Not covered (yet)
su—run_asalways escalates withsudo -n -u.- Group constraints (
exclusive,exactly-one, …) have no Rundeck equivalent; they are noted in option descriptions and still enforced by the runnable at runtime.