docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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://<gateway host>: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/<slug>/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 (`<slug>.<SITE_HOST_SUFFIX>`) and, for direct
# address access, by path (`/<slug>/`). 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 `<slug>.<suffix>` 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/<same name>, 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