# Homelab Horizon Build Makefile BINARY_NAME=homelab-horizon CMD_PATH=./cmd/homelab-horizon VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') LDFLAGS=-ldflags "-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)" # Server builds embed the per-arch hz operator-CLI binaries (served at # /admin/hz/*) behind the 'hzembed' tag; the hz-embed target cross-compiles them # into this dir first. A plain `go build`/CI omits the tag and needs no binaries. SERVER_TAGS=hzembed,uiembed HZ_EMBED_DIR=internal/server/hzbin/bin # The admin UI is compiled in for release builds, so a release is one file that # renders its own login page. ui-embed stages ui/dist here first; a plain # `go build`/CI omits the tag and needs no staged assets. UI_EMBED_DIR=internal/server/uiembed/dist # Default target .PHONY: all all: build # Generate TypeScript types from Go structs (requires tygo) .PHONY: generate generate: go run github.com/iodesystems/tygo@v0.2.21-iode.1 generate # Build frontend (React SPA) .PHONY: ui ui: generate cd ui && pnpm install --frozen-lockfile && pnpm build # Create stub ui/dist for Go-only builds (no node toolchain required) ui/dist/index.html: mkdir -p ui/dist echo 'Run make ui to build the frontend.' > ui/dist/index.html # Build for current platform (includes frontend) .PHONY: build build: ui hz-embed ui-embed CGO_ENABLED=0 go build -tags $(SERVER_TAGS) $(LDFLAGS) -o $(BINARY_NAME) $(CMD_PATH) # Build Go only (uses stub frontend if ui/dist doesn't exist) .PHONY: build-go build-go: ui/dist/index.html hz-embed ui-embed CGO_ENABLED=0 go build -tags $(SERVER_TAGS) $(LDFLAGS) -o $(BINARY_NAME) $(CMD_PATH) # Build the hz operator CLI (admin-token client: service CRUD + sync + setup) .PHONY: build-hz build-hz: CGO_ENABLED=0 go build $(LDFLAGS) -o hz ./cmd/hz # Build hz-probe, the outside-in vantage agent. It runs on a host outside the # homelab, so the useful build is a cross-compile you scp to that host. .PHONY: build-probe build-probe: CGO_ENABLED=0 go build $(LDFLAGS) -o hz-probe ./cmd/hz-probe .PHONY: build-probe-all build-probe-all: dist CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/hz-probe-linux-amd64 ./cmd/hz-probe CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/hz-probe-linux-arm64 ./cmd/hz-probe CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build $(LDFLAGS) -o dist/hz-probe-linux-armv7 ./cmd/hz-probe # Cross-compile the client binaries the server embeds: hz (served at # /admin/hz/bin/) and hz-probe (served at /admin/hz-probe/bin/). Always builds # all served arches regardless of the server's own arch. .PHONY: ui-embed ui-embed: ui/dist/index.html @rm -rf $(UI_EMBED_DIR) @mkdir -p $(UI_EMBED_DIR) @cp -r ui/dist/. $(UI_EMBED_DIR)/ @du -sh $(UI_EMBED_DIR) | sed 's/^/ ui embedded: /' .PHONY: hz-embed hz-embed: @rm -rf $(HZ_EMBED_DIR) @mkdir -p $(HZ_EMBED_DIR) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(HZ_EMBED_DIR)/hz-linux-amd64 ./cmd/hz CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(HZ_EMBED_DIR)/hz-linux-arm64 ./cmd/hz CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build $(LDFLAGS) -o $(HZ_EMBED_DIR)/hz-linux-arm ./cmd/hz CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(HZ_EMBED_DIR)/hz-probe-linux-amd64 ./cmd/hz-probe CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(HZ_EMBED_DIR)/hz-probe-linux-arm64 ./cmd/hz-probe CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build $(LDFLAGS) -o $(HZ_EMBED_DIR)/hz-probe-linux-arm ./cmd/hz-probe # Run backend + frontend dev servers together (Ctrl-C stops both) .PHONY: run run: ui/node_modules @trap 'kill 0' EXIT; \ cd ui && pnpm dev & \ go run $(CMD_PATH) & \ wait # Run Go backend only (builds frontend first, serves at /app/) .PHONY: run-backend run-backend: ui go run $(CMD_PATH) # Run Vite frontend dev server only (proxies API to :8080) .PHONY: run-frontend run-frontend: ui/node_modules cd ui && pnpm dev # Install frontend dependencies if needed ui/node_modules: ui/package.json cd ui && pnpm install @touch ui/node_modules # Build for all platforms .PHONY: build-all build-all: ui build-linux-amd64 build-linux-arm64 build-linux-arm # Linux AMD64 (most servers, x86_64) .PHONY: build-linux-amd64 build-linux-amd64: ui dist hz-embed ui-embed CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags $(SERVER_TAGS) $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-amd64 $(CMD_PATH) # Linux ARM64 (Raspberry Pi 4/5, modern ARM servers) .PHONY: build-linux-arm64 build-linux-arm64: ui dist hz-embed ui-embed CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags $(SERVER_TAGS) $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-arm64 $(CMD_PATH) # Linux ARM (Raspberry Pi 2/3, older 32-bit ARM) .PHONY: build-linux-arm build-linux-arm: ui dist hz-embed ui-embed CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -tags $(SERVER_TAGS) $(LDFLAGS) -o dist/$(BINARY_NAME)-linux-armv7 $(CMD_PATH) # Clean build artifacts .PHONY: clean clean: rm -f $(BINARY_NAME) hz hz-probe rm -rf dist/ rm -rf ui/dist/ rm -rf $(HZ_EMBED_DIR) # Run tests .PHONY: test test: go test -v ./... # Run unit tests only .PHONY: test-unit test-unit: go test -v ./internal/... # Run integration tests only .PHONY: test-integration test-integration: go test -v ./test/integration/... # Run tests with coverage .PHONY: test-coverage test-coverage: go test -v -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html # Lint (canon X-LINT-1) — install once: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 .PHONY: lint lint: golangci-lint run ./... # Check/lint — gofmt-clean check (no mutation) + vet + golangci-lint .PHONY: check check: check-generated @test -z "$$(gofmt -l . )" || { echo "gofmt needed:"; gofmt -l .; exit 1; } go vet ./... golangci-lint run ./... # The generated TypeScript must be BOTH current and valid, and nothing checked either. # # tygo emitted `export const X = any /* configmgr.Y */` for eleven cross-package constant # aliases — `any` is a type, not a value — and the committed .ts simply went stale instead, # so the breakage sat waiting for whoever next ran `make generate`. Regenerating and # diffing catches staleness; tsc catches output that parses as Go but not as TypeScript. .PHONY: check-generated check-generated: @set -e; \ tmp=$$(mktemp); \ trap 'rm -f "$$tmp"' EXIT; \ cp ui/src/api/generated-types.ts "$$tmp"; \ go run github.com/iodesystems/tygo@v0.2.21-iode.1 generate >/dev/null; \ if ! diff -q "$$tmp" ui/src/api/generated-types.ts >/dev/null; then \ cp "$$tmp" ui/src/api/generated-types.ts; \ echo "generated-types.ts is STALE — run 'make generate' and commit the result"; exit 1; \ fi; \ cd ui && npx --no-install tsc --noEmit --skipLibCheck --ignoreConfig src/api/generated-types.ts \ || { echo "generated-types.ts is not valid TypeScript — see the errors above"; exit 1; }; \ echo "generated types: current and valid" # Run all checks .PHONY: test-all test-all: test-unit test-integration check # Create dist directory dist: mkdir -p dist # DEPLOY-9: assemble a deployable payload — the binary plus the frontend it now # serves from disk (WEB-5). One tree to copy, so the two cannot arrive # separately: a binary without its UI answers the API and shows an explanatory # page where the login form should be. # # PAYLOAD_ARCH selects the binary; defaults to this machine's build. PAYLOAD_ARCH ?= .PHONY: payload payload: ui @rm -rf dist/payload @mkdir -p dist/payload/public @if [ -n "$(PAYLOAD_ARCH)" ]; then \ $(MAKE) build-$(PAYLOAD_ARCH) >/dev/null; \ cp dist/$(BINARY_NAME)-$(PAYLOAD_ARCH) dist/payload/$(BINARY_NAME); \ else \ $(MAKE) build >/dev/null; \ cp $(BINARY_NAME) dist/payload/$(BINARY_NAME); \ fi cp -r ui/dist/. dist/payload/public/ @echo "payload: dist/payload (binary + public/)" @du -sh dist/payload/public | sed 's/^/ ui: /' # Build release archives .PHONY: release # One file per archive. The UI is compiled into the binary (SERVER_TAGS # includes uiembed), so there is no second artifact to install correctly and no # way to land a gateway whose admin page cannot render. release: clean dist ui build-all @echo "Creating release archives..." @for arch in linux-amd64 linux-arm64 linux-armv7; do \ tar -czf dist/$(BINARY_NAME)-$$arch.tar.gz -C dist $(BINARY_NAME)-$$arch; \ done @echo "Release archives created in dist/" @ls -la dist/*.tar.gz # Install locally (requires sudo) .PHONY: install install: build sudo cp $(BINARY_NAME) /usr/local/bin/ @echo "Installed to /usr/local/bin/$(BINARY_NAME)" # Build Docker demo image (vanilla Ubuntu + binary + demo config) .PHONY: docker docker: build-linux-amd64 docker build -t homelab-horizon:demo . # Run Docker demo container .PHONY: docker-run docker-run: docker docker run --rm -p 8080:8080 --name hz-demo homelab-horizon:demo # Regenerate README screenshots in a hermetic Docker container (no real network) .PHONY: screenshots screenshots: ./bin/screenshots # End-to-end VPN MFA jail test in a throwaway multipass VM (real kernel, # real WireGuard/iptables/HAProxy). Needs multipass; takes a few minutes. .PHONY: e2e e2e: ./bin/e2e # Admin account auth: bootstrap, TOTP against oathtool, the second-factor gate, # the lockout guards, and a passkey ceremony against the admin relying party. .PHONY: e2e-auth e2e-auth: ACCOUNT=1 ACCOUNT_PASSKEY=1 OIDC=1 ./bin/e2e .PHONY: help help: @echo "Homelab Horizon Build Targets:" @echo "" @echo " make - Build for current platform (includes frontend)" @echo " make ui - Build frontend only (React SPA)" @echo " make build-go - Build Go only (stub frontend)" @echo " make build-hz - Build the hz operator CLI (admin-token client)" @echo " make build-probe - Build hz-probe (outside-in vantage agent)" @echo " make build-probe-all - Cross-compile hz-probe for amd64/arm64/armv7" @echo " make run - Run backend + frontend dev servers together" @echo " make run-backend - Run Go backend only (:8080)" @echo " make run-frontend - Run Vite frontend dev server only (:5173)" @echo " make build-all - Build for all platforms" @echo " make release - Build all platforms and create .tar.gz archives" @echo "" @echo " make build-linux-amd64 - Build for Linux x86_64" @echo " make build-linux-arm64 - Build for Linux ARM64 (Raspberry Pi 4/5)" @echo " make build-linux-arm - Build for Linux ARMv7 (Raspberry Pi 2/3)" @echo "" @echo " make install - Install to /usr/local/bin (requires sudo)" @echo " make clean - Remove build artifacts" @echo " make test - Run tests" @echo " make check - Run go vet and fmt" @echo "" @echo " make docker - Build Docker demo image" @echo " make docker-run - Build and run Docker demo on :8080" @echo " make screenshots - Regenerate README screenshots (hermetic Docker)" @echo " make payload - Assemble dist/payload (binary + public/) for deploy" @echo " make e2e - End-to-end MFA jail test (multipass VM)" @echo " make e2e-auth - End-to-end admin account auth test: accounts, passkeys, SSO"