Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

`forklift` gives Postgres copy-on-write branching: a branch is a COW snapshot of a whole
cluster plus its own compute. Experimental, and deliberately separate from the PostKit
codebase — it exists to test whether branching belongs there.

## Commands

```bash
make build # -> bin/forklift
make test # unit tests, no root
make test-integration # storage conformance suite — run WITHOUT sudo
make install # -> /usr/local/bin/forklift (on sudo's secure_path)
make doctor # report this machine's COW capabilities
make vet fmt clean
```

Go lives at `/usr/local/go/bin/go` and is often **not** on the default PATH:
`export PATH=$PATH:/usr/local/go/bin`.

**Never prefix a make target with `sudo`.** Targets that need root elevate
themselves. `sudo` replaces PATH with its `secure_path`, which excludes the Go
toolchain, so `sudo make test-integration` fails with `go: not found` before any
test runs — a failure that has already been misdiagnosed once as "this machine
cannot run the suite".

Running a single test:

```bash
go test ./internal/branch/ -run TestValidateName -v
sudo -E env "PATH=$PATH" go test -count=1 ./internal/storage/ -run Conformance -v
```

Use `-count=1` on anything you are using as evidence: cached `ok` lines have
already been mistaken for a passing rewrite that the tests never executed.

Anything that touches the pool (`init`, `create`, `start`, `delete`, and the integration
tests) needs **root** — loop devices, `mount`, btrfs subvolumes. `list`, `inspect` and
`doctor` do not.

Manual end-to-end check, if you changed storage or compute:

```bash
sudo ./bin/forklift init && sudo ./bin/forklift create main
sudo ./bin/forklift create agent-a --from main # fork
sudo ./bin/forklift create deep --from agent-a # fork of a fork
sudo ./bin/forklift delete main # must be REFUSED, naming its children
```

`scripts/probe-cow.sh` reports a machine's COW capabilities without building;
`scripts/cow-test.sh` is a standalone end-to-end proof needing no Go.

## Architecture

### The layer thesis

"Fork a database" can be built at three depths, and the choice of depth is the whole
design. Logical (rows/DDL, O(data)); **block** (8 KiB pages as device blocks, O(1),
Postgres unmodified); page (page versions by LSN, O(1) at any LSN, but requires patching
Postgres — the `smgr` hook never landed upstream). forklift works at the block layer, and
that constraint explains most decisions here: branches run **stock** `postgres:{version}`
images that have no idea they are on a clone.

### Three interfaces, one orchestrator

- `storage.Provider` (`internal/storage/provider.go`) — the COW backend. `btrfs.go` is the
only implementation; dm-thin/ZFS/vendor-API are intended to slot in beside it.
- `compute.Provider` (`internal/compute/docker.go`) — runs Postgres on a branch's data dir.
- `metadata.Repository` (`internal/metadata/repo.go`) — the branch registry (JSON today).

`internal/manager` is the **only** place that knows the ordering constraints between the
three; `internal/cli` just wires flags. `internal/branch` holds the domain types and has no
dependencies on the others.

### Invariants worth knowing before changing anything

**A snapshot must be atomic across the entire PGDATA, including `pg_wal`.** That is what
makes cloning a *running* Postgres safe: the clone finds no `backup_label`, enters ordinary
crash recovery, replays from the last checkpoint, and repairs torn pages from full-page
images. Split PGDATA and WAL across devices and you capture two instants — works in testing,
corrupts under load. `CHECKPOINT` before a fork is an optimisation, never a correctness
requirement.

**A cloned PGDATA needs fixups before Postgres will start.** `storage.PrepareClone` removes
`postmaster.pid` (snapshotted faithfully, naming a PID still alive in the parent), fixes
ownership, and sets 0700. Every block-level provider must call it — hence its living in
`storage`, not in `btrfs.go`. It must never touch `pg_wal`; the WAL is what makes the clone
recoverable.

**`branch.ValidateName` must be called at the storage provider boundary**, not only in the
CLI or manager. Names reach `filepath.Join` against the pool root, so an unvalidated name
escapes the pool. `btrfs.go` re-validates every handle it receives, including ones read back
from the registry.

**The registry must live outside the branchable data.** If branch records lived in the
database being forked, forking it would fork the registry and every child would believe it
was authoritative. This is why `metadata` is a file, not a table.

**Do not put branch containers on a `--internal` Docker network.** It does isolate egress —
and silently drops published ports, leaving a branch that is healthy and unreachable. Until
an inbound/outbound-aware mechanism exists, Safe Mode has to be enforced inside Postgres
(`archive_mode` off, subscriptions disabled, cron neutered, FDW mappings cleared). Ports
publish on `127.0.0.1` via `Docker.BindHost`; never make that `0.0.0.0` implicitly.

