# ─── 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 frontend 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 frontend also published on APP_PORT and proxying the API # there, 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 proxy backend frontend 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://:$(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 proxy backend frontend mailcatcher # The frontend is an nginx image, so a UI change needs a rebuild. Both the # domain and the overlay set come off the *running* stack rather than from a # file: VITE_API_URL is baked in at build time, so rebuilding under a different # domain — or without the lan overlay a stack was started with — leaves the SPA # calling an API that answers elsewhere. The lan overlay is what publishes the # host port and builds with an empty VITE_API_URL, so dropping it takes a wall # panel reaching http://:$(APP_PORT) off the air entirely and bakes a # name it cannot resolve into the bundle. A published host port is how that # overlay is recognised, because it is the thing only that overlay adds. rebuild-frontend: ## Rebuild and restart the local frontend (after a UI change) @domain=$$(docker inspect fluksio-app 2>/dev/null \ | grep -o 'Host(`app\.[^`]*`)' | head -1 | sed 's/Host(`app\.//;s/`)//'); \ port=$$(docker inspect fluksio-app \ --format '{{range $$p, $$c := .NetworkSettings.Ports}}{{range $$c}}{{.HostPort}} {{end}}{{end}}' \ 2>/dev/null | awk '{print $$1}'); \ 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)} \ $$compose up --build -d frontend 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 fastapi dev fluksio/main.py 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 redis backend frontend # 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 PLAYWRIGHT_API_URL=http://api.$(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 --add-host api.$(TEST_DOMAIN):$$ip \ -e HOST_RESOLVER_RULES="MAP app.$(TEST_DOMAIN) $$ip, MAP api.$(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 "` 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