lm-provision
Spec-first pod provisioning: a Rust host + typed profile AST DSL for declaratively provisioning LLM / GPU compute (RunPod-style pods, local hosts). A profile is a declarative document — JSON or canonical text — that is validated, hashed, planned, and applied by a single static binary with zero dependencies on the target pod.
Workspace
| Crate | What it is |
|---|---|
lm-provision |
Core library + the pod-side binary lm-provisioner (validate / hash / plan / apply [--dry-run] / fetch / pin). Typed ProfileNode AST, deterministic canonical encoding + SHA-256 profile hash, pure-Rust effect engine — no embedded scripting runtime. Fragment imports (spec 11): hash-pinned local + https fragment reuse with an XDG-cached expansion pass, plus a pin authoring subcommand that rewrites name@version imports against an index.json. |
lm-provision-cli |
The operator CLI: the lm-provision binary, and the one command an operator installs. apply / check act on a pod; logs / exec / cp work the pod an apply left running; machine list / acquire / release / sweep are the fleet; mcp serves the MCP tools over stdio. |
lm-provision-driver |
Push-driver library, behind lm-provision apply and the machine subcommands. The session: ensure-binary (resolve the provisioner for a version to the release build CI published, verify its SHA-256, cache it, and push it idempotently), place profile, apply, collect report / transcript, append to the apply ledger. The machine side: obtain one meeting the profile's declared requirements (stamping its lease onto the machine's own name), read a platform's own list, give a machine back, give back every machine whose lease has run out. |
lm-provision-mcp |
MCP server library, served by lm-provision mcp: lm_validate / lm_hash / lm_plan, lm_apply, lm_machine_list, and apply-ledger inspection as MCP tools. |
lm-provision-protocol |
The wire types shared across the license boundary: the append-only apply-ledger row schema (LedgerRow / ArtifactRow) and the acquisitions record (AcquisitionRow — the audit trail of what was bought and what came back), both in JSON Lines encoding — appended by the driver, taken custody of by the host. Neutral and permissive so both sides may depend on it. |
lm-provision-host |
The control plane, AGPL-3.0-or-later, publish = false. The TTL-enforcement daemon: it runs lm-provision machine sweep every interval — as a child process, not as a linked library, so the AGPL side depends on nothing permissive — and answers one health endpoint saying whether the last sweep worked and what it did. Enforcing is its default (--dry-run false, the opposite of the CLI's): installing it is the consent to release expired machines. Ledger custody is next. |
Highlights
- Declarative phase catalog (23 kinds) — system packages, Python
toolchain, ComfyUI install / restart / health, generic service
start / readiness, model prefetch,
https/hf:///b2://transfers, filesystem writes, shell steps, hooks, and first-class HTTP steps (headers / body /body_json/timeout_sec). - Deterministic profile hash — canonical byte encoding over the AST (frontend-independent, declaration lists sorted, phase order preserved) feeding an append-only apply ledger.
- Secrets never leak —
EnvSecret/EnvRefresolve through a declaration-derived env policy; values travel via environment or SSH stdin script only, never in argv / reports / transcripts, and audit output redacts to names + byte lengths. - Fail-fast readiness — per-kind poll deadlines (health 180s / ready 300s, overridable per step) plus died-during-wait detection that fails in seconds when the supervised process crashes during startup instead of burning the whole timeout.
- Static musl binary, built by CI — the provisioner runs on the pod
with zero preinstalled dependencies. Every tag publishes it beside a
.sha256; the driver resolves a version to that release build, verifies the digest, caches it, and pushes it on demand. Provisioning a pod needs a network, not a toolchain.
Install
Two binaries, and which is which matters: lm-provision is the
operator CLI you install and run, and lm-provisioner is the static
musl binary it pushes to a pod and runs there. The CLI is the one to
install; the provisioner comes down from the release on demand, so you
only want it locally to run a profile against the machine you are
sitting at.
# Fresh install. Upgrading from 0.9.0 or earlier: see below first.
Upgrading from 0.9.0 or earlier: uninstall the old lm-provision
package first. Up to 0.9.0 the lm-provision package owned the
lm-provision binary name; from 0.10 the lm-provision-cli package
owns it, and cargo does not hand a binary name from one installed
package to another. On a host with the old version installed:
- Without the uninstall,
cargo install lm-provision-cliis refused: "binarylm-provisionalready exists in destination as part oflm-provision". Reinstallinglm-provisionon its own does not free the name — measured on this rename, a reinstall over 0.9.0 left the oldlm-provisionbinary in place and the refusal stood — which is why the step is an uninstall, not a reinstall. --forceon the CLI install overwrites the file but leaves cargo's install record attributinglm-provisionto the old package, so a latercargo install lm-provisionmay delete the CLI as a binary that package stopped producing. If that has happened, runcargo install lm-provision-clionce more; nothing else is damaged.
Quickstart
The pod-side subcommands below (validate / hash / plan / apply /
fetch / pin, chapter 07) are lm-provisioner's; you rarely type
them yourself, because lm-provision apply is what gets them run on the
machine.
# Author a profile (JSON), then locally — the pod-side binary, run on
# your own host:
# Or start from a shared profile (docs/profiles/) — verified fetch,
# kept only if the canonical hash matches the pin from index.json:
# Fragment reuse: write `Import` nodes with the `name@version`
# shorthand and let `pin` rewrite them into the explicit src+hash
# pair against an index.json (spec 11 §The resolver layer). The
# rewrite is verified end-to-end before it lands.
# Apply on the target host itself (or via the push driver from your
# machine, below):
# One-shot provision of a remote pod over SSH. The provisioner pushed
# to the pod is the release build for this CLI's own version —
# fetched once, checksum-verified, cached under $XDG_CACHE_HOME:
# Or name the machine and let the platform say where it is — the
# address and port come out of the platform's own description of it.
# The identity file can live in ~/.config/lm-provision/.env as
# LM_PROVISION_SSH_KEY, beside RUNPOD_API_KEY, instead of --key:
# After apply: the pod's own output, without typing an ssh line.
# Pin another released version, or push a local build of your own
# (the override for developing the provisioner itself):
# The fleet. --dry-run defaults to on where something is spent or
# destroyed: those render the request and send nothing.
# The MCP server, on stdio (see below for what it reads):
MCP server: which provisioner it pushes
lm-provision mcp needs no configuration to have a provisioner: unset,
it resolves the release its own version was built alongside, the same
way apply does. LM_PROVISION_BINARY overrides that with either
form — an https:// archive URL (verified against the .sha256 beside
it, so a fork's release or an in-network mirror works), or a local path
(used as given, for developing the provisioner itself):
# neither line is required; this is what the two overrides look like
Any other scheme is refused rather than read as a filename — a mistyped
http:// reported as a missing file sends you looking for a file.
MCP server: pod target registry
lm-provision mcp resolves lm_apply's pod_id against a pod target
registry — a JSON file naming every pod the server may provision.
Point LM_PROVISION_TARGETS at it:
kind: "ssh" requires host, port (non-zero — RunPod maps a per-pod
external port) and key_path (no fallback to a default key); user
defaults to root and remote_dir to /root. kind: "local-exec"
runs on the server's own host and defaults staging_dir to
LM_PROVISION_STAGING_DIR. Paths are literal — neither ~ nor
environment variables are expanded.
A pod_id with no entry is rejected before anything runs, so a ledger
row records a destination the server was configured for. Operational
notes:
- Migrating an existing deployment: servers started without
LM_PROVISION_TARGETSresolve nothing and everylm_applyfails. Write one registry file, point the variable at it, restart the server. There is no fallback to the previous behaviour of running every apply on the server's own host. - Adding a pod: the registry is read once at startup, so edit the file and restart the server; there is no reload path.
- Where to keep it: the file carries real host names, users and key
paths — keep it outside the repository (e.g. under
/etc), or add it to.gitignoreif it must live inside one. dry_runstill connects forsshtargets: the driver uploads the binary and hashes it on the pod before invokingapply --dry-run, so only the pod-side apply effects are skipped. There is no way to check a registry entry without contacting the pod.
Specifications
External interfaces are specified in docs/spec/ (00-10:
profile DSL surface, phase catalog, pipeline stage artifacts, bridge,
sandbox layer contract, secret handling, CLI, push-driver protocol,
apply report and ledger, MCP). The implementation lands against those
specs; the specs are the normative surface.
License
The engine — lm-provision, lm-provision-cli, lm-provision-driver,
lm-provision-mcp and lm-provision-protocol — is dual-licensed under
either of:
- MIT License (
LICENSE-MIT) - Apache License, Version 2.0 (
LICENSE-APACHE)
at your option, and will remain so permanently. Provisioning a pod is what these crates do, and nothing about how someone runs a control plane changes the terms on which they may do that.
lm-provision-host, and any control-plane crate added beside it later,
is AGPL-3.0-or-later: it is a service one can host for others, and
the AGPL is the license that keeps a hosted modification available to
the people using it.
The two sides still share a vocabulary — the ledger rows the driver
writes and the host reads. Those types live in
lm-provision-protocol, which stays permissive precisely so that
depending on it commits no one to anything. The direction that would
break the promise, an engine crate depending on the host, is empty and
machine-checked: no_permissive_crate_depends_on_the_agpl_host in the
host crate reads the permissive manifests and fails if any of them
names it.