**Refuse, don't corrupt.** A COW child depends on its parent's blocks, so deleting a parent
with children returns `storage.ErrHasChildren` naming the blockers. Likewise the pool
watermark (`Manager.PoolWatermark`, 85%) fails the *fork* rather than letting a full pool
take running databases read-only.

**Resolve external binaries through `internal/tool`, never bare `exec.LookPath`.**
`losetup`, `dmsetup` and `mkfs.btrfs` live in `/usr/sbin`, which is off a normal
non-root PATH, so a bare lookup false-negatives for unprivileged callers like
`doctor`. The resolver tries PATH then `/usr/local/sbin`, `/usr/sbin`, `/sbin`.
This is the single most repeated bug in this codebase's history — four separate
instances — and the resolver exists so a new call site cannot reintroduce it.

**Detection is tri-state: available / unavailable / unknown.** A probe that could
not run for lack of privilege must return `unknown`, never `unavailable`, and
`Best()` must never select an unknown mechanism. Telling someone a mechanism is
unavailable when you merely could not look is worse than saying nothing — they
will go and change their machine to fix a problem that does not exist.

**Never operate against a Docker daemon the invoking user cannot see.** Under
`sudo`, `docker` talks to root's daemon; a user running rootless Docker or a
non-default context would get branches created somewhere invisible to them.
When `SUDO_USER` is set, compare daemon IDs and refuse on a confirmed mismatch.
Querying the user's daemon needs
`--preserve-env=DOCKER_HOST,DOCKER_CONTEXT,XDG_RUNTIME_DIR`, or you inspect
root's environment and conclude they match when they do not.

### Adding a storage backend

Implement `storage.Provider`, add detection to `storage.Detect` with a `Preference` rank,
and make `Conformance` in `internal/storage/conformance_test.go` pass. That suite is the
contract — it covers parent isolation, depth-2 forks, and delete-refusal. Implement
`storage.Teardown` if the backend holds a mount or loop device, or test cleanup fails with
"device or resource busy".

Mechanism preference is `dm-thin > btrfs > nbd > reflink`. dm-thin ranks first because its
per-device btree keeps read cost flat at any branch depth, where qcow2 backing chains and
classic LVM are O(depth) — but it was **absent on every machine probed so far**, which is
why backends are selected at runtime rather than assumed.

## Known gaps (see README for detail)

macOS is untested and is the most valuable open question — the whole portability argument
rests on the pool living inside the Linux VM. No dm-thin backend. No Safe Mode. No
diff/merge, and data merge probably should never be automatic: a three-way comparison sees
values, but an agent's write is a function of the rows it *read*, and that dependency is
invisible at the storage layer. Sibling branches collide on sequence values; the fix belongs
at fork time (disjoint ranges), not merge time.
27 changes: 22 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ BINARY := bin/forklift
PKG := ./cmd/forklift
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO ?= go
PREFIX ?= /usr/local

.PHONY: help build install test test-integration vet fmt clean doctor

Expand All @@ -14,15 +15,31 @@ build: ## Build the binary into bin/
$(GO) build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) $(PKG)
@echo "built $(BINARY) ($(VERSION))"

install: ## Install to GOBIN
$(GO) install -ldflags "-X main.version=$(VERSION)" $(PKG)
install: build ## Install to $(PREFIX)/bin (on sudo's secure_path)
@if [ "$$(id -u)" = 0 ]; then \
install -m 0755 $(BINARY) $(PREFIX)/bin/forklift; \
else \
sudo install -m 0755 $(BINARY) $(PREFIX)/bin/forklift; \
fi
@echo "installed $(PREFIX)/bin/forklift"

test: ## Unit tests (no root needed)
$(GO) test ./...

test-integration: build ## Storage conformance suite (needs root + btrfs-progs)
@echo "Running as root — the pool needs loop devices and mount."
sudo -E env "PATH=$$PATH" $(GO) test ./internal/storage/ -run Conformance -v
# Run WITHOUT sudo: this recipe elevates itself, forwarding PATH, because sudo
# resets PATH to secure_path and would lose the Go toolchain.
test-integration: ## Storage conformance suite (needs btrfs-progs; elevates itself)
@command -v $(GO) >/dev/null 2>&1 || { \
echo "go not found on PATH."; \
echo "If you typed 'sudo make', run 'make test-integration' instead —"; \
echo "the recipe elevates itself and sudo strips Go from PATH."; \
exit 1; }
@if [ "$$(id -u)" = 0 ]; then \
$(GO) test ./internal/storage/ -run Conformance -v; \
else \
echo "Elevating for the pool (loop devices, mount)..."; \
sudo -E env "PATH=$$PATH" $(GO) test ./internal/storage/ -run Conformance -v; \
fi

