# Zoomies # # The Go binary is fully usable without Node. Node is a build-time dependency # only, wrapped by `make ui`, and the built assets are embedded with go:embed. SHELL := /bin/bash .SHELLFLAGS := -eu -o pipefail -c .DEFAULT_GOAL := help # Without the leading v: the tag is v1.2.3, the version the binary reports is # 1.2.3, and the release workflow stamps the images the same way. VERSION ?= $(patsubst v%,%,$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) PKG := github.com/eyupio/zoomies LDFLAGS := -s -w \ -X $(PKG)/internal/version.Version=$(VERSION) \ -X $(PKG)/internal/version.Commit=$(COMMIT) \ -X $(PKG)/internal/version.Date=$(DATE) BIN := zoomies DIST := dist UI_DIR := web UI_OUT := internal/api/webdist RUNNER_VERSION ?= 2.338.2 GO ?= go NPM ?= npm # Package-wide budget, including race instrumentation and SQLite migrations. # Sized for the fleet runner's one-CPU share with -coverpkg, where the API # package alone has taken 38 minutes and the controller package is larger: # a package that hits this limit is hung, not slow. GO_TEST_TIMEOUT ?= 120m # The packages a test target runs. CI splits the suite by package across # runners -- the controller, API and store packages are each a large share of # the whole -- and this is how a shard says which share it is. Local runs # leave it alone and get everything. TEST_PKGS ?= ./... # A -run regexp, for CI to run half of one package on each runner. TEST_RUN ?= # What `make bench` runs: every package that has a benchmark, found rather than # listed so that a new one is not left out of a list that went stale, six runs # of each so that benchstat can say how far apart they were, with the # allocations beside the times. BENCH narrows it by benchmark name (a -bench # regexp, matched a slash-separated part at a time, so BENCH=Decide/quiet works; # leave out any $, which make would read as one of its own), BENCH_PKGS by # package, and BENCH_COUNT sets the runs. go test runs the packages of a -bench # run one at a time, because benchmarks that share the CPU contaminate each # other; leave the machine alone while it works. BENCH ?= . BENCH_COUNT ?= 6 BENCH_PKGS ?= $(sort $(patsubst %/,./%,$(dir $(shell grep -rl --include='*_test.go' '^func Benchmark' cmd internal)))) ##@ Build .PHONY: build build: ui ## Build the binary with the UI embedded CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/zoomies .PHONY: build-nogui build-nogui: $(UI_OUT)/.placeholder ## Build without rebuilding the UI (fast inner loop) CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) ./cmd/zoomies # The placeholder alone, with nothing compiled: what CI needs before go vet, # go test or a scanner can load internal/api. Building the binary to get it # cost a cold compile of every dependency on an ephemeral runner, minutes of # work in a step that only has to write one file. .PHONY: ui-stub ui-stub: $(UI_OUT)/.placeholder ## Write the placeholder UI that go:embed needs, and build nothing $(UI_OUT)/.placeholder: @mkdir -p $(UI_OUT) @# go:embed fails on an empty directory, so keep a placeholder for builds @# that skip the UI. `make ui` overwrites the directory wholesale, and with @# it this marker, so the marker's absence does not mean the UI is absent: @# write the page only when there is none, or a test run after a real build @# would replace the built UI with the placeholder. @test -e $(UI_OUT)/index.html || printf 'Zoomies

