# Olympus — containerized factory (FactoryOps) # Usage: cp .env.example .env && $EDITOR .env # TELEGRAM_BOT_TOKEN, OMNIROUTE_* etc. # docker compose up --build -d # or: make docker-up # Then: make docker-logs | make docker-app SPEC=build-requests/my-todo.md # # Builds/ is a named volume (factory output, gitignored); build-requests/ is # bind-mounted so dropping a spec triggers the next `make docker-app` without rebuild. # OmniRoute: the gateway exposes /healthz for liveness (200, unauthenticated). # /health is NOT a route (404) despite appearing in older files here — do not # probe it. Containers labelled `autoheal=true` are restarted by the autoheal # service below when their healthcheck fails. # # THE GATEWAY IS A SHARED PLATFORM SERVICE — this file starts none. The single # OmniRoute lives in Group 2 (`2-voice/`, mesh `10.10.2.1`; Consul service # `omniroute`), because one gateway means one provider pool and one place to # rotate. Olympus used to carry a second copy behind `profiles: ['gateway']`; # it was removed once the shared one held the provider connections (see # ips/docs/convergence-onyx-olympus-distro-atlas.md §4.4). # # `OMNIROUTE_BASE_URL` still has to survive the topology it is used from, and the # topology it has to survive CHANGED: the gateway no longer answers on the LAN at # all. `make gateway-auth-mode` turned its own login off, so the reachability of # 20128 is the whole control and it is published on loopback + docker0 only. The # routable door is the SSO proxy in front of it, `:20128`, which exempts `/v1` for # API clients. Pick the row that matches where the gateway runs: # on the gateway's host OMNIROUTE_BASE_URL=http://127.0.0.1:20128/v1 # on any OTHER host OMNIROUTE_BASE_URL=http://:20128/v1 # (that host's LAN address — this is what `.50` uses: # http://192.168.1.71:20128/v1) # host-networked caller OMNIROUTE_BASE_URL=http://127.0.0.1:20128/v1 # # The old `10.10.2.1:20128` mesh form and `host.docker.internal:20128` are both # dead ends now: the first is the ceremony of a binding that was deliberately # removed, the second resolves to the caller's *own* docker0, where nothing # listens (it was already silently wrong after the split, for n8n and dashboard-api # on `.30` alike). `docs/gateway-sso.md` has the evidence. # # Nothing here picks the URL for a service: `OMNIROUTE_BASE_URL` is read from the # same `.env` the host-side build runner uses. A caller on the same host as the # gateway still needs `compose.host-gateway.yml` for the loopback *proxy* only if # it dials `127.0.0.1:20128`; through the docker0 alias the bridge is enough. # `make docker-studio-up` is how Studio gets there; starting Studio without it is # silent — Studio resolves `127.0.0.1:20128` to itself and every generation fails # ECONNREFUSED. # Project name is pinned so container/volume names never depend on where this # repo is checked out (the mesh puts it in 5-dev/olympus). name: olympus services: olympus: build: context: . dockerfile: Dockerfile image: ghcr.io/innotelinc/olympus:local container_name: olympus restart: unless-stopped # Autoheal restarts any container carrying this label when its healthcheck # reports unhealthy. Requires a healthcheck to exist (see below). labels: - "autoheal=true" env_file: # Optional, because a render has to work without one: `make check-compose` # validates these files against `.env.example` in a checkout that has no # `.env`, and a missing env_file is an error rather than a warning. A # deployment has one, and it is loaded exactly as before. - path: .env required: false environment: OMNIROUTE_BASE_URL: ${OMNIROUTE_BASE_URL:-http://192.168.1.71:20128/v1} OMNIROUTE_API_KEY: ${OMNIROUTE_API_KEY:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} SPEC: ${SPEC:-} OPENAI_API_KEY: ${OMNIROUTE_API_KEY:-${OPENAI_API_KEY:-}} volumes: - olympus-builds:/app/builds - ./build-requests:/app/build-requests:ro # Persist the factory token across restarts when using env_file .env - ./.factory-env:/app/.factory-env # NO PUBLISHED PORT, deliberately, and this is a fix rather than an omission. # This service used to publish ${OLYMPUS_PORT:-20128}:20128 — the same host port # the gateway service below owns — because the image can start a *bundled* # gateway. It only ever worked because this container runs host-networked here # (its ports are discarded); on a bridge run the two services fight for 20128, # and if this one wins, the build runner and the SSO proxy reach an empty # gateway with no provider connections. Nothing outside talks to this container: # `make docker-app` uses `docker compose exec`, not the network. One OmniRoute. extra_hosts: - "host.docker.internal:host-gateway" healthcheck: # The gateway has no /health route (it answers 404), so doctor.py is the signal. # Match the status line exactly — a bare `grep -i ok` would match the per-check # '[ ok ]' rows and could never fail. test: ["CMD-SHELL", "python3 factory/doctor.py 2>/dev/null | grep -q 'Status: READY' || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 40s # Studio — the vibe-coding web UI (web/studio). Talks to the gateway # server-side; the key never reaches the browser. Build context is the app # directory so the image carries no host node_modules. studio: build: context: ./web/studio dockerfile: Dockerfile image: ghcr.io/innotelinc/olympus-studio:local container_name: olympus-studio restart: unless-stopped labels: - "autoheal=true" env_file: # Optional, because a render has to work without one: `make check-compose` # validates these files against `.env.example` in a checkout that has no # `.env`, and a missing env_file is an error rather than a warning. A # deployment has one, and it is loaded exactly as before. - path: .env required: false # See the OmniRoute caveat at the top of this file before changing the base URL. environment: OMNIROUTE_BASE_URL: ${OMNIROUTE_BASE_URL:-http://192.168.1.71:20128/v1} OMNIROUTE_API_KEY: ${OMNIROUTE_API_KEY:-} OMNIROUTE_MODEL: ${OMNIROUTE_MODEL:-auto/coding} STUDIO_ACCESS_TOKEN: ${STUDIO_ACCESS_TOKEN:-} OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-} OIDC_SCOPES: ${OIDC_SCOPES:-openid email profile} # Group allow-list. Empty = any authenticated user; set = members of a # listed group only, everyone else gets 403. OIDC_ALLOWED_GROUPS: ${OIDC_ALLOWED_GROUPS:-} STUDIO_SESSION_SECRET: ${STUDIO_SESSION_SECRET:-} # Tenancy (Distro's control plane — convergence §5.2). With these set, a # turn is spent on the signed-in user's own gateway key, quota is checked # before it and usage recorded after it; with them empty Studio falls back # to the single shared OMNIROUTE_API_KEY above, exactly as before. CONTROL_PLANE_INTERNAL_URL: ${CONTROL_PLANE_INTERNAL_URL:-} CONTROL_INTERNAL_TOKEN: ${CONTROL_INTERNAL_TOKEN:-} # Saved-app library. Without this volume the store lives in the container # and every app disappears on the next image build. STUDIO_DATA_DIR: ${STUDIO_DATA_DIR:-/app/data/studio} # Where "Build it" writes a request for the host-side runner. Studio cannot # run `make app` itself — this image carries no Archon CLI, no Codex CLI and # no checkout — so it drops a small request file here and the runner # (scripts/build-runner.py, olympus-build-runner.service) picks it up where # the toolchain actually is. The runner re-validates every field. STUDIO_BUILD_QUEUE_DIR: ${STUDIO_BUILD_QUEUE_DIR:-/app/build-queue} # Where packaged website builds land (`builds//site.zip`, written by # scripts/package-website.py). Read-only and optional: with it mounted, the # download button serves the exact archive the publish step staged; without # it, Studio falls back to zipping the saved source itself. STUDIO_BUILDS_DIR: ${STUDIO_BUILDS_DIR:-/app/builds} # The port Studio listens on, and the ONLY place it is decided. The image # carries PORT=3001 for a bare `docker run`, so without this the knob below # would set the *published* port while the app stayed on 3001 — invisible on # the bridge network, where the mapping bridges the two, and a silent 502 # under `compose.host-gateway.yml`, which discards published ports and so # leaves nothing listening on STUDIO_PORT at all (measured on .50: Studio # answered 3050 nowhere, while the edge forwarded there and every page # 502'd). `make studio-dev` already reads STUDIO_PORT this way, so both # modes now agree on what the number means. PORT: ${STUDIO_PORT:-3001} volumes: - studio-data:/app/data # The packaged builds, read-only. Only the archive route reads this. - ./builds:/app/builds:ro # The factory handoff. "Export to factory" writes a spec into this # directory, which is the repo's build-requests/ — the same place # `make app SPEC=…` reads and the CI builder watches on push. Studio runs # as uid 1001 (the image drops privileges), so the host directory has to # be writable by that uid: `chown 1001:1001 build-requests` once. If it is # not, the export fails with a message naming the fix rather than a 500. - ./build-requests:/app/build-requests # The build queue the runner watches. Root-owned here means Studio can # neither queue nor see a heartbeat; `make studio-build-queue-dir` (or # scripts/studio-export-dir.sh) settles it for uid 1001. - ./.factory/build-queue:/app/build-queue # Not published on every interface. Studio was on 0.0.0.0, so anyone on the # LAN could reach the app directly and skip the edge — and with it the # sign-in the edge performs (docs/gateway-sso.md). The edge is a container on # its own bridge, so it cannot reach a loopback-only port; it dials the # docker0 gateway instead, which is the same pattern the DNS console uses. # STUDIO_EDGE_HOST is the address the NPM proxy hosts must point at. ports: - "127.0.0.1:${STUDIO_PORT:-3001}:${STUDIO_PORT:-3001}" - "${STUDIO_EDGE_HOST:-172.17.0.1}:${STUDIO_PORT:-3001}:${STUDIO_PORT:-3001}" extra_hosts: - "host.docker.internal:host-gateway" healthcheck: # Liveness only: assert Studio answers its own root and never follow the # redirect. Following it walks straight into the OIDC login chain, so the # check would then depend on Authentik being up AND on the loopback # callback URI being registered in the provider — neither of which should # decide whether this container is healthy. It did report unhealthy for # exactly that reason once Studio began deriving the callback per host. # 2xx = running with auth disabled; 3xx = running, redirecting to login. test: - CMD-SHELL - node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3001)+'/',{redirect:'manual'}).then(r=>process.exit(r.status>=200&&r.status<400?0:1)).catch(()=>process.exit(1))" interval: 30s timeout: 5s retries: 3 start_period: 20s # Static sites — what Studio's "Build & publish" serves (opt-in, profile `sites`). # # `scripts/package-website.py --publish` stages a built site under # `OLYMPUS_SITES_ROOT` (default /var/lib/olympus/sites); this serves that tree so # the staged copy is reachable without anyone installing a web server by hand. # # Host networking for the same reason the SSO proxy uses it: the NPM edge lives # on another host (192.168.1.71) and has to reach back to a LAN address, and # `network_mode: host` is what makes that address exist. It binds ONE port — # ${SITE_PORT:-20130} — on every interface, so a site staged here is readable by # anything that can route to this host. That is the intent: a published site is # meant to be public. Nothing else about this container is exposed; it has no # shell access to the repo and the sites tree is mounted read-only. # # The site is chosen by HOSTNAME (`.`) and, for direct # address access, by path (`//`). See deploy/nginx-sites.conf.template. # # This is a plain static file server with no auth. Sites published here are open # to the LAN on purpose, and the name they answer on is what makes them public. # If a site needs to be private, do not publish it — Studio's zip download is the # delivery path for that. sites: image: nginx:1.27-alpine container_name: olympus-sites profiles: ["sites"] restart: unless-stopped network_mode: host environment: # One substitution only — see the header of the template. Without the filter, # envsubst would blank nginx's own `$uri` in try_files. SITE_PORT: ${SITE_PORT:-20130} # The leftmost label of a published name is the site's directory, so the # template needs this too. Both are in the filter and nothing else is — # an unfiltered envsubst would blank nginx's own `$uri` and `$site`. SITE_HOST_SUFFIX: ${SITE_HOST_SUFFIX:-studio.olympus.innotel.us} NGINX_ENVSUBST_FILTER: SITE_PORT|SITE_HOST_SUFFIX volumes: - ${OLYMPUS_SITES_ROOT:-/var/lib/olympus/sites}:/srv/sites:ro # One generated `server` block per running application, written here by # scripts/app-runtime.py and included from the template below. # # This is how an app gets a name without the edge config growing: the block's # `server_name` is exact, and nginx prefers an exact name over the template's # regex, so `.` reaches the app's container while everything # else still falls through to the static tree. `nginx -s reload` picks up a new # file; nothing else has to change. - ${OLYMPUS_APPS_ROOT:-/var/lib/olympus/apps}/nginx:/etc/nginx/app-conf.d:ro # Named `default.conf.template` on purpose: the image's entrypoint renders # templates to /etc/nginx/conf.d/, so this replaces the stock # default.conf instead of sitting beside it. Laid down under any other name # nginx would keep serving :80 as well. - ./deploy/nginx-sites.conf.template:/etc/nginx/templates/default.conf.template:ro # The OmniRoute gateway is NOT here. It is the shared platform service in # Group 2 (`2-voice/`), one provider pool for every platform; the `omniroute` # service and the `omniroute-data` volume that used to sit at this point were # removed in ips/docs/convergence-onyx-olympus-distro-atlas.md §4.4. # # The gateway's STATE (provider connections, settings, the dashboard password # hash, and `server.env`, whose STORAGE_ENCRYPTION_KEY decrypts them) is one # volume on the gateway's host. `make gateway-vault-backup` / `-check` / # `-restore` copy it to Cerulean Vault and must be run THERE, not here — # scripts/omniroute-vault-backup.py asks Docker for the container's own mount, # so it needs a Docker that can see the gateway (`--container omniroute`). # `make gateway-sso-up` / `gateway-edge` / `gateway-auth-mode` belong on that # host too: the SSO proxy forwards to the gateway's loopback binding, so the # dashboard gate has to run beside the dashboard. See docs/gateway-sso.md. # Autoheal — restarts containers whose healthcheck reports "unhealthy". # # Only containers labelled `autoheal=true` are touched. A container in another # compose project is out of reach here — this stack cannot label containers it # does not own — so the label and an autoheal service must be added to THAT # project instead. (Done for the external OmniRoute gateway this stack # defaults to: it now carries the label and its own autoheal service.) # # Mounting the Docker socket grants this container control over the daemon. # That is inherent to autoheal; scope it by keeping the socket read-mostly and # the label explicit rather than relying on defaults. # Pinned by digest because it holds the Docker socket — a silent upstream # retag would swap code that can restart anything on this host. # Tag at time of pinning: willfarrell/autoheal:latest autoheal: image: willfarrell/autoheal@sha256:a75fcda3176f6b3b70b75cc72fb1979ed86183fc45daacf1a2647902dc44916a container_name: olympus-autoheal restart: unless-stopped environment: AUTOHEAL_CONTAINER_LABEL: autoheal AUTOHEAL_INTERVAL: "15" AUTOHEAL_START_PERIOD: "60" AUTOHEAL_DEFAULT_STOP_TIMEOUT: "20" DOCKER_SOCK: /var/run/docker.sock volumes: - /var/run/docker.sock:/var/run/docker.sock volumes: olympus-builds: # Studio's saved apps — per Authentik identity, one JSON file each. studio-data: networks: default: name: olympus-net