forked from innotelinc/olympus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMakefile
More file actions
574 lines (482 loc) · 33.4 KB
/
Copy pathMakefile
File metadata and controls
574 lines (482 loc) · 33.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
# ==========================================================================
# olympus — operator workflow
# Usage: make <target> (see `make help`)
# ==========================================================================
.DEFAULT_GOAL := help
SHELL := /bin/bash
.PHONY: 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 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 <target>"
@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
## ---- App manufacturing (local dev — mirrors olympus-app-builder.yml) ------
app: ## Manufacture app from SPEC (or most recent build-requests/*.md) → ./builds
bash scripts/manufacture.sh $(if $(SPEC),$(SPEC),)
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/<name>.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 `g2-omniroute` (the Group 2 container
# name) 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:-20129}; \
code=$$(curl -s -o /dev/null -w '%{http_code}' -m 10 "http://127.0.0.1:$$port/ping" || true); \
if [[ "$$code" != "200" ]]; then echo "proxy: not answering — HTTP $$code from http://127.0.0.1:$$port/ping" >&2; exit 1; fi; \
echo "proxy: ok — live on 127.0.0.1:$$port"; \
loc=$$(curl -s -o /dev/null -w '%{redirect_url}' -m 10 "http://127.0.0.1:$$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=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make site-package SLUG=<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 <slug>.<SITE_HOST_SUFFIX>; 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>.<SITE_HOST_SUFFIX> (SLUG=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make site-publish SLUG=<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=<slug>, ARGS="--dry-run")
@if [ -z "$(SLUG)" ]; then echo "usage: make site-unpublish SLUG=<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 <slug>.<SITE_HOST_SUFFIX> 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=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make app-package SLUG=<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=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make app-up SLUG=<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=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make app-down SLUG=<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=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make app-remove SLUG=<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>.<SITE_HOST_SUFFIX> (SLUG=<slug>)
@if [ -z "$(SLUG)" ]; then echo "usage: make app-publish SLUG=<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.
site-check: ## Confirm a published name answers (HOST=<name>)
@if [ -z "$(HOST)" ]; then echo "usage: make site-check HOST=<name>" >&2; exit 2; fi
python3 scripts/site-check.py "$(HOST)" $(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)
docker compose exec olympus 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=<host running cerulean-authentik>)
@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=<host running cerulean-authentik>, 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