Skip to content

State reference

Jailrun is declarative — you describe what you want, and jrun figures out how to get there. Under the hood, this works through two concepts: state and plan.

State

State is a JSON file at ~/.jrun/state.json that records the current reality — what's running, what's mounted, what's forwarded. Jailrun reads it on every jrun up to understand what already exists before making changes.

The state tracks:

  • Base configuration — VM-level setup, port forwards, and mounts
  • Jails — each jail's name, release, IP, base, forwards, mounts, and supervised processes
  • QEMU wiring — the port forwards and shared directories currently passed to the VM process
  • SSH port — the port used to reach the VM
Example state.json
{
  "version": 1,
  "base": {
    "setup": {},
    "forwards": {},
    "mounts": {}
  },
  "jails": {
    "python-314": {
      "name": "python-314",
      "base": null,
      "release": "15.0-RELEASE",
      "ip": "10.17.89.10",
      "forwards": {},
      "mounts": {},
      "execs": {},
      "setup": {
        "python": {
          "type": "ansible",
          "file": "playbooks/python-314.yml",
          "vars": {}
        }
      }
    },
    "postgres-16": {
      "name": "postgres-16",
      "base": null,
      "release": "15.0-RELEASE",
      "ip": "10.17.89.11",
      "forwards": {
        "pg": {
          "proto": "tcp",
          "host": 6432,
          "jail": 5432
        }
      },
      "mounts": {},
      "execs": {},
      "setup": {
        "postgres": {
          "type": "ansible",
          "url": "hub://postgres/16",
          "vars": {}
        }
      }
    },
    "fastapi-314": {
      "name": "fastapi-314",
      "base": {
        "type": "jail",
        "name": "python-314"
      },
      "release": "15.0-RELEASE",
      "ip": "10.17.89.12",
      "forwards": {
        "http": {
          "proto": "tcp",
          "host": 8080,
          "jail": 8000
        }
      },
      "mounts": {
        "src": {
          "host": "/Users/you/Projects/fastapi",
          "jail": "/srv/app"
        }
      },
      "execs": {
        "httpserver": {
          "cmd": "python3.14 -m uvicorn app:app --reload",
          "dir": "/srv/app",
          "env": {},
          "healthcheck": {
            "test": "fetch -qo /dev/null http://127.0.0.1:8000",
            "interval": "30s",
            "timeout": "10s",
            "retries": 5
          }
        }
      },
      "setup": {
        "fastapi": {
          "type": "ansible",
          "file": "playbooks/fastapi-314.yml",
          "vars": {}
        }
      }
    }
  },
  "launched_fwds": [
    { "proto": "tcp", "host": 6432, "guest": 6432 },
    { "proto": "tcp", "host": 8080, "guest": 8080 }
  ],
  "launched_shares": [
    {
      "host": "/Users/you/Projects/fastapi",
      "id": "fs_3c197bfb31",
      "mount_tag": "jrun_3c197bfb31"
    }
  ],
  "ssh_port": 2222
}

Plan

Before making any changes, jrun compares the desired config against the current state and produces a plan — the precise set of actions needed to bring reality in line with the config.

The plan includes:

  • Jails to create — new jails or jails cloned from a base
  • Mounts to set up — host directories shared into the VM and nullfs mounts into jails
  • Processes to start — supervised commands with optional healthchecks
  • Port forwards to wire — host-to-jail port mappings via pf
  • Stale jails to remove — jails that exist in state but are no longer in the config
  • Stale mounts to clean up — mounts that are no longer needed

If the port forwarding or mount configuration changed in a way that affects QEMU's startup arguments, jrun detects this and restarts the VM automatically before applying the rest of the plan.

How they work together

graph LR
  UCL["stack.ucl\n(desired)"] --> DIFF["Compare"]
  STATE["state.json\n(current)"] --> DIFF
  DIFF --> PLAN["Plan"]
  PLAN --> EXEC["Execute"]
  EXEC --> UPDATED["state.json\n(updated)"]

This is what makes jrun idempotent — running jrun up twice with the same config produces no changes the second time. And it's what makes partial deploys safe.