Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Full-stack oc8 for a test server. See docs/DEPLOY.md.
# Core: postgres, redis, migrate (one-shot), backend, worker, ingestion-worker,
# scheduler, frontend, caddy. Optional: ollama (profile: ollama).
#
# Every long-running service carries `restart: unless-stopped`; only `migrate`
# does not, because a one-shot job that has already run must not run again.
# `unless-stopped` rather than `always` so that an operator who deliberately
# stops a service can keep it stopped.
#
# It is here because of what happened without it: both workers exited on a
# transient Redis timeout and stayed dead for 35 hours. Nothing else showed it --
# the backend was healthy, the UI was up, Redis itself was fine again minutes
# later -- and the only symptom was that no run executed. The worker is the sole
# process that performs work, so it is the one that must come back by itself.
name: oc8

x-backend-image: &backend-image
  build:
    context: ./backend
    dockerfile: ../Dockerfile.backend
  image: oc8-backend:latest

x-backend-env: &backend-env
  # SECURITY: `dev` enables POST /api/v1/auth/dev-login, an UNAUTHENTICATED
  # endpoint that mints an org_admin token for the seed tenant to ANYONE who
  # calls it. On a host reachable from the internet that is a full auth bypass,
  # not just a "test login". It is the default only because this stack exists to
  # be brought up and tested quickly — so restrict the host at the network
  # layer (firewall / VPN / bind Caddy to a private interface), OR set
  # OC8_ENV=prod to require real password auth (POST /auth/setup /
  # /auth/login). See docs/DEPLOY.md.
  OC8_ENV: ${OC8_ENV:-dev}
  # Showcase mode: bilingual ACME seed + Demo badge. Prefer compose.demo.yml
  # (password login) or compose.dev.yml (local open login) over flipping this
  # alone on a reachable host.
  OC8_DEMO: ${OC8_DEMO:-false}
  OC8_DATABASE_URL: postgresql+asyncpg://oc8_app:oc8@postgres:5432/oc8
  OC8_MIGRATION_URL: postgresql+psycopg://oc8_migrate:oc8@postgres:5432/oc8
  OC8_REDIS_URL: redis://redis:6379/0
  # Bundled S3-compatible object storage (file attachments). Bundled-by-default,
  # switchable via env: point these at any S3-compatible endpoint (AWS S3, R2,
  # etc.) in production instead of the bundled `minio` service above.
  OC8_S3_ENDPOINT: ${OC8_S3_ENDPOINT:-http://minio:9000}
  OC8_S3_ACCESS_KEY: ${OC8_S3_ACCESS_KEY:-oc8-minio}
  OC8_S3_SECRET_KEY: ${OC8_S3_SECRET_KEY:?set OC8_S3_SECRET_KEY in .env}
  OC8_S3_BUCKET: ${OC8_S3_BUCKET:-oc8-uploads}
  OC8_S3_REGION: ${OC8_S3_REGION:-us-east-1}
  OC8_JWT_SECRET: ${OC8_JWT_SECRET:?set OC8_JWT_SECRET in .env}
  OC8_SECRET_KEK: ${OC8_SECRET_KEK:?set OC8_SECRET_KEK in .env}
  # Externally reachable base URL of this stack (Caddy, not the backend
  # container's own internal port) -- used for OAuth redirect URIs and for
  # displaying an inbound webhook trigger's URL. Unset, these silently fall
  # back to oc8.config.Settings' dev defaults (http://localhost:8000 /
  # :8080), neither of which is actually reachable in this compose stack.
  OC8_OAUTH_REDIRECT_BASE_URL: ${OC8_OAUTH_REDIRECT_BASE_URL:-http://localhost}
  OC8_FRONTEND_BASE_URL: ${OC8_FRONTEND_BASE_URL:-http://localhost}
  # arm64 hosts only (ignored on x86_64): cryptography's vendored OpenSSL
  # probes CPU crypto features via an `mrs` instruction at import time, which
  # some virtualization stacks running a Linux arm64 guest under Apple's
  # Virtualization framework (e.g. Docker Desktop on Apple Silicon) trap as
  # Illegal Instruction instead of emulating -- crashing the container before
  # it ever starts. `armcap=0` skips that probe and falls back to software
  # crypto; the cost is negligible for oc8's light per-request crypto use
  # (JWT sign/verify, small secret payloads). Real arm64 production hardware
  # without this bug can restore hardware-accelerated crypto by overriding
  # OC8_OPENSSL_ARMCAP in its own .env.
  OPENSSL_armcap: ${OC8_OPENSSL_ARMCAP:-0}
  OC8_ANTHROPIC_API_KEY: ${OC8_ANTHROPIC_API_KEY:-}
  OC8_OPENAI_API_KEY: ${OC8_OPENAI_API_KEY:-}
  OC8_MISTRAL_API_KEY: ${OC8_MISTRAL_API_KEY:-}
  OC8_OLLAMA_BASE_URL: ${OC8_OLLAMA_BASE_URL:-http://ollama:11434}
  OTEL_SDK_DISABLED: ${OTEL_SDK_DISABLED:-true}
  # Plugin folders (§13). WORKDIR is /app, so this matches the ./capas
  # bind mount on the backend and worker services below.
  OC8_CAPAS_PATH: ${OC8_CAPAS_PATH:-/app/capas}
  # cli_harness is a shared helper LIBRARY the coding-CLI runtime plugins
  # (claude_code_runtime, codex_runtime, opencode_runtime) import at module
  # level -- it has no plugin.toml of its own and is never pip-installed,
  # only ever put on sys.path (see backend/pyproject.toml's identical
  # mypy_path/pythonpath entries for the same reason at dev-tooling time).
  # Without this, `from cli_harness.dirs import ...` raises ModuleNotFoundError
  # the moment the backend or worker process tries to load any of those three
  # runtime plugins, and they report as installed-but-unavailable ("plugin
  # code is not loadable") with no indication why.
  PYTHONPATH: /app/capas/cli_harness
  # §8.5 agent isolation: route runs through a per-agent Docker container.
  OC8_AGENT_ISOLATION: ${OC8_AGENT_ISOLATION:-true}
  OC8_INTERNAL_BASE_URL: ${OC8_INTERNAL_BASE_URL:-http://backend:8099}
  OC8_AGENT_RUNTIME_NETWORK: ${OC8_AGENT_RUNTIME_NETWORK:-oc8_agents}
  # uid:gid the sandbox/agent containers run as (see oc8.config.Settings.sandbox_user).
  # Empty by default -- the right value is a property of the deployment (the
  # uid that owns OC8_RUNTIME_SESSION_ROOT on the host), not of this compose file.
  OC8_SANDBOX_USER: ${OC8_SANDBOX_USER:-}
  # Runtime-provisioner privilege separation: backend/worker never touch
  # docker.sock directly (see the `runtime-provisioner` service below, the
  # SOLE owner of that mount) -- every sandbox operation crosses the network
  # to it instead, authenticated by a shared bearer token. `migrate` and the
  # other one-shot/non-sandboxing processes inherit these too (harmless: they
  # never call get_sandbox_driver()), simplest to configure in one place.
  OC8_SANDBOX_DRIVER: provisioner
  OC8_SANDBOX_PROVISIONER_URL: http://runtime-provisioner:8090
  OC8_SANDBOX_PROVISIONER_TOKEN: ${OC8_SANDBOX_PROVISIONER_TOKEN:?set OC8_SANDBOX_PROVISIONER_TOKEN in .env}
  # Container-runtime adapter: session root must resolve to the SAME absolute
  # path on the host and inside the backend (see the backend volume mount
  # below) because the backend hands these paths to a sibling container's
  # bind mounts, which the docker daemon resolves on the host. The two image
  # vars below are nanoclaw-specific and read directly by the plugin (not by
  # core oc8.config, which stays software-neutral) -- they are still declared
  # here so operators configure every image in one place.
  OC8_RUNTIME_SESSION_ROOT: ${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}
  # Evidence retention (§12.5.1). The sweep archives a finished run's session
  # folder into one hash-chained tar.xz and deletes the loose tree; off by
  # default because it deletes files. ARCHIVE_ROOT empty means
  # <session root>/archive -- which the same volume mount below already covers,
  # so turning the sweep on needs no new mount. RETENTION_DAYS=0 never reduces:
  # archiving alone is lossless, and only a window makes it lossy.
  OC8_EVIDENCE_SWEEP_ENABLED: ${OC8_EVIDENCE_SWEEP_ENABLED:-false}
  OC8_EVIDENCE_ARCHIVE_ROOT: ${OC8_EVIDENCE_ARCHIVE_ROOT:-}
  OC8_EVIDENCE_ARCHIVE_AFTER_MINUTES: ${OC8_EVIDENCE_ARCHIVE_AFTER_MINUTES:-60}
  OC8_EVIDENCE_RETENTION_DAYS: ${OC8_EVIDENCE_RETENTION_DAYS:-0}
  OC8_NANOCLAW_AGENT_IMAGE: ${OC8_NANOCLAW_AGENT_IMAGE:-nanoclaw-agent:latest}
  OC8_CLAUDE_CODE_AGENT_IMAGE: ${OC8_CLAUDE_CODE_AGENT_IMAGE:-oc8-agent-claude-code:latest}
  OC8_CODEX_AGENT_IMAGE: ${OC8_CODEX_AGENT_IMAGE:-oc8-agent-codex:latest}
  OC8_OPENCODE_AGENT_IMAGE: ${OC8_OPENCODE_AGENT_IMAGE:-oc8-agent-opencode:latest}
  OC8_NANOCLAW_PROVISIONER_IMAGE: ${OC8_NANOCLAW_PROVISIONER_IMAGE:-nanoclaw-provisioner:latest}
  # Read-only HOST paths the agent container mounts: nanoclaw's runner source and
  # skills tree (it mounts these rather than baking them in) plus oc8's MCP
  # bridge. Host paths, so the plugin's own __file__ cannot supply them -- inside
  # the backend container it reads /app/capas/..., which the daemon cannot mount.
  OC8_NANOCLAW_RUNNER_SRC: ${OC8_NANOCLAW_RUNNER_SRC:-/opt/nanoclaw/agent-runner/src}
  OC8_NANOCLAW_SKILLS_SRC: ${OC8_NANOCLAW_SKILLS_SRC:-/opt/nanoclaw/skills}
  OC8_NANOCLAW_BRIDGE_SRC: ${OC8_NANOCLAW_BRIDGE_SRC:-/opt/oc8/oc8-mcp-bridge.mjs}

services:
  postgres:
    restart: unless-stopped
    image: docker.io/pgvector/pgvector:pg15
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: oc8
    volumes:
      - oc8-pgdata:/var/lib/postgresql/data
      - ./backend/docker/init-db.sql:/docker-entrypoint-initdb.d/10-init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d oc8"]
      interval: 3s
      timeout: 3s
      retries: 30

  redis:
    restart: unless-stopped
    image: docker.io/library/redis:7-alpine
    # Redis defaults to maxmemory 0 / noeviction, which means that once the
    # keyspace fills EVERY write fails -- including run intake and the
    # realtime bus, which share this instance and are not designed to fail
    # open. Department prompt caching adds a 24h-TTL keyspace that grows with
    # traffic, so a cap plus volatile-ttl (evict only keys that HAVE a TTL,
    # nearest expiry first -- in practice the department cache dominates the
    # sampled set even though OAuth state's shorter 10-minute TTL is preferred
    # when both are candidates) leaves the TTL-less run-intake stream
    # untouched, trading an occasional dropped login state (the user retries)
    # for never blocking a run.
    command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "volatile-ttl"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      timeout: 3s
      retries: 30

  # Bundled S3-compatible object storage for file attachments (chat + agent
  # Instructions uploads, see oc8.storage.s3). Bundled-by-default, switchable
  # via env: point OC8_S3_ENDPOINT/OC8_S3_* at any S3-compatible endpoint
  # (AWS S3, R2, etc.) in production instead of running this container.
  minio:
    restart: unless-stopped
    # MinIO stopped publishing pullable images: Docker Hub 404s, and
    # quay.io/minio/{minio,mc} now return 401 for anonymous pulls, which is
    # what broke `./scripts/quickstart.sh`. Chainguard rebuilds both from
    # source and still serves them without a login.
    image: cgr.dev/chainguard/minio:latest
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${OC8_S3_ACCESS_KEY:-oc8-minio}
      MINIO_ROOT_PASSWORD: ${OC8_S3_SECRET_KEY:?set OC8_S3_SECRET_KEY in .env}
    volumes:
      - oc8-miniodata:/data
    healthcheck:
      # Distroless: no curl. bash /dev/tcp is enough to know the API is up.
      test: ["CMD", "/usr/bin/bash", "-c", "echo >/dev/tcp/127.0.0.1/9000"]
      interval: 5s
      timeout: 5s
      retries: 20

  # One-shot: create the default bucket on first boot. Idempotent (`-p` /
  # `|| true`) so a re-run against an already-provisioned bucket doesn't fail
  # the compose run.
  minio-init:
    # `:latest-dev` because the slim client has no shell, and this one-shot
    # still needs `sh -c` to set the alias then make the bucket.
    image: cgr.dev/chainguard/minio-client:latest-dev
    depends_on:
      minio:
        condition: service_healthy
    environment:
      OC8_S3_ACCESS_KEY: ${OC8_S3_ACCESS_KEY:-oc8-minio}
      OC8_S3_SECRET_KEY: ${OC8_S3_SECRET_KEY:?set OC8_S3_SECRET_KEY in .env}
      OC8_S3_BUCKET: ${OC8_S3_BUCKET:-oc8-uploads}
    entrypoint: ["/usr/bin/sh", "-c"]
    command:
      - |
        mc alias set local http://minio:9000 "$$OC8_S3_ACCESS_KEY" "$$OC8_S3_SECRET_KEY" && \
        mc mb -p "local/$$OC8_S3_BUCKET" || true
    restart: "no"

  # One-shot: migrate the schema, then optionally seed. App services wait for
  # this to complete, so migration runs exactly once (not in four processes).
  #
  # No depends_on here on purpose: backend/worker already require migrate, and
  # migrate requiring postgres would give those a two-level dependency chain --
  # which podman <= 4.x refuses to start ("container ... depends on container
  # ... not found in input list": its start-time dependency graph is one level
  # deep). Postgres readiness is waited for in the command below instead,
  # which also keeps the one-shot semantics self-contained.
  migrate:
    <<: *backend-image
    environment:
      <<: *backend-env
      OC8_SEED_ON_START: ${OC8_SEED_ON_START:-true}
      OC8_DEMO: ${OC8_DEMO:-false}
    volumes:
      # Seed installs/enables showcase capas when OC8_DEMO=true; discovery reads
      # this mount the same way backend/worker do.
      - ./capas:/app/capas:ro
    command:
      - sh
      - -c
      - |
        set -e
        i=0
        until python -c "import socket; socket.create_connection(('postgres', 5432), 2)" 2>/dev/null; do
          i=$$((i+1))
          if [ "$$i" -ge 60 ]; then echo "postgres not reachable after 60s" >&2; exit 1; fi
          sleep 1
        done
        alembic upgrade head
        if [ "$${OC8_SEED_ON_START}" = "true" ] || [ "$${OC8_DEMO}" = "true" ]; then
          echo "seeding…"; oc8 seed || echo "seed skipped/failed (non-fatal)"
        fi
    restart: "no"

  # The sole owner of the container-runtime socket in the whole Community
  # stack. backend/worker provision sandboxes over HTTP against this process
  # instead of touching Docker (OC8_SANDBOX_DRIVER=provisioner, above) -- so a
  # compromise of either (both run agent-authored code) never reaches raw
  # Docker: the attacker gets, at most, the provisioner's own narrow,
  # policy-checked API (oc8.runtime_provisioner.app / policy.py).
  #
  # The HOST socket path defaults to Docker's and can be pointed at Podman's
  # user API socket via OC8_CONTAINER_SOCKET (scripts/quickstart.sh fills it
  # in for OC8_CONTAINER_RUNTIME=podman). The container-side path and the
  # read-only bind are constant either way. This is safe to parameterize for
  # Podman because DEPLOY.md's root-equivalent-socket warning is about Docker
  # (and rootful Podman): a rootless Podman socket is scoped to the invoking
  # user's own containers, so it does not hand the provisioner more power than
  # that user already has.
  runtime-provisioner:
    restart: unless-stopped
    <<: *backend-image
    environment:
      <<: *backend-env
    volumes:
      - ${OC8_CONTAINER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
      # Read-only: the provisioner only needs to validate that a caller-supplied
      # mount falls inside this root (see ProvisionerPolicy._validate_mount);
      # it never itself writes here.
      - ${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}:${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}:ro
    command:
      - uvicorn
      - oc8.runtime_provisioner.app:create_provisioner_app
      - --factory
      - --host
      - "0.0.0.0"
      - --port
      - "8090"

  backend:
    restart: unless-stopped
    <<: *backend-image
    environment: *backend-env
    networks: [default, agents]
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
      minio:
        condition: service_healthy
      runtime-provisioner:
        condition: service_started
    volumes:
      # Plugin folders, read-only: oc8 discovers and reads them, never writes.
      - ./capas:/app/capas:ro
      # Core UI translation catalogs, read-only: only `backend` serves the
      # /api/v1/i18n/core endpoint the frontend fetches these through, so
      # only it needs the mount -- dropping in a new locale here just needs
      # a `backend` restart, no image rebuild.
      - ./i18n:/app/i18n:ro
      # runtime session root: identical absolute path on both sides is the
      # point, not a typo -- see OC8_RUNTIME_SESSION_ROOT above.
      - ${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}:${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}
    healthcheck:
      test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8099/health').status==200 else 1)\""]
      interval: 5s
      timeout: 3s
      retries: 20

  worker:
    restart: unless-stopped
    <<: *backend-image
    environment: *backend-env
    networks: [default, agents]
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
      minio:
        condition: service_healthy
      runtime-provisioner:
        condition: service_started
    volumes:
      - ./capas:/app/capas:ro
      # runtime session root: the worker is what actually starts agent
      # sandboxes, so it (not the backend) is what needs to see this path at
      # the SAME absolute location the sibling container mounts it.
      - ${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}:${OC8_RUNTIME_SESSION_ROOT:-/var/lib/oc8/sessions}
    command: ["oc8", "worker"]
    # More than one, because a worker executes runs STRICTLY SERIALLY: it
    # dequeues one message and awaits it before taking the next. Four agents on
    # a three-minute schedule produce ~80 runs an hour; one worker manages ~15,
    # and the queue grows without bound. Scale this with the number of agents,
    # not with the machine.
    #
    # Safe to raise only because the things two workers could race on are
    # settled in the database rather than in a process: a run is claimed through
    # a Redis consumer group (one message, one consumer), an agent is serialised
    # by a row lock before its run starts, a record is claimed by a unique
    # constraint, and liveness is a heartbeat on the run rather than a look at
    # the local Docker daemon -- which would have declared another host's live
    # runs dead.
    deploy:
      replicas: ${OC8_WORKERS:-2}

  ingestion-worker:
    restart: unless-stopped
    <<: *backend-image
    environment: *backend-env
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    # Same as backend/worker above: without this, every plugin-contributed
    # knowledge connector (S3, etc.) is invisible here and sync fails with
    # "unknown connector type" even though the plugin is installed and
    # enabled for the tenant -- resolve_connector fails closed on an import
    # it can never make.
    volumes:
      - ./capas:/app/capas:ro
    command: ["oc8", "ingestion-worker"]

  scheduler:
    restart: unless-stopped
    <<: *backend-image
    environment: *backend-env
    depends_on:
      migrate:
        condition: service_completed_successfully
      redis:
        condition: service_healthy
    # Needed as of the channel-poll tick (oc8.channels.poll): unlike firing
    # a cron Trigger, polling an approval channel has to load the owning
    # plugin's own code (channels_for_tenant -> capas.contributions.channels_for),
    # the same way backend/worker already do.
    volumes:
      - ./capas:/app/capas:ro
    command: ["oc8", "scheduler"]

  frontend:
    restart: unless-stopped
    build:
      # Community frontend builds from its own allowlisted context. In
      # particular, private enterprise/ and saas/ source must not enter its
      # image layers or source maps.
      context: ./frontend
      dockerfile: Dockerfile
      args:
        VITE_API_URL: /api/v1
    image: oc8-frontend:latest
    environment:
      PORT: "3000"
    depends_on:
      - backend

  caddy:
    restart: unless-stopped
    image: docker.io/library/caddy:2
    depends_on:
      - frontend
      - backend
    ports:
      - "${OC8_HTTP_PORT:-80}:80"
      - "${OC8_HTTPS_PORT:-443}:443"
    environment:
      # Read by the Caddyfile itself ({$OC8_DOMAIN}). The `:80` fallback lives
      # HERE, not in the Caddyfile: `:-` (unlike Caddy's own env-var default)
      # substitutes it whenever OC8_DOMAIN is unset OR empty, which is what an
      # unfilled `OC8_DOMAIN=` line in .env actually produces. Publishing 443
      # unconditionally below is harmless when unused (Caddy simply never
      # binds it without a domain configured).
      OC8_DOMAIN: "${OC8_DOMAIN:-:80}"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - oc8-caddy-data:/data
      - oc8-caddy-config:/config

  ollama:
    restart: unless-stopped
    profiles: ["ollama"]
    image: docker.io/ollama/ollama:latest
    volumes:
      - oc8-ollama:/root/.ollama
    # CPU-only by default. For GPU, add a deploy.resources.reservations.devices
    # block per the ollama docs.

networks:
  default:
  # Where agent containers live. `internal` means no route off the host: they can
  # reach the control plane (which sits on both networks) and NOTHING else --
  # not the database, not the internet. The container is meant to think and act
  # only through oc8's two gateways, and this is what makes that structural
  # rather than a matter of the harness behaving.
  agents:
    internal: true

volumes:
  oc8-pgdata:
  oc8-caddy-data:
  oc8-caddy-config:
  oc8-ollama:
  oc8-miniodata: