Distributed script automation.
PowerShell and bash, Windows and Linux, in one cluster. Scripts arrive over an HTTP API, replicate through a Raft consensus log, and execute only where they can actually run.
01 — What it does
Submission, placement and results, all through one replicated log.
melange replaces a system running more than a million PowerShell executions a year. A cluster is a mix of Windows and Linux machines; a job names the runtime its script is written for, and the leader places it only where it can run.
Results are replicated, not just submissions
A job's stdout and exit code go through the same consensus log its submission did, so they survive the death of the node that produced them.
Execution is at-least-once. A partitioned node is indistinguishable from a dead one, so a reassigned job can run twice; scripts need to be idempotent.
Any node accepts writes
Only the leader may append to the Raft log, so a proposal made on a follower is forwarded to the leader over the internal transport. A worker on any node can therefore record its result.
A 503 means the cluster cannot commit — no leader, or no quorum — and carries the leader's address when there is one to name.
Placement is filtered, then weighted
A node advertises its cores, memory, secret names and available interpreters.
The leader narrows the pool to nodes that can honour the job, then picks the
least loaded of those by units / slots.
A job nothing in the cluster can run is refused at submission rather than placed somewhere it would fail.
02 — How a job flows
Submitted once. Committed to a majority. Run exactly where it can be.
Every node applies every log entry, but a node only runs jobs assigned to itself. That one check is what makes a single log entry produce exactly one execution, cluster-wide. The leader is one of those nodes — it takes work like any other, and leadership moves when it dies.
POST /v1/jobs— any node accepts the write. A node that is not the leader forwards it over the peer transport.- The leader picks a target: nodes that can run it, then the least loaded by
units / slots. It assigns to itself as readily as to anyone. SubmitJobis committed to a majority. Every node applies the entry; only the assignee acts on it.- 202 Accepted — the id comes back only once the job is durable.
- The assignee, and only the assignee, spawns the interpreter, queueing if its own process slots are full.
StartJobandCompleteJobgo back through the log — three entries per job, and the output is replicated too.GET /v1/jobs/{id}is served locally, by whichever node you ask.
03 — Placement
Filtered before it is weighted.
A job can only run where the things it needs are. The leader narrows
the pool to the nodes that can honour it, prefers the ones with a free slot, and only
then picks the least loaded — by units / slots, so a big box takes
proportionally more work than a small one.
melange-cli cluster status
NODE CORES MEM (MB) SLOTS IN FLIGHT RUNTIMES STATE
*1 16 32768 64 0 powershell
2 8 16384 32 2 bash,powershell
3 4 8192 16 1 bash draining# what a job needs decides where it may go
melange-cli submit ./backup.sh --secret DB_PASSWORD
# what it declares decides only how heavily it counts
melange-cli submit ./reindex.ps1 --cores 4 --memory-mb 4096melange-cli submit ./audit.sh --secret NOBODY_HAS_THIS
error: 400 no node can supply this job's secrets: NOBODY_HAS_THIS
melange-cli submit ./audit.sh --secret DB_PASSWORD
error: 503 the nodes holding DB_PASSWORD are unreachableTwo hard constraints, one filter
A node must have an interpreter for the job’s runtime, and must hold every node-local secret it names. Both ask the same thing — can this node run it at all? — so one filter answers both.
The two refusals differ because the situations do: nothing in the cluster can ever run it, against nothing that can is reachable right now.
Then weighted by how big the node is
The leader minimises units / slots, where a node’s slots are
min(cores × 4, memory / 256 MB) — whichever of CPU and RAM runs
out first. Ratios are compared by cross-multiplication, so a placement cannot
wobble on a rounding error.
Load is queue depth, not jobs ever run: a node grinding through one 40-minute script is busier than one that churned through fifty one-liners.
Full is a preference, not a wall
A node at its concurrency limit is skipped while anything else can take the work — but if that would empty the pool it comes back whole, because a full node still runs the job in turn. A declared cost is the same: a weight, never a limit, and nothing enforces it against the script.
04 — Secrets
Named by the job, resolved by the node that runs it.
A job carries the names of the secrets it needs, never the values. The node about to run the script resolves each one and sets it as an environment variable. A script, its arguments and its output are all replicated in the clear, so this is the only way a password gets used without becoming one of them.
# prompted, or --from-file; never an argument, which shells remember
melange-cli secret set DB_PASSWORD
Value: ********
name: DB_PASSWORD
key: 59961b78f8640c8a
version: 1
nodes: 1, 2, 3
melange-cli secret list # names, key id, and who can supply each
melange-cli secret rm DB_PASSWORD
melange-cli submit ./backup.ps1 --secret DB_PASSWORD# the other kind: a JSON file on one machine, read once at startup
melange-server --secrets-file /etc/melange/secrets.jsonCluster secrets, with nothing to set up
The key that seals them is derived from the cluster CA, which every node already holds. The CLI seals the value on your own machine; the accepting node re-seals it under the cluster key, and it is that ciphertext which replicates. The plaintext is never in the log, in a database, or in a snapshot, and nothing reads one back.
Node-local secrets constrain placement
A file on one machine, never replicated, winning on a name collision. Only that node can supply it, so a job naming it can only run there. A cluster secret is usable on every keyed node, so it constrains nothing.
Printing one is scrubbed
The node running the job replaces the exact bytes in stdout, stderr, the live tail and the progress line. Base64, a hash, or a value split across a format string is not caught — a safety net for the accident, not a boundary.
05 — Data tables
Replicated, typed storage a script can write to.
melange already replicates what a job is and what it printed. Tables are
for what it computed. Columns are declared — text, int,
float, bool, timestamp — and rows are written
through the same consensus log as everything else.
melange-cli table create hosts \
--column name:text:key --column os:text \
--column last_seen:timestamp --column patched:bool
melange-cli table index hosts os
melange-cli row set hosts name=web-01 os=windows patched=true
# repeatable --filter, AND-ed; | inside one is OR
melange-cli row list hosts --filter os:eq:linux --filter patched:eq:false
melange-cli row export hosts --format csv -o hosts.csv# and from inside a job, against the loopback listener it is handed
Import-Module Melange
Set-MelangeRow -Table hosts -Values @{
name = $env:COMPUTERNAME
patched = $true
}Queries that say what they cost
Operators are eq, ne, lt, le,
gt, ge, prefix, isnull and
notnull. There is no contains, because an index cannot
answer one and an operator that always scans is a trap.
- Composite primary keys, and unique indexes enforced on write.
- Equi-joins to another table's key:
--join local:table, and you can filter on a joined column. - Ordering requires an index on the column — the alternative is a page that is ordered, plausible and wrong.
- Aggregates (
count,sum,avg,min,max) with grouping, and CSV in both directions.
Reachable from inside a job
MELANGE_API_URL is a loopback listener serving the table routes and
nothing else; MELANGE_JOB_TOKEN is minted per run, dies with it, and
carries the submitter’s role.
06 — Scheduling and pipelines
A schedule fires a chain, not just a script.
Five-field cron, evaluated in a cluster-wide timezone set by
--timezone. A schedule names an ordered list of scripts, and the leader
mints all their job ids in one fire, so a stage can depend on the concrete job before
it rather than on something that does not exist yet.
# one schedule, three script ids, run in that order
melange-cli schedule add <backup-id> <verify-id> <prune-id> \
--cron "0 3 * * *"
melange-cli schedule list
melange-cli schedule disable <id>
# or chain an ad-hoc job behind others
melange-cli submit ./report.ps1 --after <job-id>A stage waits for the one before it to succeed
A job with prerequisites is committed, placed on a node and counted as queued load — but never signalled to run until every prerequisite has succeeded.
If one fails, times out or is cancelled, the rest of the chain is cancelled rather than run. A dependency means "after this succeeds", not "eventually".
Fired once, whoever is leader
One watermark check makes a whole pipeline exactly-once across a failover. A fire is all-or-nothing: if any stage's script is missing or has nowhere to run, the tick is skipped rather than stranding half a chain.
07 — Shared libraries
Importable code, replicated to every node.
A library is named, runtime-specific and stored like a script. A job carries only the names; the node about to run it writes the content into that job's own directory, so there is nothing to install on a node and nothing to keep in step.
melange-cli library create Inventory ./Inventory.psm1
melange-cli library list
# --library is optional: imports are detected from the script itself
melange-cli submit ./audit.ps1 --library Inventory# PowerShell: on $env:PSModulePath, so this just resolves
Import-Module Inventory
# bash: sourced from the same directory
source "$MELANGE_LIB_DIR/inventory.sh"No placement cost
A secret lives on one node, so it constrains where a job can go. A library's content is replicated everywhere, so every node can honour any reference and placement never has to think about it.
Imports are detected
Submitting a script reads its own Import-Module, using module and source lines, and attaches any that name a registered library at the same runtime. --no-detect-libraries turns it off.
Checked at both ends
A name that exists nowhere is refused when the job is submitted, and checked again by the node about to run it — a job that would silently run without its library fails instead.
Libraries are parsed when saved, so a syntax error surfaces at the point you upload it rather than the first time a job imports it.
08 — Projects
Work on scripts locally, then push the set.
A project is a melange.toml beside your scripts. It vendors
the libraries they import into modules/, so an editor resolves the
imports and you can run a script on your own machine before anything reaches a
cluster.
melange-cli project init --name ops
melange-cli project add Inventory # vendor a library
melange-cli project script new nightly # scaffold a script
# run it here, with the same environment a node would give it
melange-cli project run nightly
melange-cli project outdated
melange-cli project push --dry-run
melange-cli project pushRun before you upload
project run executes the script locally the way a node would: a
private job directory, the vendored libraries on the import path, and the same
MELANGE_JOB_DIR, MELANGE_LIB_DIR and
MELANGE_CANCEL_FILE variables set.
A script belongs to its project
A script's identity is the pair (project, name), enforced when the
entry is applied. Two projects can both have a nightly without one
quietly overwriting the other and stealing its schedules.
project clone takes an existing project back out of the registry.
09 — Also in the box
The rest of what a cluster gives you.
Replicated results
A job's stdout and exit code go through the consensus log, not just its submission.
Three front ends
A CLI, a terminal UI and a web UI over one REST surface, plus a PowerShell module.
Sandboxing
A job object on Windows; no_new_privs, rlimits and optional Landlock on Linux.
Users and roles
argon2id passwords and ordered roles, replicated — create a user on one node, log in on any.
Audit trail
A server-stamped submitted_by the client cannot forge, and you can filter jobs on it.
Retention
History is finite by default: terminal jobs past the cutoff are purged in bounded batches.
Live output
Tail a running job's stdout before it finishes, from any node in the cluster.
Peer mTLS
One cluster CA, with each node minting its own leaf at boot. A node without it will not start.
10 — Quick start
A cluster on your laptop, in about a minute.
You need Rust and at least one interpreter on PATH:
pwsh (7+) or powershell (5.1), and bash on
Unix. A node detects what it has and advertises it.
Three real nodes, with Docker
The fastest way to see failover, forwarding and reassignment behave. Brings up a cluster, a web UI, and the CLI baked into the image.
docker compose up --buildOr build it and run a single node
The CA is required on every node and is the cluster's shared credential — each node mints its own leaf from it at boot, so there is nothing to distribute.
cargo build --workspace --release
# the cluster CA, once
melange-server --ca-init ./tls
melange-server --node-id 1 --data n1.redb --init \
--tls-ca-cert ./tls/ca.crt --tls-ca-key ./tls/ca.keySubmit something
The runtime is inferred from the file extension, then from a #!
line, and falls back to PowerShell. --runtime overrides it.
echo 'Write-Output "hi"' | melange-cli submit --wait
melange-cli submit ./rotate-logs.sh --wait # runs on a node with bash
melange-cli tui # the whole cluster, in a terminal