Cronomicon
The self-hosted Script Orchestrator — schedule and run Bash, Ansible, Terraform, PowerShell, Perl and Python jobs across your fleet from one auditable web console.
How to run Bash (and the rest of the shell family — perl, powershell, python) well inside Cronomicon: which of the two executors actually runs your script, how targets, bastions and host keys resolve, how env, run inputs and step outputs flow through a run, how secrets reach a script without ever touching the repo, and the failure modes worth knowing by name. It assumes you can write a shell script; what it teaches is the Cronomicon-specific shape around it.
Companion reading: the User Manual for the per-screen reference (Jobs §6, Scripts §8), the Administrator Manual for the SSH executor's internals (§4) and GitOps (§5), the Ansible Guide for playbooks, the PowerShell Guide for Windows fleets, the Python Guide for Python specifics, and the Runner Install Guide / Runner Config Guide / Runner Security Guide guides for the agent.
The one-sentence model. A shell script is a Script (run type
bash), a Scope names the hosts it may touch, a Job binds the two to schedules and env — and the run executes over SSH, dialed either by the Cronomicon server itself (the in-app SSH executor) or by a runner sitting inside the target's network segment.
1 · Two executors, one script
Unlike Ansible (runner-only), a bash run can execute on either executor. Shell-family scripts default to ssh via the resolution chain (per-trigger override → job executor → global default → run-type default); both executors share the same lifecycle, logging, redaction, kill mechanism and concurrency cap — only where the SSH connection originates differs.
ssh — in-app SSH executor | runner — agent in the segment | |
|---|---|---|
| Who dials the target | The Cronomicon server (direct or via bastion) | A registered cronomicon-runner host |
| Reaches | Anything the server (or its bastions) can route to | Network-isolated segments; only outbound HTTP to the server |
| Keys live | On the server (named secrets / stored credentials) | On the runner (key-dir, names-only manifests) |
| Enable | Off by default — CRONOMICON_SSH_EXECUTOR_ENABLED=true (pool concurrency CRONOMICON_SSH_EXECUTOR_CONCURRENCY, default 4) | Install a runner; the slim image / any host with a shell claims bash automatically |
The classic first-day symptom: shell jobs queue forever. Shell runs default to the SSH executor type, but the executor pool is disabled until an operator turns it on. If
CRONOMICON_SSH_EXECUTOR_ENABLEDis unset, everyssh-tagged run sits queued indefinitely. Enable it — the server then logs a loud warning that it holds SSH keys and has outbound SSH — or route the job to a runner instead.
The life of an SSH run (Administrator Manual §4.2): claim the oldest queued run → open the log and load the scope's redactor → resolve the command (inline command, inline script, or a sandbox-checked scriptPath from the Git clone) → resolve targets (a pinned target_host wins over scope fan-out) → apply timeout_seconds → fan out across the hosts, up to 4 in parallel, each host's output prefixed [hostname] when more than one is involved → aggregate: all ok → success; all failed → failure; mixed → warning (partial).
2 · Getting scripts in
- Raw files, auto-discovered. Any
*.shunderscripts/in the definitions repo becomes a Script named with its extension (sub-folders just shape the catalog's folder tree).*.pl,*.ps1and*.pydiscover the sibling run types the same way. - Or a
kind: ScriptYAML with exactly one ofcommand(one-liner),script(inline multi-line body), orscriptPath(repo-relative file) — plus optional declaredprompts(§4). - Jobs bind by reference. A job carries
script_ref: <name>rather than its own copy, so one reviewed script serves many jobs. In-app, the Composer builds the same binding for cronomicon-source jobs. - Let the catalog help you. The Scripts view lints script bodies and surfaces “Variables this script uses” — a heuristic scan of
$VARSin the body that the Run dialog and Composer use to hint at unsatisfied variables. Declared run inputs always beat the inferred list. - Validate at merge time. Wire
cronomicon validate .into the repo's CI (§5.4) — and there is no reason not to runshellcheckbeside it.
3 · Targets: scopes, host records & bastions
Every scope supports bash — it is the “bash floor”; the set can never exclude it. A bash run's blast radius is then decided in this order:
- A pinned
target_hoston the job wins outright — over scope fan-out and over a per-run host subset. The pin is honored on every run path (manual, cron, workflow step); before that, scheduled fires of a pinned job fanned out across the whole scope. If you were relying on that fan-out, clear the pin deliberately. - A per-run host subset — the Run dialog's “Limit to specific hosts” (SSH executor only). A host that isn't a scope member is rejected
422 scope_membership. - Otherwise the scope fans out to its member hosts.
Each target resolves through its host record (Settings → SSH Targets, or imported from a synced inventory): address, port, user, via (bastion), and its auth key. A scope member with no matching host record becomes a reported per-host failure — never a silent skip.
- Auth keys (in-app executor). Attach a first-class SSH key credential (Env Vars → SSH Keys) or name a key env var (
authKeyEnvVar). Keys must be unencrypted private keys — passphrase-protected keys,.pub, certs and.ppkare rejected. Test connection probes a host and answers verified / cred_error / conn_error, naming the key that authenticated. - Bastions (ProxyJump). The executor dials the bastion, tunnels, then handshakes to the target through the tunnel — authenticating the hop with the bastion's own key when configured; the target's private key is never presented to the bastion. 15 s dial timeout per hop.
- Host keys fail closed. A stored target host key must match exactly (
host key mismatch … possible MITM); with none stored, Trust-On-First-Use captures the first-seen key loudly and verifies strictly thereafter — there is no “ignore host key” option. Bastion host keys are pinned the same way, and a secret-injecting run over a bastion to a target with no pinned key yet is refused (unpinned_bastion_target) — probe or run once without secrets to capture it first. - On the runner path, keys resolve names-only from the runner's key-dir and targets verify against the runner's own
known_hostsvia Scan & approve — see Ansible Guide §5 and Runner Security Guide; the mechanics are identical for bash.
4 · Env, run inputs & outputs
Environment layering
A run's env assembles as job env ← schedule env ← per-run overrides (override wins per key), plus your bound references (§5) and the dispatcher-owned CRONOMICON_RUN_* context set — CRONOMICON_RUN_ID, _JOB, _SCOPE, _TYPE, _TRIGGERED_BY, _EXECUTOR and friends — handy for logging and branching inside a script. The CRONOMICON_* namespace is injector-owned: operator env may reference those names but never define one (422).
Run inputs, not read -p
There is no TTY on either executor — a read -p hangs until the timeout kills the run. Declare operator questions as run inputs (spec.prompts — name, label, required, default, options) and read the answers from the env; the Run dialog asks, records provenance, and applies them via the per-run env path. prompt_enforcement: block makes a missing required answer reject the run instead of warning. See User Manual §6.5.
Exit codes & partial results
Exit 0 is success; anything else fails that host. On a multi-host fan-out the per-host results aggregate — all ok → success, all failed → failure, mixed → warning (partial) — so let your script's exit code mean something, and reserve stdout for information (and markers, below).
Passing data to downstream workflow steps (A12)
A script hands values to later workflow steps by printing a marker line on stdout:
echo "::cronomicon-output name=VERSION::1.4.2"A downstream step declares an input bound to that output (in the Workflow Editor or Git YAML) and receives it as an env var. Markers are captured from the raw output before redaction — and if a captured value carries an injected secret, the executor drops all captured outputs and fails the run (output_secret_leak) rather than let the secret propagate. Details: User Manual §7.5.
5 · Secrets in scripts
Declare the Secrets / Variables a job consumes as reference bindings (§11.6); they arrive in the run env as CRONOMICON_SECRET_<name> / CRONOMICON_VAR_<name>. On the in-app SSH executor the server resolves the bindings itself: resolution is fail-closed (a missing or out-of-scope binding fails the run without leaking why to the run log), injected values seed the run's redactor before any output is captured, and delivery to the remote command rides STDIN — never argv, so a secret never lands in /proc/<pid>/cmdline. On the runner path the same bindings require the per-runner Secret injection flag — un-flagged runners leave a binding-bearing run queued; see Ansible Guide §6.
CRONOMICON_KEY_*(SSH-key material) is runner-only. On the in-app SSH path a declared key reference is warned and skipped — remote key-file delivery is deliberately not done, because the file would have to land on the target. Host authentication uses the host record's own credential instead. This asymmetry is accepted as permanent: “use the department's key” for a shell run means connect as it, which the per-run Connect as picker and a job'sspec.ssh_credentialboth do. Secrets and Variables reach a bash run normally; only key material is withheld. If a script genuinely needs a key file on the target, deliver it as a secret and write it out yourself — with the sameumask 077andtrapcleanup as below.- Don't re-broadcast what you were given. Redaction masks known secret values in stored logs, but a value your script fetches itself (curl to a vault, a decrypt step) was never registered with the redactor — treat it like ansible's
no_log: don't echo it, don't put it on another process's argv. - Nothing secret in the repo, schedule env, or run overrides — those are plaintext by design and the UI says so. Secrets belong in the encrypted store / Vault, referenced by binding.
Privilege escalation: sudo -S, and its one sharp edge
Passwordless sudo is the preferred arrangement, for shell runs as much as for Ansible. A NOPASSWD rule scoped to the exact commands a job needs is auditable on the target, survives a password rotation, and keeps the credential out of the run entirely. Everything below is the exception path — reach for it when a target's policy genuinely forbids passwordless escalation, not as the default shape of a job.
Bash has no become primitive: unlike an Ansible run, there is no flag Cronomicon can pass on your behalf, so escalation is whatever the script author writes. Declare the password as a reference binding and feed it to sudo -S, which reads from stdin:
set -euo pipefail
# CRONOMICON_SECRET_BECOME_PASSWORD arrives from a declared reference binding.
printf '%s\n' "$CRONOMICON_SECRET_BECOME_PASSWORD" | sudo -S -p '' systemctl restart myapp⚠ The sharp edge: the process table on the target.
sudo -Sis safe because the value crosses a pipe. The moment you put it anywhere else on a command line —sudo -S <<< "$PASS"is fine, butecho "$PASS" | ...inside abash -cstring, or passing it as an argument to any helper — it lands in/proc/<pid>/cmdlineon the target host, readable by any local user there for the life of the process. Cronomicon's redaction cannot reach that. Redaction masks injected values in the run log; it has no visibility into a remote process table. The same applies to writing the value to a file on the target withoutumask 077, and toset -x, which will happily echo the whole pipeline.
- Use
-p ''to suppress the prompt string. Without itsudowrites[sudo] password for …to stderr, which is captured into the run log — harmless, but it reads like a hang to whoever is watching. sudo -Sfails closed on a wrong password, but slowly: it retries and then exits non-zero after the attempts are exhausted. Withset -euo pipefailthat surfaces as a plain run failure; sizetimeout_secondsso the retries don't eat the whole budget.- One password per run. If a scope's hosts have different sudo passwords, a single binding cannot serve them — split the scope, or use per-host key-based escalation instead.
- Don't cache it. Writing the password to a file on the target to avoid re-prompting turns a run-scoped credential into a persistent one; if you must,
umask 077andtrap 'rm -f "$f"' EXIT.
For Ansible runs none of this applies: a job names its become password with spec.become_password_secret, Cronomicon resolves it at dispatch and the agent hands it to ansible-playbook --become-password-file as a 0600 file that never enters the environment. See Ansible Guide §5.
6 · Best-practice checklist
- Start every script with
set -euo pipefail— a run that half-fails while exiting 0 reads as success on every dashboard. - Be idempotent. Retries (
retries:), schedule re-fires and operator re-runs all mean the script will run again; make “again” safe. - Exit codes are the contract.
0only when the work truly succeeded; per-host failures aggregate into the partial-warning state for free. - Pin single-host jobs with
target_hostrather than a one-host scope; the pin holds on scheduled and workflow paths too. - Bound everything: a realistic
timeout_seconds(the overrun kill logscronomicon: job timed out),concurrency_policy: Forbidfor non-reentrant scripts. - Run inputs instead of prompts,
::cronomicon-outputmarkers instead of temp files, env instead of argv for anything sensitive. - Keep scripts in Git behind
script_refso they are reviewed, versioned and reused; save inlinecommand:for true one-liners. Lint withshellcheck+cronomicon validatein CI.
7 · Troubleshooting
| Symptom | Likely cause → fix |
|---|---|
Runs sit queued forever (executor ssh) | The SSH executor pool is disabled. Set CRONOMICON_SSH_EXECUTOR_ENABLED=true — or give the job executor: runner. |
| Queued, “waiting for a bash-capable runner” | Executor runner but no online runner claims bash — runner down, or a capabilityMask narrowed it. Check the Runners view. |
| Queued, status reason names a missing runner property | A run that no online runner can claim records why: no runner is online, no online runner can run bash jobs, no online runner belongs to <agency>, no eligible runner is flagged for secret injection, or a named missing capability token. Read the reason before inspecting the fleet — it names the one gate that failed. |
| Queued: “only a runner with no agencies can claim it” | The run has no scope, and an untagged run is claimable only by an untagged runner (the general pool is disjoint, not a fallback). Every online runner belongs to a department. Bind a scope to the run. |
422 “this job consumes department-owned credentials” | An unbound run (no scope on the job, none supplied at trigger) declaring a binding to a department-owned Secret/Variable/key. An unbound run carries an empty department set, so an owned row resolves for nobody. Supply a scope in the Run dialog. A binding to a global row is unaffected. |
host key mismatch … possible MITM | The target's key changed (re-key or a real problem). Verify out-of-band; if legitimate, clear the stored host key to re-capture via TOFU. |
| cred_error on Test connection | Wrong/passphrase-protected/mis-formatted key, or wrong user. The key must be an unencrypted private key. |
| conn_error | Unreachable address/port, a bastion hop failing, or a host-key mismatch — check the bastion's own key and reachability first. |
422 scope_membership | A per-run host subset names a host outside the effective scope. |
| Per-host failure “no matching host record” | A scope member has no SSH host record — add it in Settings → SSH Targets (or fix the inventory import). |
unpinned_bastion_target | A secret-injecting run over a bastion to a target with no pinned host key. Probe the target (Test connection) or run once without secrets to capture it. |
Run killed with cronomicon: job timed out | timeout_seconds is smaller than reality — or the script hung (interactive prompt, dead remote). Fix the hang first, then size the timeout. |
Run failed, reason executor_lost | The server restarted mid-run and reconciled it at startup (plain Failed badge — distinct from a runner's amber Lost). Re-run. |
Run failed, reason output_secret_leak | A ::cronomicon-output value contained an injected secret; all outputs were dropped deliberately. Emit something derived instead of the secret itself. |
Manual run rejected 409 / cron fire skipped | The global maxConcurrent cap is reached, or a Forbid policy suppressed the overlap (recorded once as a skipped run). |
For SSH-executor internals, crash recovery and the stale-run reaper, see Administrator Manual §4; for anything on the agent side, the Runner Install Guide and Runner Config Guide guides.