Makefile
Ref: Size: 20.5 KiB History
BIN := bin
WEB_DIST := internal/server/web/dist
# Pinned so local and CI lint identically. Bump deliberately.
GOLANGCI_VERSION := v2.13.0
GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint
# Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately.
DEADCODE_VERSION := v0.48.0
# Pinned vulnerability scanner (golang.org/x/vuln/cmd/govulncheck). Bump
# deliberately — and note the floor is real: v1.1.4 and older crash under
# Go 1.27's module graph.
GOVULN_VERSION := v1.7.0
GOVULN := $(shell go env GOPATH)/bin/govulncheck
# Style/complexity linters run informationally (exit 0); promote into
# .golangci.yml's enable list once a linter's baseline is clean.
LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret
# Pinned mutation tester (github.com/go-gremlins/gremlins). Bump deliberately.
# Shared by `proto` and `proto-check` so the two can never generate differently.
PROTOC_OPTS := --go_opt=module=github.com/a73x/eitri proto/eitri/v1/sync.proto
GREMLINS_VERSION := v0.6.0
GREMLINS := $(shell go env GOPATH)/bin/gremlins
GREMLINS_WORKERS ?= 2
GREMLINS_TIMEOUT ?= 10
MUTATE_BASE ?= origin/main
.PHONY: build build-go build-darwin web test vet proto clean \
lint lint-extra arch cover fmt fmt-check tidy-check proto-check shape shape-check api api-check \
site site-check ci deadcode web-test web-check vuln vuln-tool scan-image \
deploy release ship hooks site-image server-image backup-image \
mutate mutate-diff mutate-report gremlins-tool
# Enable the repo's client-side merge gate: point git at .githooks, whose
# pre-push hook runs `make ci` before any push that updates main. Run once per
# clone. Bypass a single push with `git push --no-verify`.
hooks:
git config core.hooksPath .githooks
@echo "git hooks enabled -> .githooks (pre-push runs 'make ci' on pushes to main)"
# Build the SvelteKit SPA and stage it into the Go embed dir. Requires Node.
# `go build` works without this (the server serves a "UI not built" notice until
# the SPA is staged here).
web:
cd web && npm ci && npm run build
find $(WEB_DIST) -mindepth 1 ! -name .gitkeep -delete
cp -r web/build/. $(WEB_DIST)/
# Version stamp baked into every binary (internal/version.Version).
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS := -ldflags "-X github.com/a73x/eitri/internal/version.Version=$(VERSION)"
build: web
go build $(GO_LDFLAGS) -o $(BIN)/eitri-server ./cmd/eitri-server
go build $(GO_LDFLAGS) -o $(BIN)/eitri-oidc ./cmd/eitri-oidc
go build $(GO_LDFLAGS) -o $(BIN)/eitri-agent ./cmd/eitri-agent
go build $(GO_LDFLAGS) -o $(BIN)/eitri-smoke ./cmd/eitri-smoke
go build $(GO_LDFLAGS) -o $(BIN)/eitri-site ./cmd/eitri-site
go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri
test:
go test -race -shuffle=on ./...
vet:
go vet ./...
proto:
protoc --go_out=. $(PROTOC_OPTS)
# Roll freshly-built HEAD to the live fleet: local eitri-server + every remote
# eitri-agent (restart-based; running VMs survive the agent bounce). Config from
# $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example.
# The deploy runs a real-VM boot-gate (cmd/eitri-smoke): create a throwaway VM,
# prove it boots under UEFI, then reap it — the fleet's one automated real-VM
# check, which also emits merged server+agent integration coverage.
deploy:
./scripts/deploy.sh
# Cross-compiled release tarballs + checksums + agent-upgrade manifest into
# dist/<version>/ (see scripts/release.sh). Refuses a dirty tree; releases
# that agents can upgrade to must be built from a clean TAGGED tree.
release: web
./scripts/release.sh
# The release pipeline for one plane: build the tagged tree, publish it, roll the
# plane and prove it (see scripts/ship.sh). Run it twice — TARGET=stg with a
# pre-release tag, then TARGET=prod. FROM=<n> resumes at a stage after a partial
# failure; the script's other flags are for direct use.
ship:
@$(if $(and $(TARGET),$(TAG)),,$(error ship: TARGET and TAG are required, e.g. make ship TARGET=stg TAG=v0.0.4-pre.1))
./scripts/ship.sh --target $(TARGET) --tag $(TAG)$(if $(FROM), --from $(FROM))$(if $(SKIP_SMOKE), --skip-smoke)
# --- quality gates -----------------------------------------------------------
# Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests
# shell out to `go list`, so Go's test cache cannot see edges changing elsewhere.
arch:
go test -count=1 ./internal/arch/
# `go install` always writes the same unversioned path, so a file target could
# not tell a stale binary from a current one: bumping GOLANGCI_VERSION left the
# old linter in place, and a linter built by an older Go refuses to analyse a
# newer language version outright. Check what is actually installed instead.
.PHONY: lint-tool
lint-tool:
@$(GOLANGCI) version 2>/dev/null | \
grep -q "version $(GOLANGCI_VERSION:v%=%) built with $(shell go env GOVERSION) " || \
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
# Block tier: fails on any finding (boundaries + correctness). See .golangci.yml.
lint: lint-tool
$(GOLANGCI) run ./...
# Warn tier: complexity/style, reported but never fails the build.
lint-extra: lint-tool
$(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./...
.PHONY: vuln-tool
vuln-tool:
@$(GOVULN) -version 2>/dev/null | grep -q "$(GOVULN_VERSION:v%=%)" || \
go install golang.org/x/vuln/cmd/govulncheck@$(GOVULN_VERSION)
# Known-vulnerability gate over the Go dependency graph.
#
# It is REACHABILITY-based, which is what makes it usable as a gate: an
# advisory in a module we never call the vulnerable symbol of does not fail the
# build, so this stays quiet until something we actually execute is affected —
# and then it names the call site. That is the difference between a gate people
# keep and one they learn to skip.
#
# It covers Go modules only. The OS layer of a published container is a
# different surface with a different scanner; see scan-image.
vuln: vuln-tool
$(GOVULN) ./...
# Scan one already-built image for OS/library advisories (see
# scripts/scan-image.sh). The image publish scripts call this between build and
# push; this target is for scanning something by hand.
# make scan-image IMAGE=registry.example/eitri-site:v0.0.7
scan-image:
./scripts/scan-image.sh $(IMAGE)
.PHONY: gremlins-tool
gremlins-tool:
@go version -m $(GREMLINS) 2>/dev/null | \
grep -qE "^\s+mod\s+github.com/go-gremlins/gremlins\s+$(GREMLINS_VERSION)\s" || \
go install github.com/go-gremlins/gremlins/cmd/gremlins@$(GREMLINS_VERSION)
# Mutation testing: rewrite one operator at a time and see whether the suite
# notices. It answers what coverage cannot — a line can be executed by every
# test and still be asserted by none.
#
# Deliberately NOT part of `ci`: gremlins runs the package's whole test suite
# once per mutant, so cost scales with suite runtime x mutation count. This is a
# tool you point at a package while hardening it, not a gate on every push.
#
# PKG is required. A default of ./... would be the version of this target that
# gets run once and never again.
#
# GREMLINS_WORKERS is a memory bound, not a speed knob. Gremlins copies the
# ENTIRE module root — gitignored artifacts included, ~1.8G here because of
# dist/ and bin/ — once per worker into TMPDIR. On this box TMPDIR is a 31G
# tmpfs and the default worker count is NumCPU: the first run filled RAM and
# wedged the machine. Two workers is ~4G, and tmpfs is what keeps the copies
# fast. Do NOT redirect TMPDIR to a spinning-disk path to "save memory": cold
# compiles in each fresh workdir then outrun the mutant timeout, and timed-out
# mutants are EXCLUDED from the score — internal/server/store read 18 killed /
# 207 timed out / "100% efficacy" on ext4, against 199 killed / 12 timed out /
# 93.43% on tmpfs. A slow disk here does not fail: it flatters.
#
# GREMLINS_TIMEOUT is load-bearing too. Gremlins derives each mutant's timeout
# from the UNMUTATED suite's elapsed time, which for a small package is under a
# second — shorter than the compile the mutant needs in its own workdir.
#
# make mutate PKG=./internal/cloudinit
mutate: gremlins-tool
@test -n "$(PKG)" || { echo "usage: make mutate PKG=./internal/<pkg>"; exit 1; }
$(GREMLINS) unleash \
--workers $(GREMLINS_WORKERS) \
--timeout-coefficient $(GREMLINS_TIMEOUT) \
$(PKG)
# Mutate only the lines this branch changed against MUTATE_BASE. Cheap enough to
# run per branch: the mutant count is the size of the diff, not of the tree.
#
# The trailing "." is not a default, it is a requirement. Gremlins keys its
# diff (git diff --merge-base) by repo-relative path, but names parsed files
# relative to the directory it was pointed at — so `-D main ./internal/foo`
# matches nothing and reports every mutant SKIPPED, which reads exactly like a
# clean run. Only the module root makes the two path forms agree.
mutate-diff: gremlins-tool
$(GREMLINS) unleash \
--workers $(GREMLINS_WORKERS) \
--timeout-coefficient $(GREMLINS_TIMEOUT) \
-D $(MUTATE_BASE) .
# Warn tier, the shape lint-extra uses: run over the diff, report, never fail.
#
# The base is origin/main, not main, so this covers what the push is about to
# publish rather than what the branch has already merged locally — on main
# itself, a diff against main is empty and the check would silently pass.
#
# No threshold yet. Setting one needs a few branches' worth of observed scores
# first: the tree has equivalent mutants that CANNOT be killed (internal/
# cloudinit's `i+1 < len(root.Content)` is the same loop for every even-length
# YAML mapping either way), so a 100% floor would fail honest work.
# The empty-diff guard is not an optimisation. Gremlins treats an EMPTY parsed
# diff as "everything changed" (internal/diff: IsChanged returns true when the
# diff has no entries), so -D against a base that equals HEAD silently becomes a
# whole-tree run — 22 minutes, and it reaches internal/agent/cloudhv, whose
# `if pid != 0` mutant turns into kill(0) and SIGKILLs this make process. Which
# is exactly what a push to main does: it moves origin/main to HEAD, and the
# NEXT ci run has nothing to diff.
#
# setsid for the same reason: a mutant that signals its own process group must
# not be able to reach the build that spawned it.
mutate-report: gremlins-tool
@git diff --quiet --merge-base $(MUTATE_BASE) -- '*.go' 2>/dev/null && \
{ echo "mutate-report: no Go changes against $(MUTATE_BASE) — skipped"; exit 0; } || \
setsid $(GREMLINS) unleash --workers $(GREMLINS_WORKERS) \
--timeout-coefficient $(GREMLINS_TIMEOUT) \
-D $(MUTATE_BASE) . 2>&1 | tail -6 || true
# internal/server/web's floor is measured against the REAL SPA: with only the
# committed placeholder staged, its embed-serving paths never run and the floor
# fails at 89% on a tree with nothing wrong in it (which is every fresh clone
# and every new worktree). Depend on the staged index.html rather than on `web`,
# so a dist that is already there is not rebuilt — `web` runs `npm ci`, which is
# minutes, and `cover` is not the gate that owns the SPA build (web-test and
# web-check are). A STALE dist is likewise not rebuilt, deliberately: this
# prerequisite exists to make the floor measurable, not to keep the SPA current.
$(WEB_DIST)/index.html:
$(MAKE) web
# Per-package coverage ratchet (see scripts/coverage.sh).
cover: $(WEB_DIST)/index.html
./scripts/coverage.sh
# Dependency hygiene: `go mod tidy` must produce no diff.
# gofmt is not one of golangci-lint's enabled linters, so nothing was enforcing
# it: six files had drifted out of canonical form while ci stayed green.
#
# Reformat rather than diff-against-HEAD (the shape/api/tidy pattern), because
# gofmt is a whole-tree property, not a generated artifact — a contributor with
# an unformatted file has nothing committed to compare against. -l lists what
# would change and prints nothing when the tree is clean.
#
# NOTE for whoever fixes a failure here: since Go 1.19 gofmt also reformats DOC
# comments, where `''` and `` are the old ASCII spellings of typographic quotes
# and get rewritten as such. That is fine in prose and wrong in a SQL or shell
# fragment, which stops being valid. Put such fragments in an indented code
# block, which go/doc treats as preformatted and leaves alone. `gofmt -w` is
# safe to run, but read what it did to your comments.
fmt:
gofmt -w ./cmd ./internal
fmt-check:
@out="$$(gofmt -l ./cmd ./internal)"; \
if [ -n "$$out" ]; then \
echo "fmt-check: not gofmt-clean — run 'make fmt':"; echo "$$out"; exit 1; \
fi
tidy-check:
go mod tidy
git diff --exit-code go.mod go.sum
# The committed internal/pb must be what today's proto/eitri/v1/sync.proto
# produces. Generate into a throwaway tree and diff THAT against the committed
# file, which is the actual invariant: regenerating in place and running
# `git diff --exit-code` instead compares the worktree against HEAD, so an
# in-progress .proto edit fails the gate even when its generated output is
# perfectly consistent — the gate refuses a correct tree for being unstaged.
# The `-I` drops the protoc/protoc-gen-go version-stamp comments so the gate
# tracks real code drift rather than the exact toolchain patch version. Skips
# (does not fail) when protoc is unavailable so `make ci` still runs on
# protoc-less machines; CI installs protoc, so the gate is enforced there.
proto-check:
@if ! command -v protoc >/dev/null 2>&1; then \
echo "proto-check: protoc not installed — SKIPPING (enforced in CI)"; \
exit 0; \
fi; \
tmp=$$(mktemp -d) || exit 1; \
trap 'rm -rf "$$tmp"' EXIT; \
protoc --go_out="$$tmp" $(PROTOC_OPTS) || \
{ echo "proto-check: protoc failed on proto/eitri/v1/sync.proto"; exit 1; }; \
diff -ru -I '^//[[:space:]]*protoc' "$$tmp/internal/pb" internal/pb || \
{ echo "proto-check: internal/pb is stale — run 'make proto'"; exit 1; }
# Regenerate the explorable architecture-shape diagram (docs/shape.{json,html}).
shape:
go run ./cmd/eitri-shape
# Merge gate: the committed diagram must match the current package graph.
# Mirrors proto-check — regenerate, then fail on any diff.
shape-check:
go run ./cmd/eitri-shape
git diff --exit-code docs/shape.json docs/shape.html || \
{ echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; }
# Regenerate the API contract artifacts: docs/openapi.json from the route
# table, and the TypeScript types from the spec (needs the web toolchain,
# like `make web`).
api:
go run ./cmd/eitri-apispec
@if [ -x web/node_modules/.bin/openapi-typescript ]; then \
cd web && npm run gen:api; \
else \
echo "api: openapi-typescript not installed (run 'make web') — skipping TS type generation (enforced in CI)"; \
fi
# Merge gate: the committed spec and TS types must match the route table.
api-check:
@$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \
{ echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; }
# Generate the eitri.sh static site into site/dist. Picks up the newest
# dist/<version> (from `make release`) for the downloads page when one
# exists; renders a docs-only preview otherwise.
SITE_DIST = $(shell find dist -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1)
site:
rm -rf site/dist
go run ./cmd/eitri-site -docs docs -site site -out site/dist \
$(if $(SITE_DIST),-dist $(SITE_DIST))
# Merge gate: the site must build from the real docs tree — a broken
# inter-doc link fails here, keeping repo docs and eitri.sh in lockstep.
site-check:
@tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \
go run ./cmd/eitri-site -docs docs -site site -out $$tmp && \
for f in index.html style.css favicon.svg openapi.json docs/index.html docs/quickstart/index.html docs/upgrade/index.html dl/index.html og/home.png og/quickstart.png; do \
test -f $$tmp/$$f || { echo "site-check: missing $$f"; exit 1; }; \
done && echo "site-check: ok"
# Build and push the eitri.sh site image (nginx + site + /dl artifacts).
# Needs SITE_IMAGE in deploy.env.
# Rollout on k8s is manual and stays outside the repo.
#
# SITE_DL_VERSION is the release /dl serves, and defaults to this tree's own
# version. Publishing site copy between releases pins it to the last release —
# `make site-image SITE_DL_VERSION=v0.0.7` — which also skips building one,
# because /dl/latest carries the manifest every agent polls.
SITE_DL_VERSION ?= $(VERSION)
site-image:
@if [ "$(SITE_DL_VERSION)" = "$(VERSION)" ]; then $(MAKE) release; \
else echo "site-image: /dl pinned to $(SITE_DL_VERSION); not building a release"; fi
$(MAKE) site SITE_DIST=dist/$(SITE_DL_VERSION)
SITE_DL_VERSION=$(SITE_DL_VERSION) ./scripts/site-image.sh
# eitri-server production image (arm64) — the hosted control plane.
server-image: web
./scripts/server-image.sh
# The nightly backup job's image: alpine + sqlite, carrying its own tag. Built
# only when the base moves, not per release.
backup-image:
./scripts/backup-image.sh
# Console logic under test (web/src/**/*.test.ts, run by vitest). New logic in
# the SPA lands with tests; markup does not need them.
#
# Skips (does not fail) when the web toolchain is absent, like `make api` —
# `make ci` still runs on a machine with no Node. CI runs `make web` first, so
# the gate is enforced there.
web-test:
@if [ -x web/node_modules/.bin/vitest ]; then \
cd web && npm test; \
else \
echo "web-test: vitest not installed (run 'make web') — SKIPPING (enforced in CI)"; \
fi
# Console types under test (svelte-check over the SPA and its fixtures). This is
# where wire drift surfaces: the api-types.ts the generator writes is the only
# description of the API the console has, and a fixture that still sets a field
# the server stopped sending is a type error and nothing else. vitest compiles
# each module in isolation and will not see it.
#
# `npm run check` runs `svelte-kit sync` first, so a bare worktree with no
# .svelte-kit generates it here rather than failing on missing $$types.
#
# Skips (does not fail) when the web toolchain is absent, like web-test.
web-check:
@if [ -x web/node_modules/.bin/svelte-check ]; then \
cd web && npm run check; \
else \
echo "web-check: svelte-check not installed (run 'make web') — SKIPPING (enforced in CI)"; \
fi
# Whole-program dead-code gate: fails on any function unreachable from a real
# entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what
# catches production code kept alive only by its own tests; the fix is to remove
# it, wire it into a real path, or move it into a _test.go.
#
# The analysis runs once per platform the agent ships on, because dead means
# dead on ALL of them: hostinfo's Darwin parse helpers and the whole vfkit
# backend are unreachable on Linux, the whole cloud-hypervisor stack is
# unreachable on Darwin, and none of it is dead. The two runs are intersected
# — except for build-tagged files, which are compiled in exactly one run and
# are therefore judged by that run alone, so dead code inside hostinfo_linux.go
# still fails.
#
# `go install` into a temp GOBIN rather than `go run`: with GOOS set, `go run`
# would cross-compile the analyzer itself instead of the code under analysis.
deadcode:
@tmp=$$(mktemp -d); trap 'rm -rf $$tmp' EXIT; \
GOBIN=$$tmp go install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION); \
$$tmp/deadcode ./... | sort > $$tmp/linux; \
GOOS=darwin GOARCH=arm64 $$tmp/deadcode ./... | sort > $$tmp/darwin; \
out=$$( { comm -12 $$tmp/linux $$tmp/darwin; \
grep '_linux\.go:' $$tmp/linux; \
grep '_darwin\.go:' $$tmp/darwin; } | sort -u ); \
if [ -n "$$out" ]; then \
echo "deadcode: unreachable from any cmd/ entrypoint on linux/amd64 or darwin/arm64 (remove it, wire it in, or move it to a _test.go):"; \
echo "$$out"; exit 1; \
fi
# The merge gate. Mirrors the required checks in CI. `test` is the authoritative
# race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch`
# re-runs the fitness tests with -count=1 (the race run may serve them cached).
ci: vet build-go build-darwin arch lint fmt-check test cover tidy-check proto-check api-check shape-check deadcode site-check web-test web-check vuln mutate-report
# Compile every Go package (no Node/web build needed — the embed dir ships a
# placeholder, so the server builds and serves a "UI not built" notice).
build-go:
go build ./...
# The second platform the agent ships on. Build tags are confined to hostinfo's
# readers and the wire_*.go files, so a broken Darwin build is invisible to
# every other target in `ci` — this is the only thing that compiles it.
build-darwin:
GOOS=darwin GOARCH=arm64 go build ./...
GOOS=darwin GOARCH=arm64 go vet ./...
clean:
rm -rf $(BIN)