The UI was not built. Run make ui.' > $(UI_OUT)/index.html @touch $@ .PHONY: ui ui: ## Build the Svelte UI into the embed directory cd $(UI_DIR) && $(NPM) ci --no-audit --no-fund cd $(UI_DIR) && $(NPM) run build .PHONY: ui-dev ui-dev: | $(UI_DIR)/node_modules ## Run the Vite dev server against a controller on :8080 cd $(UI_DIR) && $(NPM) run dev .PHONY: dist dist: ui ## Cross-compile release binaries into dist/ @mkdir -p $(DIST) @for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do \ os=$${platform%/*}; arch=$${platform#*/}; \ case $$os in windows) ext=.exe ;; *) ext= ;; esac; \ echo " building $$os/$$arch"; \ CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -trimpath \ -ldflags "$(LDFLAGS)" -o $(DIST)/$(BIN)_$${os}_$${arch}$$ext ./cmd/zoomies; \ done cd $(DIST) && sha256sum $(BIN)_* > checksums.txt @echo " wrote $(DIST)/checksums.txt" ##@ Test .PHONY: test test: | $(UI_OUT)/.placeholder ## Run Go unit and integration tests $(GO) test -race -count=1 -timeout $(GO_TEST_TIMEOUT) $(if $(TEST_RUN),-run '$(TEST_RUN)') $(TEST_PKGS) .PHONY: test-short test-short: | $(UI_OUT)/.placeholder ## Run only fast tests $(GO) test -short -count=1 ./... .PHONY: bench bench: | $(UI_OUT)/.placeholder ## Run the benchmarks, six runs each (BENCH, BENCH_PKGS, BENCH_COUNT narrow it); compare two runs with benchstat $(GO) test -run '^$$' -bench '$(BENCH)' -benchmem -count=$(BENCH_COUNT) -timeout $(GO_TEST_TIMEOUT) $(BENCH_PKGS) .PHONY: cover cover: | $(UI_OUT)/.placeholder ## Run tests with a coverage report # -coverpkg is what makes the number honest. Without it a package is only # credited for the lines its own _test.go files run, so code exercised # through the layer above it -- the controller's migration service, driven # entirely by internal/api's handler tests -- reports zero and sends # whoever reads the dashboard to write tests for the best-covered file in # the tree. $(GO) test -race -count=1 -timeout $(GO_TEST_TIMEOUT) -coverpkg=./... -coverprofile=coverage.out -covermode=atomic $(TEST_PKGS) $(GO) tool cover -func=coverage.out | tail -1 # One shard of the coverage run, in the binary format `go tool covdata` merges. # A text profile cannot be joined to another one line by line -- the same block # appears in each with its own count -- so a shard leaves its counters in # COVERDIR and `cover-merge` turns the set into the one coverage.out that # `make cover` writes directly. .PHONY: cover-shard cover-shard: | $(UI_OUT)/.placeholder ## Run one shard of the coverage run into COVERDIR (see cover-merge) @test -n "$(COVERDIR)" || { echo "cover-shard needs COVERDIR="; exit 2; } @mkdir -p $(COVERDIR) $(GO) test -race -count=1 -timeout $(GO_TEST_TIMEOUT) -cover -coverpkg=./... -covermode=atomic $(if $(TEST_RUN),-run '$(TEST_RUN)') $(TEST_PKGS) -args -test.gocoverdir=$(abspath $(COVERDIR)) .PHONY: cover-merge cover-merge: ## Merge every cover-shard directory under COVERDIRS into coverage.out @test -n "$(COVERDIRS)" || { echo "cover-merge needs COVERDIRS=

