# ========================================================================== # olympus — operator workflow # Usage: make (see `make help`) # ========================================================================== .DEFAULT_GOAL := help SHELL := /bin/bash .PHONY: site-evidence edge-publish edge-preview help setup env-sync env-sync-write doctor up down logs ps check secret-scan secret-scan-history check-commits check-compose factory-doctor factory-trigger dispatch-status lap-record factory-arm factory-disarm app plan new-request builds prune build-runner-install build-runner-check build-runner-list test-runner studio-install studio-dev studio-build studio studio-test studio-check studio-e2e studio-oidc studio-oidc-check studio-token-check studio-token-rotate studio-export-dir studio-build-queue-dir tui docker-build docker-up docker-up-host docker-down docker-down-host docker-logs docker-ps docker-ps-host docker-shell docker-app docker-clean docker-studio vault-bootstrap vault-renew sites-up sites-down site-package site-publish site-unpublish sites-wildcard sites-list site-check app-package app-up app-down app-remove apps-list app-publish gateway-edge-check help: ## Show this help message @echo "olympus — operator workflow" @echo "Usage: make " @grep -E '^[a-zA-Z_0-9./-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' ## ---- Bootstrap ------------------------------------------------------------ setup: ## Preflight, install guard hooks, generate .env secrets bash setup.sh @# Last, so a failed preflight does not leave a half-seeded .env: bring the new @# file level with every key .env.example documents. `setup.sh` does not open @# .env at all, so an upgrade that adds a knob was invisible until somebody @# read the example and noticed — see the header of scripts/env-sync.py. @[ -f .env ] && python3 scripts/env-sync.py --write || true # An absent key and a key set to the documented default behave identically — until # the default changes, and then a deployment that never had the key moves with it # while its operator believes nothing changed. `make check` reports; `env-sync-write` # appends. Neither ever edits, reorders or removes a line already in `.env`. env-sync: ## Report .env keys .env.example documents that .env has never mentioned @if [ ! -f .env ]; then echo "env-sync: no .env — nothing to compare (cp .env.example .env)"; exit 0; fi python3 scripts/env-sync.py $(ARGS) env-sync-write: ## Append those keys to .env, carrying the example's own comments @if [ ! -f .env ]; then echo "env-sync: no .env — create it first (make setup)" >&2; exit 2; fi python3 scripts/env-sync.py --write $(ARGS) doctor: ## Audit readiness (factory doctor + trigger status) python3 factory/doctor.py || true python3 factory/trigger.py --status 2>/dev/null || echo "trigger: not configured (status unavailable until first lap)" ## ---- Factory (autonomy level 0 — manual) --------------------------------- factory-doctor: ## Run factory doctor (readiness + blockers) python3 factory/doctor.py factory-trigger: ## Show scheduler trigger status (expected: NOT_ARMED) python3 factory/trigger.py --status ## ---- Dispatch (autonomy level 1 — earned) ---------------------------------- ## The gate is scripts/factory-dispatch.py: it records the watched lap as ## evidence and is the only thing that arms the loop. `factory-arm` refuses ## while any gate is unclear and names the command that clears it. dispatch-status: ## Report the autonomy gate: level, pin, schedule, blockers python3 scripts/factory-dispatch.py --status lap-record: ## Record a watched lap as evidence (WF= RESULT=pass RUN= WHO=) python3 scripts/factory-dispatch.py --record-lap \ --workflow "$(WF)" --result "$(RESULT)" --run "$(RUN)" --watched-by "$(WHO)" \ $(if $(NOTES),--notes "$(NOTES)",) factory-arm: ## Arm L1 dispatch (WF= [INTERVAL=900]; ARGS="--dry-run" to preview) python3 scripts/factory-dispatch.py --arm $(if $(WF),--workflow "$(WF)",) \ $(if $(INTERVAL),--interval $(INTERVAL),) $(ARGS) factory-disarm: ## Disarm L1 dispatch and stop the timer python3 scripts/factory-dispatch.py --disarm ## ---- App manufacturing (local dev — mirrors olympus-app-builder.yml) ------ app: ## Manufacture app from SPEC (or most recent build-requests/*.md) → ./builds REPLACE=$(REPLACE) bash scripts/manufacture.sh $(if $(SPEC),$(SPEC),) # REPLACE=1 rebuilds over an app this workflow already manufactured. The load node # refuses otherwise, on purpose — the previous ./builds/ is the build you were # comparing against, and a retry of a failed build is precisely the case that has one. new-request: ## Scaffold build-requests/$(NAME).md from factory/APP_SPEC_TEMPLATE.md @if [ -z "$(NAME)" ]; then echo "usage: make new-request NAME=my-app" >&2; exit 2; fi @mkdir -p build-requests @if [ -f "build-requests/$(NAME).md" ]; then echo "already exists: build-requests/$(NAME).md" >&2; exit 1; fi @cp factory/APP_SPEC_TEMPLATE.md "build-requests/$(NAME).md" @echo "created build-requests/$(NAME).md — edit it, then run: make app SPEC=build-requests/$(NAME).md" builds: ## List factory output (./builds, gitignored) @ls -la builds 2>/dev/null || echo "builds/: empty (run make app) — output is gitignored per .gitignore:builds/" # The step `make app` now runs first, runnable on its own. It answers one question — # which stack is this spec going to be built in — and answering it costs one gateway # turn, while getting it wrong costs the whole agent run. `--dry-run` prints the plan # without writing plan.json, which is the way to look before committing to a build. plan: ## Plan a SPEC's stack without building it (SPEC=build-requests/x.md, ARGS="--dry-run") @if [ -z "$(SPEC)" ]; then echo "usage: make plan SPEC=build-requests/.md" >&2; exit 2; fi python3 scripts/project_plan.py --spec "$(SPEC)" $(ARGS) ## ---- Build runner (Studio's "Build it" executor) -------------------------- build-runner-install: ## Install the host build runner as a systemd service (needs root) bash scripts/install-build-runner.sh build-runner-check: ## Check the build runner's environment without building anything @# As the account the unit actually runs as — which the installer probes for (root, @# on hosts that deny unprivileged user namespaces). Checking as the wrong account @# reports a PATH and a .env the runner cannot really see, which is the difference @# that makes a broken install look green. @UNIT_USER=$$(systemctl show olympus-build-runner -p User --value 2>/dev/null); \ UNIT_HOME=$$(systemctl show olympus-build-runner -p Environment --value 2>/dev/null \ | tr ' ' '\n' | sed -n 's/^HOME=//p' | head -1); \ if [ "$$(id -u)" = "0" ] && [ -n "$$UNIT_USER" ] && [ "$$UNIT_USER" != "root" ] \ && command -v runuser >/dev/null 2>&1; then \ runuser -u "$$UNIT_USER" -- env HOME="$$UNIT_HOME" USER="$$UNIT_USER" \ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin BUILD_EXTRA_PATH=/usr/local/bin \ python3 scripts/build-runner.py --check; \ else \ python3 scripts/build-runner.py --check; \ fi prune: ## Report old builds + finished queue files (ARGS="--yes" to delete, "--older-than 0" for all, "--queue-only" to spare builds) python3 scripts/prune-builds.py $(ARGS) ## ---- Build model (the one thing that decides whether a build writes anything) -- build-model-check: ## Can the configured build model call a tool, and be asked twice? (exit 1 = builds write nothing) @# Exit 0 usable, 1 broken, 2 could not tell. A build fails silently when this @# exits 1: Codex exits 0, the app directory stays empty, nothing says why. python3 scripts/build-model-check.py $(ARGS) build-model-alert: ## Run the check and alert (Telegram) when the chain cannot build bash scripts/build-model-alert.sh $(ARGS) build-runner-list: ## Show the build queue and where each job got to python3 scripts/build-runner.py --list tui: ## Build from the terminal UI (ARGS="--list"|"--once 'a weight tracker'") python3 scripts/olympus-tui.py $(ARGS) test-runner: ## Run the script unit tests (runner, prune, model check, restore) python3 -m unittest discover -s scripts/tests -t scripts/tests -v ## ---- Compose (Vault profile) ---------------------------------------------- up: ## Start supporting services where present (Vault profile) docker compose -f compose.vault.yml --profile vault up -d 2>/dev/null || docker compose -f docker-compose.vault.yml --profile vault up -d 2>/dev/null || echo "no compose stack for this profile" down: ## Stop supporting services (keeps volumes) docker compose -f compose.vault.yml --profile vault down 2>/dev/null || docker compose -f docker-compose.vault.yml --profile vault down 2>/dev/null || true logs: ## Tail logs from supporting services docker compose -f compose.vault.yml --profile vault logs -f 2>/dev/null || docker compose -f docker-compose.vault.yml --profile vault logs -f 2>/dev/null || true ps: ## List supporting service status docker compose -f compose.vault.yml --profile vault ps 2>/dev/null || docker compose -f docker-compose.vault.yml --profile vault ps 2>/dev/null || true ## ---- Docker (container) --------------------------------------------------- docker-build: ## Build the Olympus image (ghcr.io/innotelinc/olympus:local) docker build -t ghcr.io/innotelinc/olympus:local . # --force-recreate is what keeps the "builds → volume" promise honest. Compose # recreates a container only when its config or image changed, and a rebuild that was # a full cache hit produces a byte-identical image — so `up --build` alone answers # `Running` and runs the factory as it was, however long ago the image was built. The # first line brings the factory up unconditionally; the second converges everything # else without touching it, so omniroute and the SSO pair keep their sessions. # (docker-studio-up force-recreates Studio itself, for the same reason.) # # The guard refuses the file set that cannot work here: on a host where olympus runs # host-networked, this target's plain compose file would recreate it on the bridge, # where the loopback-published gateway is unreachable — the same break # compose.host-gateway.yml exists to prevent, applied to the factory. Switching # deliberately is `make docker-down` then this again. docker-up: ## Start the stack (detached); the factory container always comes up fresh @if [ "$$(docker inspect olympus --format '{{.HostConfig.NetworkMode}}' 2>/dev/null)" = host ]; then \ echo "olympus is running host-networked — this host deploys through compose.host-gateway.yml." >&2; \ echo "Run \`make docker-up-host\`, or \`make docker-down\` first if you mean to switch." >&2; \ exit 1; \ fi docker compose up -d --build --force-recreate --no-deps olympus docker compose up -d --build # The gateway this stack talks to is published on 127.0.0.1 only, and a bridge # container cannot reach a loopback-published port (verified: host.docker.internal, # the host-gateway IP and the LAN address all refuse). Use this target when the # gateway runs on this host; see the header of compose.host-gateway.yml. docker-up-host: ## Start with host networking (gateway published on loopback here) @docker compose -f docker-compose.yml -f compose.host-gateway.yml up -d --build --force-recreate --no-deps olympus @docker compose -f docker-compose.yml -f compose.host-gateway.yml up -d --build docker-down: ## Stop the Olympus container (keeps builds volume) docker compose down docker-down-host: ## Stop the host-networked stack docker compose -f docker-compose.yml -f compose.host-gateway.yml down docker-logs: ## Tail Olympus container logs docker compose logs -f docker-ps: ## List Olympus container status docker compose ps docker-ps-host: ## List the host-networked stack's status docker compose -f docker-compose.yml -f compose.host-gateway.yml ps # The dashboard SSO proxy, and the gateway state tooling below it. Deliberately # not folded into `docker-up`: the proxy needs the GATEWAY_* credentials from # `make gateway-oidc`, and without them oauth2-proxy would start and then refuse # every login, which is the failure mode that looks like a working deployment. # See compose.gateway-sso.yml and docs/gateway-sso.md. # # THERE IS NO GATEWAY IN THIS STACK ANY MORE. The single OmniRoute is the Group 2 # platform service (`2-voice/`, mesh `10.10.2.1`), so this stack starts none and # `make gateway-up` / `gateway-down` are gone with the `omniroute` service # (ips/docs/convergence-onyx-olympus-distro-atlas.md §4.4). Start or stop the # gateway on its own host: # # docker compose -f 2-voice/docker-compose.yml up -d omniroute # # The four targets below stay here because they are the DASHBOARD's tooling, not # the gateway's, and the dashboard is one surface wherever the gateway runs — but # each has to run on that host, which is what the notes beside them say. # The gateway's state is ONE volume, and the key that decrypts its provider # connections lives inside that same volume (`server.env`) — so the volume does # not merely hold the gateway, it is the gateway. This copies both halves into # Cerulean Vault so losing the host stops mattering. # # RUN THIS ON THE GATEWAY'S HOST. It asks Docker for the gateway container's own # mount, so it needs a Docker that can see `omniroute` (the container's name in # the repo that declares it) and that host's VAULT_* — the volume, not this checkout, is what is being # backed up. It is safe to run any time: `--check` compares the backup with the # live gateway and exits non-zero on drift, which is the version worth putting on # a timer. gateway-vault-backup: ## Back the shared gateway's keys + connections up to Cerulean Vault (run on the gateway's host) python3 scripts/omniroute-vault-backup.py $(ARGS) gateway-vault-check: ## Fail when the Vault backup no longer matches the live gateway (run on the gateway's host) python3 scripts/omniroute-vault-backup.py --check gateway-vault-restore: ## Put the stored keys + connections back (ARGS="--force" to overwrite server.env; run on the gateway's host) python3 scripts/omniroute-vault-backup.py --restore $(ARGS) gateway-sso-up: ## Put the gateway dashboard behind Cerulean Authentik (oauth2-proxy) @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi; \ for k in GATEWAY_OIDC_CLIENT_SECRET GATEWAY_SSO_COOKIE_SECRET GATEWAY_PUBLIC_HOST; do \ v=$$(sed -n "s/^$$k=//p" .env | tail -1); \ if [[ -z "$$v" ]]; then echo "$$k is not set in .env — run 'make gateway-oidc ARGS=--rotate-secret' first" >&2; exit 2; fi; \ done; \ r=$$(sed -n 's/^GATEWAY_SSO_REDIS_PASSWORD=//p' .env | tail -1); \ if [[ -z "$$r" ]]; then \ echo "GATEWAY_SSO_REDIS_PASSWORD is not set in .env." >&2; \ echo "The proxy keeps its sessions there rather than in a cookie — a cookie" >&2; \ echo "session overflows on an identity with many groups and the edge answers" >&2; \ echo "the login callback with 502. Add one with:" >&2; \ echo " printf 'GATEWAY_SSO_REDIS_PASSWORD=%s\\n' \$$(openssl rand -hex 32) >> .env" >&2; \ exit 2; \ fi; \ docker compose -f docker-compose.yml -f compose.gateway-sso.yml up -d --no-deps --wait gateway-sso-sessions gateway-sso gateway-sso-down: ## Stop the dashboard SSO proxy and its session store (the gateway keeps running) docker compose -f docker-compose.yml -f compose.gateway-sso.yml rm -sf gateway-sso gateway-sso-sessions # Makes Authentik the only gate at the gateway: `requireLogin=false`, so there is # one login instead of two and the one that could not authenticate anyone (the # gateway's own) is gone. Idempotent, and it refuses if the gateway is reachable # beyond this host — because reachability is the whole control once its own login # is off. See scripts/gateway-auth-mode.py. gateway-auth-mode: ## Make Cerulean Authentik the only gate at the gateway (ARGS="--dry-run"|"--verify") python3 scripts/gateway-auth-mode.py $(ARGS) # Publishes GATEWAY_PUBLIC_HOST at the edge. The SSO proxy makes the dashboard # safe to reach; this is what makes it REACHABLE — the CNAME, a certificate, and # the NPM proxy host that forwards to the proxy rather than to the gateway. # Idempotent, and it refuses to repoint a name that already answers somewhere # else. Needs CERULEAN_* in .env; see docs/gateway-sso.md. # # --deny-path /v1 is not optional decoration. This name exists for the dashboard, # and the proxy behind it deliberately exempts `/v1` because inference clients send # a bearer key rather than a session cookie. Measured on this deployment, that key # was never being checked (no key, a bogus key and the real key all answered 200, # and an unauthenticated POST /v1/chat/completions on the public name returned a # completion), because `make gateway-auth-mode` leaves the gateway with no gate of # its own — its docstring says the loopback binding is then the entire control. The # exemption plus no key check meant the public name handed out the provider # credentials behind it. `/v1` stays open on the LAN, which is what the proxy's # 0.0.0.0 listener and docs/gateway-sso.md's "another machine" path are for; the # public name stops carrying it. See closed_paths_config in scripts/cerulean_api.py. gateway-edge: ## Publish the gateway's public name through Cerulean + the NPM edge (ARGS="--dry-run") @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi python3 scripts/cerulean-edge.py --deny-path /v1 $(ARGS) # Asserts the two things that distinguish "wired up" from "running": the proxy # answers liveness, and an unauthenticated request is handed to Authentik with # OUR client id. A dashboard served directly would pass the first and fail the # second — which is exactly the misconfiguration worth catching. gateway-sso-check: ## Confirm the proxy redirects to Authentik instead of serving the dashboard @port=$$(sed -n 's/^GATEWAY_SSO_PORT=//p' .env 2>/dev/null | tail -1 | tr -d "'\" " ) ; port=$${port:-20128}; \ host=$$(sed -n 's/^GATEWAY_SSO_BIND=//p' .env 2>/dev/null | tail -1 | tr -d "'\" " ) ; host=$${host:-127.0.0.1}; \ code=$$(curl -s -o /dev/null -w '%{http_code}' -m 10 "http://$$host:$$port/ping" || true); \ if [[ "$$code" != "200" ]]; then echo "proxy: not answering — HTTP $$code from http://$$host:$$port/ping" >&2; exit 1; fi; \ echo "proxy: ok — live on $$host:$$port"; \ loc=$$(curl -s -o /dev/null -w '%{redirect_url}' -m 10 "http://$$host:$$port/" || true); \ issuer=$$(sed -n 's/^GATEWAY_OIDC_ISSUER_URL=//p' .env 2>/dev/null | tail -1 | tr -d "'\" "); \ client=$$(sed -n 's/^GATEWAY_OIDC_CLIENT_ID=//p' .env 2>/dev/null | tail -1 | tr -d "'\" "); \ base=$${issuer%/}; base=$${base%/application/o/*}; \ if [[ "$$loc" != "$$base/application/o/authorize/"* ]]; then \ echo "sso: FAILED — / did not redirect to Authentik (got '$$loc')" >&2; exit 1; \ fi; \ if [[ -n "$$client" && "$$loc" != *"client_id=$$client"* ]]; then \ echo "sso: FAILED — redirect names a different client than GATEWAY_OIDC_CLIENT_ID" >&2; exit 1; \ fi; \ echo "sso: ok — / redirects to $$base/application/o/authorize/ as client '$$client'" # The step the two checks above cannot make: whether the PUBLIC name resolves and # serves. They probe loopback ports, so they pass while DNS is broken, the # certificate has lapsed or the edge is down — which is precisely the report that # arrives as "it's not resolving". This walks the chain in order and names the # first link that is broken. `monitor-gateway-edge` is the same check on a timer. gateway-edge-check: ## Is gateway.olympus.innotel.us reachable, and if not, which link broke? python3 scripts/gateway-edge-check.py $(ARGS) # ---- Published websites (Studio "Build & publish") ---------------------------- # A Studio *app* is finished when it is generated; a Studio *website* is finished # when it has been built. These targets are that second step, in the order it has # to happen: package (source → dist) then publish (staged tree → a name). # # The pieces are separate on purpose. Packaging is deterministic and testable and # works with no configuration at all; publishing needs Cerulean and the edge, and # failing there must not throw away a good build. sites-up: ## Start the static site server for staged sites (compose profile `sites`) @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi docker compose --profile sites up -d sites sites-down: ## Stop the static site server (staged sites are left on disk) docker compose --profile sites rm -sf sites site-package: ## Build a Studio website into a servable dist/ and stage it (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make site-package SLUG=" >&2; exit 2; fi python3 scripts/package-website.py $(SLUG) --publish # ONE WILDCARD, THEN INSTANT PUBLISHES. # # Before this, every published name cost a DNS record and a certificate — a minute # or more of Let's Encrypt issuance per site, which is not a button you can put in # a UI. `sites-wildcard` runs the slow half once: `*.studio.olympus.innotel.us` # plus one certificate covering it. After that, `site-publish` adds only a proxy # host — seconds, no waiting, no per-name certificate. # # The name is always .; it is derived, never typed, so it # cannot drift from the build directory the slug already names. sites-wildcard: ## Create the wildcard name + certificate under which sites publish (once) python3 scripts/studio-sites.py --wildcard $(ARGS) sites-list: ## List the sites published under the wildcard suffix python3 scripts/studio-sites.py --list # The stage step is separate and it is what fills the site's directory; the publish # step only points a name at the server. Running both here is the convenience, not # the design — a republish of already-staged files is just `site-publish`. site-publish: ## Stage a website and put it on . (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make site-publish SLUG=" >&2; exit 2; fi python3 scripts/package-website.py "$(SLUG)" --publish || exit $$? python3 scripts/studio-sites.py --publish "$(SLUG)" $(ARGS) site-unpublish: ## Take a published site off the edge (SLUG=, ARGS="--dry-run") @if [ -z "$(SLUG)" ]; then echo "usage: make site-unpublish SLUG=" >&2; exit 2; fi python3 scripts/studio-sites.py --remove "$(SLUG)" $(ARGS) # --- applications (Studio → one container per app) -------------------------- # # An *app* is full-stack, so it has two more steps than a website and they are # separate for the same reason packaging and publishing are: the build is # deterministic and testable, and running a container is a deployment. # # app-package client build + the source archive (`dist/client`, `app.zip`) # app-up build the image, run the container, write its nginx vhost # app-publish app-up, then put . in front of it # # The vhost is what makes the edge generic: `olympus-sites` proxies the name to # the app's loopback port, so `site-publish` never learns an app-specific port. app-package: ## Build a Studio app's client and write its archive (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make app-package SLUG=" >&2; exit 2; fi python3 scripts/package-app.py "$(SLUG)" $(ARGS) app-up: ## Build and run a Studio app as its own container (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make app-up SLUG=" >&2; exit 2; fi python3 scripts/app-runtime.py --up "$(SLUG)" --build $(ARGS) app-down: ## Stop an app's container, keeping its database (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make app-down SLUG=" >&2; exit 2; fi python3 scripts/app-runtime.py --down "$(SLUG)" $(ARGS) app-remove: ## Stop an app and delete its container, database and image (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make app-remove SLUG=" >&2; exit 2; fi python3 scripts/app-runtime.py --remove "$(SLUG)" $(ARGS) apps-list: ## List the applications this host is running python3 scripts/app-runtime.py --list app-publish: ## Package, run and publish an app on . (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make app-publish SLUG=" >&2; exit 2; fi python3 scripts/package-app.py "$(SLUG)" || exit $$? python3 scripts/app-runtime.py --up "$(SLUG)" --build || exit $$? python3 scripts/studio-sites.py --publish "$(SLUG)" $(ARGS) # The assertion that publishing actually published: the name is served by the # edge, from this host's staged tree, and the entry point is the built site # rather than the edge's error page. A name that 502s or 404s passes "the record # exists" and fails this, which is the difference worth checking. # # It follows redirects now, because a name behind the identity provider answers # 307/302 by design and used to be reported FAILED — two of the six names this is # pointed at. The verdict says which gate answered instead; a name that redirects # somewhere that is not the configured issuer still fails. scripts/site-check.py. # The assertion's two halves, in the order a failure needs them. `site-check` asks # whether the name answers; this asks *why not* when it does not — the app or the # name — and records the answer under the apps root so the panel shows what a # publish was checked against rather than only that it returned zero. # # Exit 3 is the state worth knowing: the app is running and only its name is # missing, so `make edge-publish`/`edge-preview` fixes it and nothing is rebuilt. site-evidence: ## Check (and record) whether a name really serves (SLUG= | HOST=) @if [ -z "$(SLUG)$(HOST)" ]; then echo "usage: make site-evidence SLUG= [ARGS=--preview]" >&2; exit 2; fi python3 scripts/delivery-evidence.py $(if $(HOST),"$(HOST)","$(SLUG)") --record $(ARGS) # The retry action the roadmap asks for, and it is one command with no build in it: # registering a name is an edge operation, and an operator whose app is up and whose # name is missing should not have to repackage a container to fix that. edge-publish: ## Re-register an already-running app's name at the edge (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make edge-publish SLUG=" >&2; exit 2; fi python3 scripts/studio-sites.py --publish "$(SLUG)" $(ARGS) edge-preview: ## Register (or retry) an app's preview name at the edge (SLUG=) @if [ -z "$(SLUG)" ]; then echo "usage: make edge-preview SLUG=" >&2; exit 2; fi python3 scripts/studio-sites.py --preview "$(SLUG)" $(ARGS) docker-shell: ## Shell into the running Olympus container docker compose exec olympus bash docker-app: ## Manufacture inside the container (SPEC= or newest build-request, REPLACE=1 to rebuild) docker compose exec olympus bash -c "REPLACE=$(REPLACE) bash scripts/manufacture.sh $(if $(SPEC),$(SPEC),)" docker-clean: ## Remove container + builds volume (irreversible) docker compose down -v docker-studio: ## Build the Studio image (ghcr.io/innotelinc/olympus-studio:local) docker compose build studio # Rebuild + restart Studio THE WAY THIS HOST RUNS IT. Not a convenience alias: # `docker compose up -d studio` starts Studio on the bridge network, where its # `OMNIROUTE_BASE_URL` (127.0.0.1 by default and in .env) resolves to Studio # itself — every generation then fails ECONNREFUSED with nothing in the UI that # says so. That is how this deployment broke once. Studio belongs in the same # host-networked group as `olympus` and the SSO proxy whenever the gateway is # published on loopback; see the header of compose.host-gateway.yml. # # --force-recreate is what makes the "restart" half of the promise true. Compose # recreates a container only when its config or image changed, and a rebuild that # was a full cache hit produces a byte-identical image — so `up -d --build` alone # answers `Running` and the process, sessions included, is exactly as it was. A # deploy command that sometimes deploys is worse than one that always does. docker-studio-up: ## Rebuild + restart Studio with host networking (the gateway is loopback-published) docker compose -f docker-compose.yml -f compose.host-gateway.yml up -d --build --force-recreate studio @echo "--- reachability (Studio -> gateway) ---"; \ docker exec olympus-studio node -e 'fetch(process.env.OMNIROUTE_BASE_URL.replace(/\/v1$$/,"")+"/healthz",{signal:AbortSignal.timeout(8000)}).then(r=>{console.log("gateway",r.status);process.exit(r.ok?0:1)}).catch(e=>{console.error("gateway unreachable:",e.cause?.code||e.name);process.exit(1)})' ## ---- Studio (vibe-coding web UI — web/studio) ----------------------------- studio-install: ## Install Studio dependencies (web/studio) cd web/studio && npm ci studio-dev: ## Run the Studio dev server (default http://localhost:3001) cd web/studio && npm run dev studio-build: ## Production build of Studio cd web/studio && npm run build studio: ## Run the built Studio server cd web/studio && npm run start studio-test: ## Run the Studio test suite (vitest — parser, gateway route, OIDC flow) cd web/studio && npm test studio-export-dir: ## Make build-requests/ writable by the Studio container (Export to factory) bash scripts/studio-export-dir.sh studio-build-queue-dir: ## Make .factory/build-queue/ writable by the Studio container (Build it) STUDIO_DIR_LABEL=build-queue bash scripts/studio-export-dir.sh .factory/build-queue studio-check: ## Typecheck + test Studio (run make studio-install first) cd web/studio && npx tsc --noEmit && npm test studio-e2e: ## Drive the real Authentik handshake (needs STUDIO_E2E_* vars; see web/studio/README.md) cd web/studio && npm run test:integration # Registers Studio's OIDC application on Cerulean's Authentik and registers the # local + public callbacks as redirect URIs. Idempotent: a re-run repairs an # existing provider (grant_types, missing redirect URIs) instead of skipping it. # Needs AUTHENTIK_URL/AUTHENTIK_TOKEN in .env — see `make studio-token-rotate`. studio-oidc: ## Register/repair Studio's OIDC app in Cerulean Authentik (ARGS="--dry-run" to preview) @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi python3 scripts/authentik-studio-app.py $(ARGS) # The gateway dashboard's OIDC client. Registered separately from Studio's # because the issuer path IS the application slug, and the dashboard is a second # application with its own callbacks. GATEWAY_PUBLIC_HOST is what the browser # uses, so each callback is built from it and Authentik must have it on file. # # TWO callbacks are registered on purpose: # /oauth2/callback the identity-aware proxy that is actually deployed # /api/auth/oidc/callback OmniRoute's own OIDC, which cannot validate an # Authentik ID token today (docs/gateway-sso.md) but # needs no re-registration if upstream fixes it # # ARGS="--rotate-secret" is the only way to obtain a client secret at all: # Authentik stores them write-only, so an existing provider has no readable # value. It prints the new secret once, and the previous one stops working. gateway-oidc: ## Register/repair the gateway dashboard's OIDC client in Cerulean Authentik (ARGS="--dry-run") @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi; \ host=$$(sed -n 's/^GATEWAY_PUBLIC_HOST=//p' .env | tail -1 | tr -d '"' | tr -d "'" | tr -d '[:space:]'); \ if [[ -z "$$host" ]]; then \ echo "set GATEWAY_PUBLIC_HOST in .env (e.g. gateway.olympus.innotel.us) — it is the redirect URI Authentik registers" >&2; \ exit 2; \ fi; \ slug=$$(sed -n 's/^GATEWAY_SLUG=//p' .env | tail -1 | tr -d '"' | tr -d "'" | tr -d '[:space:]'); \ python3 scripts/authentik-studio-app.py \ --slug "$${slug:-omniroute}" \ --client-id "$${slug:-omniroute}" \ --name "OmniRoute Gateway" \ --env-prefix GATEWAY_OIDC_ \ --redirect-uri "https://$$host/oauth2/callback" \ --redirect-uri "https://$$host/api/auth/oidc/callback" $(ARGS) studio-oidc-check: ## Confirm the issuer answers discovery, and the credential is not lapsing @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi; \ issuer=$$(sed -n 's/^OIDC_ISSUER_URL=//p' .env | tail -1 | tr -d '"' | tr -d "'" | tr -d '[:space:]'); \ if [[ -z "$$issuer" ]]; then echo "OIDC_ISSUER_URL is not set in .env" >&2; exit 2; fi; \ url="$${issuer%/}/.well-known/openid-configuration"; \ code=$$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$$url" || true); \ if [[ "$$code" == "200" ]]; then echo "discovery: ok — $$url"; else echo "discovery: HTTP $$code from $$url" >&2; exit 1; fi @python3 scripts/authentik-studio-token.py --check $(ARGS) @# The credential the browser flow actually uses: a bogus code against the @# token endpoint separates "invalid_client" (credentials wrong — the exact @# 400 that surfaces as `Sign-in failed:` in Studio) from "invalid_grant". @python3 scripts/authentik-studio-app.py --verify-client studio-token-check: ## Report the registration credential's expiry (exit 2 once it is lapsing) @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi python3 scripts/authentik-studio-token.py --check $(ARGS) # Mints the credential in the Authentik shell rather than through the REST API. # Authentik 2026.8 forces `expires` to the tenant's default_token_duration for # every api-intent token (minutes=30 on Cerulean), so a REST-created credential # would die every half hour; and PATCHing a token re-parents it to the caller, # which is how an administrator credential gets mistaken for this one. The host # is a parameter because this repository stores neither its name nor a key; the # key is the operator's own, in their ssh agent or the default identity, with # AUTHENTIK_SSH_KEY to point somewhere else. studio-token-rotate: ## Rotate the registration credential (needs AUTHENTIK_HOST=) @if [[ ! -f .env ]]; then echo "no .env — cp .env.example .env first" >&2; exit 2; fi; \ if [[ -z "$(AUTHENTIK_HOST)" ]]; then \ echo "set AUTHENTIK_HOST=, e.g. make studio-token-rotate AUTHENTIK_HOST=10.0.0.5" >&2; \ echo "the credential is rebuilt by running a program in that container's shell; see docs/stack.md" >&2; \ exit 2; \ fi python3 scripts/authentik-studio-token.py --snippet $(ARGS) \ | ssh $(if $(AUTHENTIK_SSH_KEY),-i $(AUTHENTIK_SSH_KEY)) $${AUTHENTIK_SSH_USER:-root}@$(AUTHENTIK_HOST) 'docker exec -i cerulean-authentik ak shell' \ | python3 scripts/authentik-studio-token.py --store-stdin $(ARGS) vault-bootstrap: ## Store this stack's password in Cerulean Vault (AUTHENTIK_* are written too when set) python3 scripts/vault-bootstrap.py vault-renew: ## Renew this stack's scoped Vault token so it cannot lapse (--check to report only) bash scripts/vault-renew.sh ## ---- Conformity ----------------------------------------------------------- check: ## Run attribution guard + credential scan + structure checks bash .githooks/commit-msg .git/COMMIT_EDITMSG 2>/dev/null || true python3 -m compileall -q factory harness 2>/dev/null || true python3 scripts/secret-scan.py @# Only where there is a .env — CI and a fresh clone legitimately have none, and @# a missing file is not drift. On a deployment it is the whole point: the check @# is what turns "a knob was added" into a line of output instead of a surprise. @if [ -f .env ]; then python3 scripts/env-sync.py; fi secret-scan: ## Fail on literal credentials in tracked files python3 scripts/secret-scan.py secret-scan-history: ## Scan every blob in git history (post-purge verification) python3 scripts/secret-scan.py --history check-commits: ## Run the attribution guard over recent commit messages bash .githooks/commit-msg .git/COMMIT_EDITMSG 2>/dev/null || true git log --oneline -5 2>/dev/null | head -5 check-compose: ## Validate every compose file (rendered against .env.example, never touching .env) @if ! command -v docker >/dev/null 2>&1; then echo "compose config: skipped (no docker)"; exit 0; fi; \ tmp_env=$$(mktemp); \ cp .env.example "$$tmp_env" 2>/dev/null || true; \ status=0; \ for f in docker-compose.yml compose.vault.yml; do \ if docker compose --env-file "$$tmp_env" -f "$$f" config --quiet 2>/dev/null; then \ echo "compose config: ok ($$f)"; \ else \ echo "compose config: FAILED ($$f)" >&2; status=1; \ fi; \ done; \ for overlay in compose.host-gateway.yml compose.gateway-sso.yml; do \ if docker compose --env-file "$$tmp_env" -f docker-compose.yml -f "$$overlay" config --quiet 2>/dev/null; then \ echo "compose config: ok (docker-compose.yml + $$overlay)"; \ else \ echo "compose config: FAILED ($$overlay)" >&2; status=1; \ fi; \ done; \ rm -f "$$tmp_env"; \ exit $$status