A base for building clustered applications on top of an embedded etcd server. It boots etcd in-process (no separate etcd binary to operate) and exposes the cluster's membership and leadership over a small REST API.
Phase 1 (MVP) is complete; Phase 2 hardening is underway (multi-node clustering landed). See ROADMAP.md for status and what's next.
- Embedded etcd server (single- or multi-node) with graceful startup/shutdown.
- Static multi-node cluster bootstrap via
-initial-cluster. - Structured JSON logging (
log/slog); etcd's own logs are bridged into the same stream and its benign shutdown noise is suppressed. Level via-log-level. - REST API (stdlib
net/http, no web framework):GET /cluster/nodes— list cluster members and their role (leader/follower).GET /healthz— liveness probe.
Requires Go 1.26+.
make install # download + tidy dependenciesAll workflows go through the Makefile (make help lists everything):
| Command | Description |
|---|---|
make run |
Run the server locally (Ctrl-C to stop). |
make build |
Compile the binary into ./bin/raftbase. |
make test |
Full test suite, including the embedded-etcd integration test. |
make test-unit |
Fast unit tests only (-short, skips booting etcd). |
make test-e2e |
3-container Docker end-to-end test (builds image, boots cluster). |
make lint |
go vet + gofmt formatting check. |
make fmt |
Auto-format all Go source. |
make clean |
Remove ./bin and the local ./data etcd directory. |
make run
# in another terminal:
curl -s localhost:8080/healthz # -> ok
curl -s localhost:8080/cluster/nodes | jq # -> {"nodes":[{... "status":"leader" ...}]}Flags (see go run ./cmd/raftbase -h): -name, -data-dir, -client-url,
-peer-url, -api-addr, -log-level, -initial-cluster,
-initial-cluster-state, -initial-cluster-token.
Start each member with the same -initial-cluster (and token) but a unique
-name, -data-dir, and set of URLs. Example 3-node cluster on one host:
CLUSTER="n1=http://127.0.0.1:2380,n2=http://127.0.0.1:2382,n3=http://127.0.0.1:2384"
go run ./cmd/raftbase -name n1 -data-dir ./data/n1 \
-client-url http://127.0.0.1:2379 -peer-url http://127.0.0.1:2380 \
-api-addr :8080 -initial-cluster "$CLUSTER" -initial-cluster-token raftbase-demo &
go run ./cmd/raftbase -name n2 -data-dir ./data/n2 \
-client-url http://127.0.0.1:2381 -peer-url http://127.0.0.1:2382 \
-api-addr :8081 -initial-cluster "$CLUSTER" -initial-cluster-token raftbase-demo &
go run ./cmd/raftbase -name n3 -data-dir ./data/n3 \
-client-url http://127.0.0.1:2383 -peer-url http://127.0.0.1:2384 \
-api-addr :8082 -initial-cluster "$CLUSTER" -initial-cluster-token raftbase-demo &
curl -s localhost:8080/cluster/nodes | jq # one node "leader", two "follower"Listen vs. advertise URLs. etcd requires its listen URLs (
-client-url,-peer-url) to use an IP host (e.g.127.0.0.1or0.0.0.0) — it will not bind a hostname. The advertise URLs (-advertise-client-url,-advertise-peer-url) are what peers/clients use to reach this member and may be hostnames. On a single host they can be left unset (they default to the listen URLs); across containers/hosts, bind0.0.0.0and advertise a routable hostname. Seedocker-compose.ymlfor a worked example.
make test-e2e builds the image, brings up a 3-node cluster via
docker-compose.yml, runs a build-tagged Go test
(test/e2e/cluster_e2e_test.go) against the live REST APIs, then tears the
stack down. Requires Docker; it is excluded from go test ./... by the e2e
build tag. The suite covers:
- Convergence — all three nodes agree on a single leader.
- Leader failover — stop the leader's container, then assert the two surviving nodes (quorum of three) elect a new, different leader and agree on it. The stopped node is restarted on cleanup.
cmd/raftbase entrypoint: parse flags, start etcd, serve API, handle signals
└── internal/
├── cluster embedded etcd lifecycle + cluster status logic
│ ├── cluster.go Node/Status types, MemberInfo, Inspector seam, classifyNodes()
│ └── etcd.go StartEmbedded(), etcd-backed Inspector, graceful Close()
├── api REST layer (depends only on cluster.Inspector)
│ └── server.go /cluster/nodes and /healthz handlers
└── logging slog setup + zap->slog bridge for etcd's logs
└── logging.go ParseLevel, New (JSON slog), NewZapBridge
The design separates three concerns so each is independently testable:
classifyNodes— a pure function mapping(members, leaderID)to[]Node. Unit-tested with no etcd dependency.Inspector— the interface the HTTP layer depends on. Tests use a fake; production uses the embedded etcd server, which implements it.- Embedded etcd — booted and inspected behind
Inspector, covered by an integration test that runs a real node on ephemeral ports.
Built test-first (Red → Green → Refactor). The integration test boots a real
embedded etcd node, so it is skipped under make test-unit (go test -short)
and included in make test.