,,..."; exit 2; } $(GO) tool covdata textfmt -i=$(COVERDIRS) -o coverage.out $(GO) tool cover -func=coverage.out | tail -1 .PHONY: test-unit test-unit: | $(UI_DIR)/node_modules ## Run the UI's unit tests (Node's own runner, no browser, a few seconds) cd $(UI_DIR) && $(NPM) run test:unit .PHONY: test-ui test-ui: ## Run the Playwright suite (builds the binary first) # Built as CI builds it, which is a plain `go build` with no version stamp # and so the "dev" default. A working tree is normally dirty while the UI # is being worked on, and `git describe --dirty` then stamps a version that # is neither a release nor a main build -- so the controller correctly # declines to pin new hosts to a channel that does not exist, and the hosts # spec, which asserts the pin CI produces, fails for no reason anyone can # see from the diff they are testing. $(MAKE) build VERSION=dev cd $(UI_DIR) && $(NPM) run test:e2e .PHONY: test-e2e test-e2e: | $(UI_OUT)/.placeholder ## Docker-based end-to-end test; needs GitHub credentials, skipped without $(GO) test -count=1 -tags e2e -timeout 40m ./test/e2e/... .PHONY: test-eli-eval test-eli-eval: | $(UI_OUT)/.placeholder ## Play Eli's evaluation scenarios against a real provider; needs ELI_EVAL_URL, ELI_EVAL_KEY and ELI_EVAL_MODEL, skipped without $(GO) test -count=1 -v -run 'TestEliScenariosAgainstARealProvider' ./internal/api/ .PHONY: test-e2e-proxmox test-e2e-proxmox: ## Live Proxmox qualification: needs a cluster and ZOOMIES_PROXMOX_*, skipped without. Hours, not minutes -- not in CI. $(GO) test -count=1 -v -tags e2e -timeout 10h ./test/e2e/proxmox/... .PHONY: test-drill test-drill: build-nogui ## Runtime drills: the built binary as a real controller and agent, against a fake GitHub $(GO) test -count=1 -v -tags drill -timeout 20m ./test/drill/... # Both binaries land under dist/, which is already ignored: a fixed directory # rather than a mktemp -d so a failed run leaves the two binaries that produced # it where they can be run again by hand. UPGRADE_DIR := $(DIST)/upgrade .PHONY: test-upgrade test-upgrade: build-nogui ## Install the last published release, then upgrade it in place to this build; then run the update helper against a fake systemctl @mkdir -p $(UPGRADE_DIR)/old $(GO) build -o $(UPGRADE_DIR)/new/zoomies ./cmd/zoomies sh install.sh --no-init --yes --prefix $(UPGRADE_DIR)/old sh test/upgrade/upgrade-check.sh $(UPGRADE_DIR)/old/zoomies $(UPGRADE_DIR)/new/zoomies GO=$(GO) sh test/upgrade/helper-check.sh # The load measurement writes evidence, not an exit code: the figures are # recorded and compared with the last run, never turned into a threshold a # slower runner would fail a pull request on. # Absolute, because `go test` runs in the package's own directory. LOAD_RECORD ?= $(CURDIR)/roadmap/validation/load-$(shell git rev-parse --short HEAD).md .PHONY: measure measure: | $(UI_OUT)/.placeholder ## Build a fleet with a month of history and time the reads a page makes ZOOMIES_LOAD_RECORD=$(LOAD_RECORD) \ $(GO) test -count=1 -v -tags load -timeout 20m ./test/load/... @echo "record: $(LOAD_RECORD)" E2E_RESULTS ?= roadmap/validation/e2e .PHONY: test-e2e-required test-e2e-required: ## The same run, but a missing prerequisite is a failure and every scenario must pass rm -rf $(E2E_RESULTS) ZOOMIES_E2E_REQUIRED=1 ZOOMIES_E2E_RESULTS_DIR=$(E2E_RESULTS) \ $(GO) test -count=1 -v -tags e2e -timeout 40m ./test/e2e/... || true $(GO) run ./test/e2e/verify -dir $(E2E_RESULTS) .PHONY: screenshots screenshots: build ## Recapture docs/screenshots from the real UI (needs Pillow: pip install pillow) cd $(UI_DIR) && node tests/support/screenshots.mjs .PHONY: screenshots-gallery screenshots-gallery: | $(UI_DIR)/node_modules ## Recapture the component gallery in both styles and modes (needs Pillow) cd $(UI_DIR) && node tests/support/gallery-screenshots.mjs # The UI's dependencies, as an order-only prerequisite of anything that checks # the UI. # # Without them `prettier --check` fails with "Cannot find package # 'prettier-plugin-svelte'", which reads like an npm hiccup and is easy to # scroll past -- and the UI is then not checked at all. That is the one check # that matters for a change to .svelte files, and it went unrun twice in a # fresh worktree before anybody noticed. So a missing tree is installed rather # than reported: the failure mode of this step should be slow, not silent. # # Order-only, so an install already there is never redone because something # touched its mtime. $(UI_DIR)/node_modules: cd $(UI_DIR) && $(NPM) ci --no-audit --no-fund ##@ Quality .PHONY: lint lint: | $(UI_DIR)/node_modules $(UI_OUT)/.placeholder ## Vet, format check, and staticcheck when available $(GO) vet ./... @$(MAKE) --no-print-directory vet-tags @out=$$(gofmt -l $$(git ls-files '*.go')); \ if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi @if command -v staticcheck >/dev/null 2>&1; then staticcheck ./...; \ else echo " staticcheck not installed, skipping"; fi cd $(UI_DIR) && $(NPM) run lint # The tagged suites. An ordinary `go vet ./...` never compiles them, which is how # test/e2e sat with a non-test file referring to a symbol only its _test.go half # defined: every gate was green and the package did not build under its own # tag. Vet rather than build, because vet compiles the tests as well and it is # the test halves that carry most of this code. CI runs this target too, and a # test in internal/docs fails when a build tag is used that it does not cover. .PHONY: vet-tags vet-tags: | $(UI_OUT)/.placeholder ## Vet the suites behind a build tag (e2e, drill, load), which a plain go vet never compiles $(GO) vet -tags e2e ./... $(GO) vet -tags drill ./... $(GO) vet -tags load ./... .PHONY: fmt fmt: | $(UI_DIR)/node_modules ## Format Go and UI sources gofmt -w $$(git ls-files '*.go') cd $(UI_DIR) && $(NPM) run format .PHONY: tidy tidy: ## Tidy go.mod $(GO) mod tidy ##@ Run .PHONY: run run: build-nogui ## Run a controller against a local database ./$(BIN) controller --config ./zoomies.yaml .PHONY: dev dev: build-nogui ## Run a controller with auth off, for local UI development ZOOMIES_DISABLE_AUTH=true ZOOMIES_LOG_FORMAT=text ZOOMIES_LOG_LEVEL=debug \ ZOOMIES_DB_PATH=./zoomies.db ./$(BIN) controller .PHONY: gallery gallery: | $(UI_DIR)/node_modules ## Serve the development-only component gallery on :5173/gallery.html @echo "component gallery: http://127.0.0.1:5173/gallery.html?style=mission-control&mode=dark" cd $(UI_DIR) && $(NPM) run dev ##@ Images .PHONY: image image: ## Build the controller image (deploy/Dockerfile, target controller) docker build -f deploy/Dockerfile --target controller \ --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg DATE=$(DATE) \ -t ghcr.io/eyupio/zoomies:$(VERSION) -t ghcr.io/eyupio/zoomies:latest . # The bounded Git worker conflict repair merges in. It is not published yet: no # workflow names the target, so nothing pushes it to GHCR and nothing would # notice if its base image moved. The Dockerfile's last step in the target runs # the worker's own toolchain check, so a build that succeeds is a worker whose # Git can merge. .PHONY: image-gitworker image-gitworker: ## Build the Git worker image (deploy/Dockerfile, target gitworker) docker build -f deploy/Dockerfile --target gitworker \ --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) --build-arg DATE=$(DATE) \ -t ghcr.io/eyupio/zoomies-git:$(VERSION) . .PHONY: image-nixpacks image-nixpacks: ## Build the controller image from source with Nixpacks (needs the nixpacks CLI) @command -v nixpacks >/dev/null 2>&1 || { \ echo "nixpacks is not installed: https://nixpacks.com/docs/install"; exit 1; } nixpacks build . --name ghcr.io/eyupio/zoomies:$(VERSION)-nixpacks # The runner image's variants. Each one is a base image, the package family # deploy/Dockerfile.runner installs with, and the platform it claims to be. # # The rows below are generated from internal/naming's catalogue, which is the # one place an operating system is added or swapped. Do not edit them by hand: # run `make generate` and commit what it writes. RUNNER_IMAGE ?= ghcr.io/eyupio/zoomies-runner RUNNER_VARIANT ?= $(RUNNER_VARIANT_DEFAULT) # zoomies:catalogue-begin RUNNER_VARIANTS := ubuntu-2404 ubuntu-2604 ubuntu-2204 debian-13 debian-12 fedora-42 rocky-9 RUNNER_VARIANT_DEFAULT := ubuntu-2404 # base | family | os | version variant.ubuntu-2404 := ubuntu:24.04@sha256:224a1869083a311ef3f13648a154ba79832fbef6364d31493642ca03082da254 apt ubuntu 24.04 variant.ubuntu-2604 := ubuntu:26.04@sha256:da6fc2be547864451aa253836dd926da33623312df4a9a243e35dc877c378a78 apt ubuntu 26.04 variant.ubuntu-2204 := ubuntu:22.04@sha256:829f6df217bcbae2b371026e81711d1a787c61b2967ad09d015063663ebafbf7 apt ubuntu 22.04 variant.debian-13 := debian:13-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a apt debian 13 variant.debian-12 := debian:12-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 apt debian 12 variant.fedora-42 := fedora:42@sha256:99e203b80b1c3d8f7e161ec10a68fd02b081ef83a3963553e513c82846b97814 dnf fedora 42 variant.rocky-9 := rockylinux/rockylinux:9@sha256:8101994123cf3d0a8fee517bee7f39e555c7d92bd2d9eb3303cc988a0eeed00f dnf rocky 9 # zoomies:catalogue-end # variant-args renders one variant's row as docker build arguments. variant-args = \ --build-arg BASE=$(word 1,$(variant.$(1))) \ --build-arg OS_FAMILY=$(word 2,$(variant.$(1))) \ --build-arg OS_ID=$(word 3,$(variant.$(1))) \ --build-arg OS_VERSION=$(word 4,$(variant.$(1))) .PHONY: check-variant check-variant: @if [ -z "$(variant.$(RUNNER_VARIANT))" ]; then \ echo "unknown RUNNER_VARIANT '$(RUNNER_VARIANT)'; one of: $(RUNNER_VARIANTS)" >&2; \ exit 1; \ fi .PHONY: image-runner image-runner: check-variant ## Build one runner variant for the host architecture (RUNNER_VARIANT=debian-12, RUNNER_TARGET=runner-docker for the Docker one) docker build -f deploy/Dockerfile.runner --target $(or $(RUNNER_TARGET),runner) \ --build-arg RUNNER_VERSION=$(RUNNER_VERSION) \ $(call variant-args,$(RUNNER_VARIANT)) \ -t $(RUNNER_IMAGE):$(RUNNER_VARIANT) . .PHONY: images-runner images-runner: ## Build every runner variant for the host architecture @for v in $(RUNNER_VARIANTS); do \ echo " building $(RUNNER_IMAGE):$$v"; \ $(MAKE) --no-print-directory image-runner RUNNER_VARIANT=$$v; \ done .PHONY: check-image-runner check-image-runner: check-variant ## Check a locally built runner variant has what Zoomies relies on (RUNNER_VARIANT=debian-12 RUNNER_TARGET=runner-docker) RUNNER_VERSION=$(RUNNER_VERSION) test/runner-image/check.sh $(RUNNER_IMAGE):$(RUNNER_VARIANT) \ $(word 3,$(variant.$(RUNNER_VARIANT))) $(word 4,$(variant.$(RUNNER_VARIANT))) $(or $(RUNNER_TARGET),runner) .PHONY: image-runner-multiarch image-runner-multiarch: check-variant ## Build one runner variant for amd64 and arm64 (needs buildx) docker buildx build --platform linux/amd64,linux/arm64 \ -f deploy/Dockerfile.runner --build-arg RUNNER_VERSION=$(RUNNER_VERSION) \ $(call variant-args,$(RUNNER_VARIANT)) \ -t $(RUNNER_IMAGE):$(RUNNER_VARIANT) . .PHONY: images-multiarch images-multiarch: ## Build the controller and every runner variant for amd64 and arm64 (needs buildx) docker buildx build --platform linux/amd64,linux/arm64 \ -f deploy/Dockerfile --target controller \ --build-arg VERSION=$(VERSION) -t ghcr.io/eyupio/zoomies:$(VERSION) . @for v in $(RUNNER_VARIANTS); do \ echo " building $(RUNNER_IMAGE):$$v for amd64 and arm64"; \ $(MAKE) --no-print-directory image-runner-multiarch RUNNER_VARIANT=$$v; \ done ##@ Housekeeping .PHONY: openapi openapi: | $(UI_DIR)/node_modules ## Regenerate both copies of the contract: the embedded Go source and the TypeScript client @# Two files are generated from api/openapi.yaml and a test fails on either @# when it is stale (the embedded copy in internal/api, the client in CI), @# so one command does both. The Go half reads the spec with the standard @# library only and does not need the UI. $(GO) run internal/api/gen_openapi.go cd $(UI_DIR) && $(NPM) run generate:api .PHONY: generate generate: | $(UI_OUT)/.placeholder ## Rewrite everything generated from a table in Go: the image catalogue, the problem and check catalog, the command and MCP references $(GO) run internal/naming/gen_catalogue.go $(GO) run internal/catalog/gen_catalog.go mkdir -p skills/zoomies && $(GO) run ./cmd/zoomies commands --output markdown > skills/zoomies/reference.md $(GO) run ./cmd/zoomies mcp --describe --output markdown > skills/zoomies/mcp-reference.md .PHONY: runner-bases runner-bases: ## Move each runner image's base-image pin to what its tag points at today, and rewrite what is derived from it (needs network) @# Only what the table feeds: not `make generate`, which also restamps the @# problem catalogue and rewrites the skill references, and would put that @# noise into the weekly pull request that runs this. $(GO) run internal/naming/gen_base_digests.go $(GO) run internal/naming/gen_catalogue.go .PHONY: marketplace-lock marketplace-lock: ## Re-resolve deploy/marketplace/images.lock against the registry (needs network) $(GO) run deploy/marketplace/gen_lock.go .PHONY: clean clean: ## Remove build output rm -rf $(BIN) $(DIST) $(UI_OUT) coverage.out rm -rf $(UI_DIR)/node_modules $(UI_DIR)/dist .PHONY: help help: ## Show this help @awk 'BEGIN {FS = ":.*##"; printf "\nZoomies -- off the lead, on the job.\n\nUsage:\n make \033[36m\033[0m\n"} \ /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-18s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)