rinman24/billet

★ 0Forks 0PythonGitHub ↗Compare

README

billet

A berth for every repo.

A stateless CLI that posts any repo's devcontainer to a shared cloud Host — from the command line.

PyPI Python 3.11+ License: MIT

Install  ·  Quick start  ·  Commands  ·  Docs  ·  Brand  ·  Source

billet provisions and drives an Azure VM (a Host), then starts, stops, and connects to one or more repositories' devcontainers (Workspaces) running on it. Each repository keeps owning its own .devcontainer/; billet owns the VM lifecycle, connectivity, and a registry-driven dispatcher. It is decomposed by volatility (Löwy closed architecture); the swappable HostProvider is the load-bearing seam (Azure VM today; DevPod / Dev Box later).

Stateless by design: operator intent lives in one config.toml; IP address, power state, and the host↔workspace mapping are derived live from Azure and resource tags.

Status

Both subsystems ship in Python. The Host subsystem drives the VM behind the HostProvider seam (billet host up|stop|pin-ip|specs), with a dry-run plan and a confirm gate on billable cold-create. The Workspace subsystem clones, builds, bootstraps, and connects a repo's devcontainer on a Host (billet add|ls|doctor|start|stop|connect|ssh-config|rm), reading each repo's .devcontainer/devcontainer.json as a read-only data contract. The Python tool now fully replaces the original shell scripts lifted from gswa-backend, which have been removed. The architecture is recorded in ADR-0001 and ADR-0002.

Install

uv tool install git+https://github.com/rinman24/billet

(PyPI publication is deferred; install from GitHub for now.)

PATH setup

uv tool install places the billet executable in ~/.local/bin. If your shell reports billet: command not found, that directory is not on your PATH. Fix it with uv's own helper, then open a new terminal:

uv tool update-shell

Or add the directory to your shell profile manually (zsh shown):

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc

If billet is still not found after opening a new terminal, your shell may not be sourcing the file that was edited — check echo $ZDOTDIR and inspect your .zshenv, .zprofile, or /etc/zshenv for a config-directory redirect.

Update

Move an existing install to the latest main by re-running the install with --force --reinstall, then check what you actually got:

uv tool install --force --reinstall git+https://github.com/rinman24/billet
billet version

uv tool upgrade billet is the shorter form and often works, but billet installs from an unpinned git URL whose requirement string is identical from one release to the next, so the upgrade can resolve out of uv's git cache and report success without changing anything. --reinstall ignores that cache, and --force lets the new build replace the existing ~/.local/bin/billet. If billet version still reports the old number, run uv cache clean billet and install again.

Usage

mkdir -p ~/.config/billet && cp config.example.toml ~/.config/billet/config.toml
# edit config.toml: subscription, [hosts.<key>], [workspaces.<key>]

billet host up --dry-run   # show the plan (cold-create / resume, auto-detected)
billet host up             # create or resume the VM (cold-create asks to confirm)
billet host pin-ip         # re-pin inbound SSH to your current egress IP/32
billet host specs          # live CPU / memory / disk / container usage on the host
billet host stop           # deallocate the VM (stops compute billing)

Then run a repository's devcontainer Workspace on the Host:

billet add gswa-backend          # validate the [workspaces.<key>] block
billet start gswa-backend        # bring the Host up, then clone + compose up + bootstrap
billet ssh-config                # write ~/.ssh/config.d/billet.conf (+ one Include line)
billet connect gswa-backend      # ssh in and attach to the tmux session
billet ls                        # show each Workspace and whether it is running
billet doctor                    # report each Workspace's Berth drift (read-only, exit 0)
billet stop gswa-backend         # stop the container (non-destructive)

billet doctor [--host <key>] [--workspace <key>] compares each Workspace's Host checkout with the Berth the installed billet ships: the berth.version stamp, and dev-entrypoint.sh, sshd.conf and authorized_keys-stub by directive hash (comments and whitespace ignored). It reads over one SSH session per Host, shows each checkout's short HEAD, skips an unreachable Host without starting it, reports a Host whose probe outlasts 30 s as timed out, and always exits 0 (ADR-0015). The templates ship inside the wheel, so run it from an installed billet; an editable checkout (uv run) does not carry them.

The compose service, compose file(s), workspaceFolder, remoteUser, and postCreateCommand are read live from each repo's .devcontainer/devcontainer.json — billet does not duplicate them in config.toml.

Because remoteUser is read live, billet ssh-config can only render a Workspace that is already cloned on a running Host. Any other is skipped with a warning and the remaining aliases are still written, so one un-started Workspace never costs you connectivity to the rest — billet start <key>, then re-run ssh-config, to pick up its alias. See ADR-0010.

Multiple Workspaces on one Host

Several repos can share one VM. Give each a distinct container_ssh_port (billet add validates per-host uniqueness), and have each repo's compose bind its sshd to billet's assigned port — billet exports BILLET_CONTAINER_SSH_PORT before every docker compose, so the repo publishes:

services:
  <service>:
    ports:
      - "127.0.0.1:${BILLET_CONTAINER_SSH_PORT:-2222}:22"

The :-2222 default keeps an un-adopted repo working unchanged; only the second repo onward must parameterize its port. billet ssh-config then renders both containers behind the one Host (ProxyJump), each with its own port and a collision-free HostKeyAlias, and the Host still needs a single NSG rule. See ADR-0003.

Onboarding a new repo is a checklist plus copyable template files: see Adopting a repo as a Workspace and templates/workspace/.

A Host without Workspaces (the fleet-host)

Some Hosts are managed by billet purely for their VM lifecycle and carry no Workspaces — for example a fleet-host whose runtime is owned elsewhere. Declare such a Host with manages_workspaces = false:

[hosts.fleet]
resource_group     = "GSWA-FLEET-HOST-RG"
vm_name            = "gswa-fleet-host"
location           = "westus3"
admin_user         = "azureuser"
vm_size            = "Standard_D4s_v5"
manages_workspaces = false
# ... vm_image / public_ip_sku / os_disk_gb / storage_sku as for any host

Its VM lifecycle works like any other Host:

billet host up --host fleet --dry-run   # adopt → pin → start → wait
billet host stop --host fleet           # deallocate

But it registers no [workspaces.*]. The workspace verbs (add/start/stop/connect/ ssh-config) refuse a Host with manages_workspaces = false, and billet ls flags any Workspace wrongly placed on one as INVALID rather than probing it. See ADR-0004.

Ubiquitous language

  • Host — a cloud VM that runs containers.
  • Workspace — a repository's devcontainer running on a Host.
  • HostProvider — the backend seam that implements Host lifecycle (Azure VM today).
  • Berth — the Workspace runtime contract billet publishes under templates/workspace/ (sshd on the assigned loopback port, dev at uid/gid 1000, the entrypoint's behaviors), versioned independently of billet by berth.version.
  • Locker — one named compose volume persisting one tool's state under the login user's home (<service>_claude_home:/home/dev/.claude), declared only in the consumer's compose file.

The full glossary and the map of the repositories billet collaborates with are in the context map.

Development

uv sync                 # create .venv and install dev tooling
make lint               # ruff + pyright (strict)
make imports            # import-linter layer contract
make test               # pytest

License

MIT © 2026 Rich Inman, PhD

Contributors

rinman24

Issues