Files
app/Makefile
T
Melvin StroblandClaude Opus 5.5 5235ac59c2 Assistant confirms a command from the values it set, without a second round
run_turn takes answer_tools: false ends the turn once the tools have
answered. The assistant node uses it and says what was set ("Kitchen light
off, living room light off."), which saves one model round, about 1.1 s on
the P100. A turn without a call still streams the model's own words.

rebuild-frontend keeps the running stack's FRONTEND_HOST, so the token
issuer no longer flips between the LAN address and app.localhost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ljdk7nDaguh9Uhi3zhcSjn
2026-10-20 08:26:14 +02:00

292 lines
16 KiB
Makefile

# ─── Fluksio app Makefile ───
# Convenience targets for development, testing, linting, and deployment.
# The workspace root delegates to these (see ../Makefile).
.PHONY: init env-check dev-utils dev dev-local dev-lan rebuild-frontend up down update install dev-backend dev-frontend \
generate-client sync-example test test-backend test-checks test-stack test-frontend test-webkit soak \
bench-startup bench-engine lint lint-backend lint-frontend format-frontend umami build docs docs-serve clean help
COMPOSE_ROOT := $(CURDIR)
# Explicit project name keeps this stack isolated from the sibling website
# stack (otherwise both default to "docker", the directory their compose
# files live in).
COMPOSE_PROJECT := fluksio-app
# Compose interpolation needs the project-local .env before reading compose.yml.
COMPOSE := docker compose -p $(COMPOSE_PROJECT) --env-file $(COMPOSE_ROOT)/.env
COMPOSE_PROD := $(COMPOSE) -f docker/compose.yml
# Production also runs autoheal, which restarts the backend when its deep
# health check fails. It is profile-gated because it mounts the Docker socket.
COMPOSE_PROD_RUN := $(COMPOSE_PROD) --profile autoheal
# Host port `make dev-lan` publishes the app on.
APP_PORT ?= 8080
COMPOSE_DEV := $(COMPOSE_PROD) -f docker/compose.dev.yml
# Integrated local stack: dev stack wired onto the shared `proxy` network.
COMPOSE_LOCAL := $(COMPOSE_DEV) -f docker/compose.local.yml
# Same stack, with the app also published on APP_PORT, for clients that cannot
# resolve app.$(DOMAIN).
COMPOSE_LAN := $(COMPOSE_LOCAL) -f docker/compose.lan.yml
# The frontend suite's own stack, a project of its own behind the local Traefik
# (compose.test.yml). The hostname is fixed, never read from a file or the
# command line, so the suite can only ever reach this stack.
TEST_DOMAIN := test.localhost
COMPOSE_TEST := DOMAIN=$(TEST_DOMAIN) STACK_NAME=fluksio-test TAG=test \
docker compose -p fluksio-test --env-file $(COMPOSE_ROOT)/.env -f docker/compose.yml \
-f docker/compose.dev.yml -f docker/compose.local.yml -f docker/compose.test.yml
help: ## Show available targets
@awk 'BEGIN{FS=":.*?## "} /^[a-zA-Z_-]+:.*?##/ {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# ── Development (Docker) ──────────────────────────────────────────
# Hostname the local stack is served under. Never .env's DOMAIN: a checkout
# configured for a deployment carries that deployment's domain, and a local
# stack started under it answers to the same names the live instance does.
# Target-specific on purpose — an exported DOMAIN outranks --env-file in
# compose interpolation, which would put the *production* targets on localhost.
# `make dev DOMAIN=fluksio.com` still wins.
dev dev-utils dev-local dev-lan rebuild-frontend: export DOMAIN ?= localhost
# A key the template lists and the compose files default, missing from .env,
# falls back without a word. For TZ that means every cron runs on UTC. Only a
# warning, so a start never fails over it. `update` runs it after its pull.
dev dev-local dev-lan up: env-check
env-check:
@missing=$$(grep -ohE '\$$\{[A-Z_]+:?-' docker/compose*.yml | sed 's/^..//; s/:\{0,1\}-$$//' | sort -u \
| while read -r k; do \
grep -q "^$$k=" .env.example && ! grep -q "^$$k=" .env && printf ' %s' "$$k"; \
done); \
[ -z "$$missing" ] || echo " ! .env lacks$$missing, so compose uses its defaults. See .env.example."
# First-time bootstrap. Idempotent: only secrets still set to `changethis` are
# generated, so an existing .env keeps its values.
init: ## First-time setup (.env + secrets + docker network)
@[ -f .env ] || { cp .env.example .env && echo " + .env created from .env.example"; }
@for k in SECRET_KEY POSTGRES_PASSWORD FIRST_SUPERUSER_PASSWORD; do \
if grep -q "^$$k=changethis$$" .env; then \
sed -i "s/^$$k=changethis$$/$$k=$$(openssl rand -hex 16)/" .env && echo " + $$k generated"; \
fi; \
done
@docker network inspect proxy >/dev/null 2>&1 || docker network create proxy >/dev/null
@echo ""
@echo " Admin login: $$(sed -n 's/^FIRST_SUPERUSER=//p' .env) / $$(sed -n 's/^FIRST_SUPERUSER_PASSWORD=//p' .env)"
@echo " Next: 'make dev-local', then open http://app.localhost"
dev-utils: ## Start only the utility containers (proxy, mailcatcher)
$(COMPOSE_DEV) up --build proxy mailcatcher
dev: ## Start the full dev stack (includes a local Traefik proxy)
$(COMPOSE_DEV) up --build
dev-local: ## Start the local stack at http://app.localhost (also run by the root `make dev`)
DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} \
$(COMPOSE_LOCAL) up --build -d --remove-orphans proxy backend docs mailcatcher
# The host's LAN address is the source of its default route. Without `ip`
# (macOS) it stays empty and the instance keeps calling itself app.$(DOMAIN).
dev-lan: ## Same, plus the app on http://<host-ip>:$(APP_PORT) (no DNS needed)
@ip=$$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p'); \
DOMAIN=$${DOMAIN:-localhost} ENVIRONMENT=$${ENVIRONMENT:-local} APP_PORT=$(APP_PORT) \
LAN_URL=$${ip:+http://$$ip:$(APP_PORT)} \
$(COMPOSE_LAN) up --build -d --remove-orphans proxy backend mailcatcher
# The dashboard is built into the app image, so a UI change needs a rebuild;
# only the image's frontend stage runs again. The domain and the overlay set
# come off the *running* stack rather than from a file, so the rebuilt
# container keeps the hostname and, on a LAN stack, the published port it was
# started with. A published host port is how the lan overlay is recognised,
# because it is the thing only that overlay adds. Its address is kept too,
# since it is the issuer worker and agent tokens were signed for.
rebuild-frontend: ## Rebuild the app image and restart it (after a UI change)
@domain=$$(docker inspect fluksio-api 2>/dev/null \
| grep -o 'Host(`app\.[^`]*`)' | head -1 | sed 's/Host(`app\.//;s/`)//'); \
port=$$(docker inspect fluksio-api \
--format '{{range $$p, $$c := .NetworkSettings.Ports}}{{range $$c}}{{.HostPort}} {{end}}{{end}}' \
2>/dev/null | awk '{print $$1}'); \
lan=$$(docker inspect fluksio-api --format '{{range .Config.Env}}{{println .}}{{end}}' \
2>/dev/null | sed -n 's/^FRONTEND_HOST=//p'); \
if [ -n "$$port" ]; then \
echo " lan stack detected (published on $$port) — keeping compose.lan.yml"; \
compose="$(COMPOSE_LAN)"; \
else \
compose="$(COMPOSE_LOCAL)"; \
fi; \
DOMAIN=$${domain:-$$DOMAIN} ENVIRONMENT=$${ENVIRONMENT:-local} \
APP_PORT=$${port:-$(APP_PORT)} LAN_URL=$$lan \
$$compose up --build -d backend
up: ## Start the production stack
$(COMPOSE_PROD_RUN) up --build -d
umami: ## Provision + start the optional Umami site-analytics service (idempotent)
# Guard: the dashboard is internet-facing on analytics.$(DOMAIN); refuse to
# start when its session-signing secret is unset or still the placeholder.
@secret=$$(grep -E '^UMAMI_APP_SECRET=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
if [ -z "$$secret" ] || [ "$$secret" = "changethis" ]; then \
echo "ERROR: UMAMI_APP_SECRET is empty or 'changethis' in .env — run the workspace root's scripts/setup.sh (or set it: openssl rand -hex 32) before 'make umami'." >&2; \
exit 1; \
fi
# --no-recreate: reuse an already-running db instead of recreating it under
# the prod compose set, which would blip every service that depends on it.
$(COMPOSE_PROD) up -d --wait --no-recreate db
# Least-privilege role owning only the umami database, so the third-party
# image never holds the shared superuser credentials.
@pguser=$$(grep -E '^POSTGRES_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); pguser=$${pguser:-postgres}; \
role=$$(grep -E '^UMAMI_DB_USER=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); role=$${role:-umami}; \
pw=$$(grep -E '^UMAMI_DB_PASSWORD=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); \
db=$$(grep -E '^UMAMI_DB=' $(COMPOSE_ROOT)/.env 2>/dev/null | head -1 | cut -d= -f2-); db=$${db:-umami}; \
if [ -z "$$pw" ] || [ "$$pw" = "changethis" ]; then \
echo "ERROR: UMAMI_DB_PASSWORD is empty or 'changethis' in .env — run the workspace root's scripts/setup.sh before 'make umami'." >&2; \
exit 1; \
fi; \
$(COMPOSE_PROD) exec -T db psql -v ON_ERROR_STOP=1 -U "$$pguser" -d postgres \
-v role="$$role" -v pw="$$pw" -v db="$$db" < docker/provision-umami-db.sql
# --no-deps: only (re)create umami, never restart the shared db underneath it.
$(COMPOSE_PROD) --profile analytics up -d --no-deps umami
update: ## Pull, rebuild using the layer cache, and recreate changed containers
git pull
@$(MAKE) --no-print-directory env-check
$(COMPOSE_PROD_RUN) build
$(COMPOSE_PROD_RUN) up -d --remove-orphans
docker image prune -f
down: ## Stop all running containers
-$(COMPOSE_LOCAL) down
-$(COMPOSE_TEST) down
-$(COMPOSE_PROD_RUN) down
# ── Development (local, no Docker) ───────────────────────────────
# Run `make dev-backend` and `make dev-frontend` in two separate terminals.
install: ## Install all dependencies (backend + frontend)
# Every extra: the suite exercises the connectors that moved into
# `fluksio[server]`, and strict mypy checks their call sites.
cd backend && uv sync --all-extras
cd frontend && bun install
dev-backend: ## Start the FastAPI backend with hot-reload (local)
cd backend && uv run --all-extras uvicorn fluksio.main:app --reload --ws-per-message-deflate false
dev-frontend: ## Start the Vite dev server (local)
cd frontend && bun dev
generate-client: ## Regenerate the frontend SDK from the backend's OpenAPI schema
bash scripts/generate-client.sh
sync-example: ## Upload `examples/myresearch` the way a data scientist would
# The same command the docs give, against whichever engine `fluksio login`
# last talked to. The nodes import the package from this checkout, so the
# engine has to be one that can see this path.
cd backend && uv run fluksio sync ../examples/myresearch
# ── Testing ───────────────────────────────────────────────────────
test: test-backend test-checks test-frontend ## Run all tests (backend + checks + frontend)
# The frontend has no unit-test runner, so a pure helper is checked by a
# `*.check.ts` script of plain `node:assert` that runs itself and needs no
# stack. This is the target that runs every one of them.
test-checks: ## Run the frontend's *.check.ts scripts
cd frontend && bun run check
test-backend: ## Run backend tests (pytest + coverage)
# Its own SQLite file in a temp directory (tests/__init__.py), so this needs
# nothing running and touches no development data.
cd backend && uv run --all-extras bash scripts/test.sh
PW_VERSION = $(shell sed -n 's/.*"@playwright\/test": "[^0-9]*\([0-9.]*\)".*/\1/p' frontend/package.json | head -1)
# The suite creates and deletes flows, dashboards and users, so it runs against
# a stack of its own, never the dev instance. `up` only recreates what changed,
# and the stack is left running for the next run (and for a look at a failure
# on http://app.test.localhost). `make down` stops it. Its volumes are
# expendable: `docker compose -p fluksio-test down -v` starts it over.
test-stack: ## Start or update the frontend suite's own stack at http://app.test.localhost
@docker ps -q -f name='^fluksio-app-proxy-1$$' | grep -q . \
|| { echo " ✗ the local Traefik is not running, 'make dev' first"; exit 1; }
$(COMPOSE_TEST) up --build -d --wait --remove-orphans redis backend
# The Playwright container, minus its network. tests/guard.ts refuses a public
# address on top of the fixed hostname.
PW_RUN = docker run --rm --ipc=host --user $$(id -u):$$(id -g) -e HOME=/tmp \
-v $(COMPOSE_ROOT):/app -w /app/frontend \
-e PLAYWRIGHT_BASE_URL=http://app.$(TEST_DOMAIN) \
-e CI=$${CI:-1} \
-e FIRST_SUPERUSER="$$(sed -n 's/^FIRST_SUPERUSER=//p' $(COMPOSE_ROOT)/.env | head -1)" \
-e FIRST_SUPERUSER_PASSWORD="$$(sed -n 's/^FIRST_SUPERUSER_PASSWORD=//p' $(COMPOSE_ROOT)/.env | head -1)"
PW_IMAGE = mcr.microsoft.com/playwright:v$(PW_VERSION)-noble
# The names are mapped onto the local Traefik by address, so DNS never decides.
# Twice, because Chromium pins *.localhost to loopback whatever /etc/hosts says.
test-frontend: test-stack ## Run frontend tests (Playwright e2e) against the test stack
@ip=$$(docker network inspect proxy \
--format '{{range .Containers}}{{if eq .Name "fluksio-app-proxy-1"}}{{.IPv4Address}}{{end}}{{end}}' \
2>/dev/null | cut -d/ -f1); \
[ -n "$$ip" ] || { echo " ✗ the local Traefik is not on the proxy network"; exit 1; }; \
$(PW_RUN) --network proxy \
--add-host app.$(TEST_DOMAIN):$$ip \
-e HOST_RESOLVER_RULES="MAP app.$(TEST_DOMAIN) $$ip" \
$(PW_IMAGE) npx playwright test $(PLAYWRIGHT_ARGS)
# WebKit takes *.localhost to loopback through GLib and ignores /etc/hosts, so
# this run shares Traefik's network namespace, where loopback *is* Traefik.
# Only the drag specs, which is where the engines differ.
test-webkit: test-stack ## Run the drag specs in WebKit against the test stack
@$(PW_RUN) --network container:fluksio-app-proxy-1 -e PLAYWRIGHT_WEBKIT=1 \
$(PW_IMAGE) npx playwright test --project=webkit $(PLAYWRIGHT_ARGS)
# Load and chaos against a *running* stack, never part of `make test`: it
# restarts this stack's containers. `SOAK_ARGS="--dry-run"` only looks.
soak: ## Run the soak/chaos harness (SOAK_ARGS="--minutes 30 --scenario redis --force")
cd backend && uv run python scripts/soak.py $(SOAK_ARGS)
bench-startup: ## Time submitting a run (BENCH_ARGS="--kedro ../some/kedro/project")
cd backend && uv run python scripts/bench_startup.py $(BENCH_ARGS)
# In-process, so it needs no stack: what a message costs the engine in work
# and in time. `BENCH_ARGS="--redis <host>"` measures it against a real Redis,
# which is where the round trips are.
bench-engine: ## Engine throughput and per-message cost (BENCH_ARGS="--redis localhost")
cd backend && uv run python scripts/bench_engine.py $(BENCH_ARGS)
# ── Linting ───────────────────────────────────────────────────────
build: ## Build the fluksio and fluksio-worker wheels into dist/
uv build --all-packages --out-dir dist
lint: lint-backend lint-frontend ## Run all linters
lint-backend: ## Lint backend with ruff + mypy
cd backend && uv run --all-extras ruff check .
cd backend && uv run --all-extras ruff format --check .
cd backend && uv run --all-extras mypy fluksio
cd worker && uv run --no-project --with mypy mypy fluksio_worker
lint-frontend: ## Lint frontend with biome
cd frontend && bun run lint
format-frontend: ## Apply biome's fixes to the frontend (what `lint-frontend` only reports)
cd frontend && bun run format
# ── Documentation ─────────────────────────────────────────────────
# The public site on docs.${DOMAIN}, served by the `docs` service in
# docker/compose.yml. These targets are for local authoring: zensical runs on
# demand via uvx, so it never touches the backend environment. Pinned to the
# version docker/Dockerfile.docs and .gitea/workflows/docs.yml ship, so local
# authoring builds with what docs.fluksio.com actually gets.
ZENSICAL := zensical==0.0.46
docs: ## Build the documentation site into ./site
uvx $(ZENSICAL) build --clean
docs-serve: ## Serve the documentation site locally with live reload
uvx $(ZENSICAL) serve
# ── Cleanup ───────────────────────────────────────────────────────
clean: ## Remove build artifacts and caches
rm -rf frontend/dist frontend/blob-report frontend/test-results
rm -rf backend/.pytest_cache backend/htmlcov site \
|| echo " ! not all removed (htmlcov/ root-owned? sudo rm -rf backend/htmlcov)" >&2
find backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true