vet: ## go vet
$(GO) vet ./...
Expand Down
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,31 @@ universally available. Probing a plain Linux Docker host found dm-thin absent
only `multipath, striped, linear, error` device-mapper targets, with an empty
`/lib/modules` — plus no nbd and no reflink, since the filesystem was overlayfs.

`doctor` needs no root, but some probes do — it says so rather than guessing:

```
$ forklift doctor
MECHANISM STATUS DETAIL
dm-thin unavailable absent; dm targets present: multipath, striped, linear, error
btrfs available validated: 210ms live snapshot, clean recovery, depth-2 verified
nbd (qcow2) unavailable absent (/dev/nbd0 missing)
reflink unavailable filesystem does not support reflink
loop devices working required by every pool-in-a-file mechanism
MECHANISM STATUS DETAIL
dm-thin unknown — re-run with sudo to determine requires root to query dm targets
btrfs available validated: 210ms live snapshot, clean recovery, depth-2 verified
nbd (qcow2) unavailable absent (/dev/nbd0 missing)
reflink unavailable filesystem does not support reflink
loop devices unknown — re-run with sudo to determine required by every pool-in-a-file mechanism
docker available context default, rootless no, id a64d6a4e-...

Best available mechanism: btrfs

Note: probes marked "unknown" could not run without root; re-run with sudo to determine them.
```

`unknown` is deliberately distinct from `unavailable`. Querying dm targets needs
`/dev/mapper/control`, and probing loop devices needs to attach one — neither is
possible unprivileged. Reporting those as "unavailable" would tell you a
mechanism does not work on your machine when it does. Under `sudo` they resolve:

```
dm-thin unavailable absent; dm targets present: multipath, striped, linear, error
loop devices available required by every pool-in-a-file mechanism
```

| Mechanism | Status | Notes |
Expand All @@ -120,10 +135,18 @@ work on macOS, where a ZFS-on-the-host approach cannot — untested, see below.
Requires Linux, root (loop devices, `mount`, btrfs subvolumes), `btrfs-progs`,
and Docker.

`make install` deliberately targets `/usr/local/bin` rather than `GOBIN`.
`go install` puts the binary in `~/go/bin`, which is **not** on sudo's
`secure_path`, so `sudo forklift ...` would fail with `command not found` — and
every pool command needs root. Override with `make install PREFIX=/somewhere`.
If you do run it from an unusual location, forklift prints the exact
`sudo /abs/path/forklift ...` line to re-run.

```bash
make build
make build # -> ./bin/forklift
make install # -> /usr/local/bin/forklift

sudo ./bin/forklift doctor # what can this machine do?
sudo forklift doctor # what can this machine do?
sudo ./bin/forklift init # create the pool
sudo ./bin/forklift create main # empty branch, runs initdb
sudo ./bin/forklift create agent-a --from main
Expand Down Expand Up @@ -164,9 +187,15 @@ child would believe it was the authoritative source of truth about all branches.

```bash
make test # unit tests, no root required
make test-integration # conformance suite against btrfs; needs root
make test-integration # conformance suite against btrfs — run WITHOUT sudo
```

`make test-integration` elevates itself. Do not prefix it with `sudo`: sudo
replaces `PATH` with its `secure_path`, which excludes the Go toolchain, so
`sudo make` fails with `go: not found` before any test runs. The target detects
that and tells you to drop the sudo. If you are already root (a CI container,
say) it runs `go test` directly instead of nesting sudo.

## Known gaps

- **macOS is untested.** The whole portability argument rests on the pool living
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ module github.com/postkitstack/forklift
go 1.26.6

require (
github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55
github.com/lib/pq v1.12.3
github.com/spf13/cobra v1.10.2
)

require (
github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55 h1:VAnGuI8RNnP8vHqCn8X1O63TexAv+QjMqffBdkLbYKU=
github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55/go.mod h1:Kn6RQo4OP1ZEoLB3uldDJabFcf72VgDRInxEqLEo8OE=
github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 h1:K71w/n/Y74EQsKo91511t7TK35YRPrk9G+2anKYNPXk=
github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068/go.mod h1:ellh2YB5ldny99SBU/VX7Nq0xiZbHphf1DrtHxxjMk0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
Expand Down
Loading