This guide covers the developer workflow: the Nix build/test targets, the automated test suite, linting, and protobuf regeneration. For an overview of what the tool does, see the README and the documentation hub.
Everything is driven through a Nix flake; you do not need to install Go, buf, or the linters separately. Enter the dev shell:
nix develop
xtcp2-help # prints the cheat sheet of build / lint / test commandsThe shell puts Go 1.26.5 (pinned in nix/versions.nix, not the host toolchain), buf, golangci-lint, gosec, nixfmt, and the project helper commands (lint-quick, lint, lint-comprehensive, lint-fix, lint-new, regen-protos, xtcp2-help) on your PATH, and sets CGO_ENABLED=0. Those helpers are real binaries, not shell functions, so nix run .#lint-quick works without entering the shell — and because they take relative config paths they exit 2 unless run from the repo root.
With Nix (reproducible, sandboxed):
nix build .#xtcp2 # main daemon
nix build .#xtcp2-all # every cmd/* binary, joined under one bin/
nix build .#oci-xtcp2 # OCI container imagextcp2 has a three-axis build matrix — a build variant (debug / default / stripped), a destination flavor (full / min / kafka / nats / nsq / valkey / s3parquet), and an enrichment flavor (none / asn / locality / enrich). Library destinations are gated behind //go:build dest_<scheme> tags and the two heavyweight enrichers behind //go:build enrich_<feature>, so slim binaries omit code they don't need. The full matrix, with measured sizes, is documented in docs/build-flavors.md.
To build outside Nix, set the build tags for the destinations and enrichers you want:
# Full daemon (all library destinations, all enrichers)
CGO_ENABLED=0 go build -tags "netgo,osusergo,dest_kafka,dest_nats,dest_nsq,dest_valkey,enrich_asn,enrich_locality" \
-ldflags "-s -w" -trimpath -o xtcp2 ./cmd/xtcp2
# Minimal (stdlib destinations only: null/udp/unix/unixgram; no enrichers)
CGO_ENABLED=0 go build -tags "netgo,osusergo" -ldflags "-s -w" -trimpath -o xtcp2-min ./cmd/xtcp2
# Kafka only
CGO_ENABLED=0 go build -tags "netgo,osusergo,dest_kafka" -ldflags "-s -w" -trimpath -o xtcp2-kafka ./cmd/xtcp2
# Kafka plus both enrichers
CGO_ENABLED=0 go build -tags "netgo,osusergo,dest_kafka,enrich_asn,enrich_locality" \
-ldflags "-s -w" -trimpath -o xtcp2-kafka-enrich ./cmd/xtcp2The enricher tags are load-bearing at runtime, not just at link time: asking for -enrichAsn on a
binary built without enrich_asn is a fatal startup error, not a silent no-op. That is deliberate —
a fleet quietly emitting empty ASN columns is the failure mode the tags exist to make visible.
Run nix flake show for the complete, current list. The main groups:
xtcp2,xtcp2-debug,xtcp2-stripped— main daemon, per build variant.xtcp2-min,xtcp2-kafka,xtcp2-nats,xtcp2-nsq,xtcp2-valkey,xtcp2-s3parquet— destination-flavor builds. Each also has-asn,-localityand-enrichvariants (e.g.xtcp2-kafka-enrich) carrying the compile-time-gated enrichers; the unsuffixed name is the no-enricher build. 6 x 4 = 24 cells, generated fromnix/versions.nix. See docs/build-flavors.md.xtcp2-all,xtcp2-all-debug,xtcp2-all-stripped— everycmd/*binary joined under onebin/.xtcp2client,xtcp2_kafka_client,ns,nsTest,register_schema,kafka_to_clickhouse,clickhouse_protobuflist,clickhouse_protobuflist_db,clickhouse_http_insert_protobuflist— the supporting tools.
oci-xtcp2, oci-xtcp2-debug, oci-xtcp2-stripped (fat images with every binary, every destination and every enricher), the 24 slim single-binary daemon images oci-xtcp2-<dest>[-<enrich>] (oci-xtcp2-min, oci-xtcp2-kafka-enrich, oci-xtcp2-s3parquet-asn, ...), the slim client images oci-xtcp2client and oci-xtcp2ctl, oci-ipfeed-collector (the standalone ASN-artifact builder), plus oci-xtcp2-tcp-stress for load testing. nix flake show has the live list.
Boot xtcp2 inside a QEMU microVM against a real kernel and real namespaces: microvm-x86_64 (minimal lifecycle), -coverage, -coverage-iouring, -soak, -tcp-stress, -clickhouse-pipeline, -clickhouse-pipeline-parquet, -s3parquet-pipeline, -s3parquet-runner, -s3parquet-stress, -s3parquet-lowfreq, -capcheck-fail, and -discovery-bench (namespace-discovery A/B benchmark: dir-scan vs /proc-scan on a real kernel). Destination round-trip lifecycles — -lifecycle-valkey, -lifecycle-nats, -lifecycle-nsq, -lifecycle-clickhouse-http, and -lifecycle-{tcp,udp,unix,unixgram}-sink — boot a native in-VM broker / raw-socket receiver / direct HTTP→ClickHouse insert and assert the records arrived. Run the whole suite sequentially with nix run .#integration-all (-- --soak adds the 1h soaks). See Testing and docs/integration-testing.md.
regen-protos— regenerate protobuf code (bufdep update → lint → build → generate).quality-report/update-quality-report— print or refreshdocs/quality-report.md.coverage-merge— merge host + microVM Go coverage profiles.lint-fix-one -- <linter>— auto-fix a single linter at a time (safer thanlint-fix).- The
microvm-x86_64-*runners (lifecycle, soak, tcp-stress, pipelines), several of which accept-- --duration <dur>.
go test ./... # all packages, locally
nix build .#test-go-unit # sandboxed unit runPer-package sandboxed runs are exposed too: test-pkg-xtcp, test-pkg-xtcpnl, test-pkg-io-uring, test-pkg-misc, plus test-cmd-xtcp2, test-cmd-xtcp2client.
go test -bench=. ./pkg/xtcpnl/... # benchmarks
nix build .#test-go-bench
nix build .#test-go-race # race detector (CGO enabled in the sandbox)
nix build .#test-pkg-io-uring # localised race run for pkg/io_uring onlytest-go-race covers every package but is slow. test-pkg-io-uring is the same detector scoped to one package — that is where io_uring's IORING_SETUP_SINGLE_ISSUER contract lives (the kernel rejects submissions from any task other than the ring's creator), so it is the one most likely to be broken by a change. The other per-package targets run without -race to stay fast; the flag is per-package in nix/tests/go-test-per-package.nix.
The destination and enrichment build tags change which code compiles, so coverage is measured per flavor:
nix build .#test-go-flavor-kafka # also: -nats, -nsq, -valkey, -s3parquet
nix build .#test-go-flavor-enrich # also: -asn, -locality, -s3parquet-enrich
nix build .#test-go-flavor-all # every tag at onceThese are deliberately not the full destination × enrichment cross product. The two axes are
orthogonal in the Go code, so the four enrichment cells above cover every compilation outcome that
28 would; s3parquet-enrich earns its place because it is the one combination where parquet-go is
reachable from two directions at once.
nix build .#test-proto-deserialize-golden # decode known-good netlink fixturesThese need KVM (/dev/kvm). They boot a VM, start the daemon, and run a battery of self-tests (systemd up, metrics endpoint, netlink readout, gRPC round-trip, poll/listen record streaming, health probes, namespace add/delete lifecycle, per-namespace traffic, runtime-control reconfigure, and — per flavor — the destination round-trip: raw-socket/broker consume-back, ClickHouse, and S3/Parquet assertions):
nix run .#microvm-x86_64-lifecycle # ~45s smoke test
nix run .#microvm-x86_64-lifecycle-valkey # native-broker consume-back (also -nats / -nsq)
nix run .#microvm-x86_64-lifecycle-clickhouse-http # direct HTTP→ClickHouse ProtobufList insert
nix run .#microvm-x86_64-soak -- --duration 1h # long-running stability
nix run .#microvm-x86_64-tcp-stress -- --duration 180s
nix run .#microvm-x86_64-clickhouse-pipeline # full xtcp2 → redpanda → clickhouse stack
nix run .#microvm-x86_64-discovery-bench -- --timeout 900 # ns-discovery A/B grid
nix run .#integration-all # every lifecycle flavor, sequentiallySee docs/integration-testing.md for the harness internals, flavor descriptions, and troubleshooting.
Three tiers, all configured via .golangci*.yml:
| Command | Tier | Approx. time | When |
|---|---|---|---|
lint-quick |
0 | ~90s | pre-commit |
lint |
1 | ~2min | CI gating |
lint-comprehensive |
2 | ~10min | nightly |
lint-fix |
— | — | apply auto-fixable findings |
lint-new |
— | — | lint only the diff since HEAD~1 |
Which linter sits in which tier is a deliberate choice, not an accident of history. misspell is in Tier 1 (see "Spelling" below) because it only ever caught regressions long after the fact when it ran nightly. prealloc is deliberately left in Tier 2: a single continue anywhere in a file silences every prealloc hint in that file, so it makes a poor gate.
All three configs set issues.max-issues-per-linter: 0 and issues.max-same-issues: 0. The golangci-lint defaults (50 and 3) truncate the report silently, which once made a 35-finding cleanup look like a 19-finding one. If you add a config, set them there too.
The Nix tree is linted as well: nixfmt for layout, plus deadnix (unused bindings and lambda arguments) and statix (antipatterns), both gating in nix flake check. statix's lint scope lives in the repo-root statix.toml; repeated_keys is disabled there with a written reason, and per-site # statix: ignore comments are not used. When fixing a deadnix finding, remember that removing a lambda argument also means removing it from every inherit at the call sites — diff nix flake show --all-systems before and after to prove evaluation still works.
Shell scripts produced by the Nix tree must use pkgs.writeShellApplication, never pkgs.writeShellScript. writeShellApplication runs shellcheck and bash -n at build time and prepends set -o errexit -o nounset -o pipefail; writeShellScript does neither. There is no shellcheck check in nix/checks/, so that build-time run is the only shell linting in this repo — a writeShellScript body is genuinely unlinted. Two consequences worth knowing:
- The result is a package directory with
destination = "/bin/<name>", so systemd references areExecStart = "${theApp}/bin/<name>";. A bare"${theApp}"puts a directory inExecStartand fails at VM runtime, which no build-time check catches. writeShellApplicationprependsruntimeInputstoPATH(inheritPathdefaults totrue); it does not clampPATH. A "command not found" inside one of these scripts is a missingruntimeInputsentry, not a sandbox.
Findings get fixed, not silenced: excludeShellChecks, a relaxed bashOptions, and an overridden checkPhase appear nowhere in the tree, and there is exactly one # shellcheck disable (nix/microvms/mkVm.nix, SC2016 on a deliberately single-quoted bash -c body). Where errexit bites, say why in a comment and use || true, if ! cmd; then, or a narrow set +e region.
Local CI equivalent — runs Tier 0+1 plus the custom audits (netlink-audit, iouring-audit, metrics-audit, proto-field-audit), go-vet, gofmt, gosec, nixfmt, deadnix, statix, per-binary cli-help-smoke-* checks, capability checks, the race test, the per-flavor builds, and the minimal microVM lifecycle:
nix flake checkThe aggregated linter/coverage status is regenerated into docs/quality-report.md with nix run .#update-quality-report (that file is auto-generated — do not hand-edit it).
nix flake check does not currently pass end to end. The known failures — which
tier they are in, whether they are code or environment, and what fixing each one
involves — are tracked in TODO-SOON.md. Check there before
assuming a red check is something you broke.
The schemas live under proto/. All generated code lands in one gen/ tree — Go in gen/go/{xtcp_config,xtcp_flat_record,clickhouse_protolist}/, plus gen/python, gen/dart, gen/cpp, gen/openapi. Generation uses local nix-pinned plugins (fully offline — no buf cloud). Regenerate after editing a .proto:
regen-protos # buf dep update → lint → build → generate (local plugins) → re-sync CH schemas
# or:
nix run .#regen-protosSee docs/protobuf-formats.md for a reference of every schema (config, data export, ClickHouse), the per-language generated outputs, and the buf.validate constraints.
-
Handle every error. The codebase does not use
//nolintsuppressions; lint classes are eliminated structurally rather than silenced. Keep that standard in new code — if a linter complains, fix the cause. -
Spelling: US English.
behavior,serialization,canceled,neighbor,initialization,labeled,honor. This is enforced bymisspellin Tier 1, so a British spelling fails CI on the commit that introduces it — which is the point. The repo swept British→US four times before this was written down, and it regressed every time, becausemisspellonly ran in the nightly tier.Two things the linter cannot see, so they are conventions rather than rules:
misspellskips camelCase/PascalCase tokens unconditionally (identifiers and Prometheus label values likecancelledDuringInitare out of its reach), and it only reads.gofiles (docs/,.nixand.sqlare not checked).proto/headers/sock.cis verbatim Linux kernel source — leave its spelling alone. -
Match the surrounding style: comment density, naming, and idioms of the file you're editing.
-
Keep the low-level netlink machinery (
pkg/xtcpnl) and the type-safe sync wrappers (pkg/xsync) independently testable.