# Mesh full-local platform (`mesh start`).
#
# Shipped inside @mesh-tech/mesh-cli so any repo can run the platform locally
# with only Docker installed — no AWS credentials, no VPN, no Pulumi state.
# Evolved from tools/local/docker-compose.yml in mesh-platform; every image is
# pullable (no monorepo build contexts). The codec server and Temporal UI
# gateway require monorepo image builds and join the stack once their images
# are published.
#
# Discovery fabric: ministack (MIT-licensed AWS emulator) provides SSM
# Parameter Store + Secrets Manager + S3 so the registry
# (/mesh-platform/{tenant}/{env}/…) works locally with unchanged code paths —
# services point at it via the SDK-native AWS_ENDPOINT_URL_* env overrides.
# `mesh start` seeds tenant `local` / env `dev`.
#
# SECURITY: every credential in this stack (postgres/postgres, test/test AWS
# keys, the Zitadel masterkey, SpiceDB's local-dev-key, permissive CORS) is a
# LOCAL-ONLY default. Never reuse any of them outside this compose project —
# deployed platforms mint their own secrets. Data/secret ports are bound to
# 127.0.0.1 so none of this is reachable from the LAN.
name: mesh-local

# AWS wiring for in-network services (Hub API/worker join the stack once
# their images are published): merge this anchor into a service's environment
# and its AWS SDK clients hit the local fabric with zero code changes —
# AWS_ENDPOINT_URL is the SDK-native endpoint override.
x-local-aws-env: &local-aws-env
  AWS_ENDPOINT_URL: http://ministack:4566
  AWS_REGION: us-east-2
  AWS_ACCESS_KEY_ID: test
  AWS_SECRET_ACCESS_KEY: test

services:
  database:
    image: postgres:17@sha256:a426e44bac0b759c95894d68e1a0ac03ecc20b619f498a91aae373bf06d8508d
    ports:
      - '127.0.0.1:5433:5432'
    environment:
      PGUSER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: temporal
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init:/docker-entrypoint-initdb.d
    networks:
      - mesh_network
    healthcheck:
      # Single-string CMD-SHELL (an array's extra elements are ignored shell
      # positionals) probing over TCP: the entrypoint's temporary initdb-phase
      # server is socket-only, so -h 127.0.0.1 can't go green until the final
      # server is up — after /docker-entrypoint-initdb.d has created the
      # spicedb/app/hub databases spicedb-migrate needs.
      test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -d temporal -U postgres']
      interval: '10s'
      timeout: '30s'
      retries: 5
      start_period: '20s'

  memcached:
    image: memcached:1.6-alpine@sha256:c29847751abb41f4c268c84fb3087fee05d4edcbda44409ccb5086e26148e8a7
    ports:
      - '11211:11211'
    networks:
      - mesh_network
    command: -m 128
    healthcheck:
      test: ['CMD', 'nc', '-z', 'localhost', '11211']
      interval: 30s
      timeout: 5s
      retries: 3

  elasticsearch:
    image: opensearchproject/opensearch:2.11.1
    ports:
      - '9200:9200'
    environment:
      - cluster.name=temporal-elasticsearch
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - "OPENSEARCH_JAVA_OPTS=-Xms256m -Xmx256m"
      - DISABLE_INSTALL_DEMO_CONFIG=true
      - DISABLE_SECURITY_PLUGIN=true
      - compatibility.override_main_response_version=true
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - elasticsearch_data:/usr/share/elasticsearch/data
    networks:
      - mesh_network
    healthcheck:
      # URL must be quoted: an unquoted & backgrounds curl and the check
      # always passes, letting Temporal start before OpenSearch is up.
      test: ['CMD-SHELL', 'curl -f "http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=10s" || exit 1']
      interval: 30s
      timeout: 10s
      retries: 5

  temporal:
    image: temporalio/auto-setup:1.28.1
    depends_on:
      database:
        condition: service_healthy
      elasticsearch:
        condition: service_healthy
    environment:
      - DB=postgres12
      - DB_PORT=5432
      - POSTGRES_USER=postgres
      - POSTGRES_PWD=postgres
      - POSTGRES_SEEDS=database
      - DYNAMIC_CONFIG_FILE_PATH=/etc/temporal/config/dynamicconfig/local.yaml
      - TEMPORAL_ADDRESS=temporal:7233
      - TEMPORAL_CLI_ADDRESS=temporal:7233
      - ENABLE_ES=true
      - ES_SEEDS=elasticsearch
      - ES_PORT=9200
      - ES_VERSION=v7
      - ES_VIS_INDEX=temporal_visibility_v1_dev
    ports:
      - '7233:7233'
    volumes:
      # Mount ONLY the dynamic config — mounting over /etc/temporal/config
      # would clobber the auto-setup image's config_template.yaml.
      - ./temporal/local.yaml:/etc/temporal/config/dynamicconfig/local.yaml
    networks:
      - mesh_network

  temporal-admin-tools:
    image: temporalio/admin-tools:1.13.4
    depends_on:
      - temporal
    environment:
      - TEMPORAL_ADDRESS=temporal:7233
      - TEMPORAL_CLI_ADDRESS=temporal:7233
    networks:
      - mesh_network
    stdin_open: true
    tty: true

  temporal-ui:
    image: temporalio/ui:2.16.2
    depends_on:
      - temporal
    environment:
      - TEMPORAL_ADDRESS=temporal:7233
      - TEMPORAL_CORS_ORIGINS=http://localhost:3000,http://localhost:8233,http://localhost:8080
      # Land on the seeded local tenant namespace ({tenant}-{env}).
      - TEMPORAL_DEFAULT_NAMESPACE=local-dev
      # Auth comes back with the seeded local Zitadel (mesh start seeds an
      # org + CLI app + test users in a follow-up; until then the UI is open).
      - TEMPORAL_AUTH_ENABLED=false
    ports:
      # 8233 = Temporal's conventional dev-UI port. Deliberately NOT 8081:
      # the legacy tools/local stack serves its UI there, and a shared origin
      # means shared browser localStorage — the UI would auto-redirect to
      # namespaces remembered from the old stack ("namespace not found").
      - '8233:8080'
    networks:
      - mesh_network

  # Zitadel Database
  zitadel-db:
    image: postgres:17@sha256:a426e44bac0b759c95894d68e1a0ac03ecc20b619f498a91aae373bf06d8508d
    ports:
      - '127.0.0.1:5435:5432'
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: zitadel
    volumes:
      - zitadel_postgres_data:/var/lib/postgresql/data
    networks:
      - mesh_network
    healthcheck:
      # -h 127.0.0.1 (TCP) so the socket-only initdb-phase server can't
      # satisfy the probe before first-boot init completes.
      test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -d zitadel -U postgres']
      interval: '10s'
      timeout: '30s'
      retries: 5
      start_period: '20s'

  # One-shot: the machinekey volume must be writable by the zitadel user
  # (uid 1000 — the image runs non-root and a fresh named volume is
  # root-owned; the first-instance PAT write fails otherwise).
  zitadel-machinekey-init:
    image: busybox:1.36
    command: chown -R 1000:1000 /machinekey
    restart: 'no'
    volumes:
      - zitadel_machinekey:/machinekey
    networks:
      - mesh_network

  # Zitadel Identity Server — v4 to match the cloud (approved tenant-auth
  # pattern spec item 7: v4's no-role-claims machine tokens make version
  # parity matter for auth testing).
  # Local mailbox. Zitadel sends real mail for user activation and password
  # resets, and without somewhere to send it those flows cannot be exercised at
  # all locally — the Hub's "resend invitation" and "send password reset email"
  # actions return 200 and the mail goes nowhere. Mailpit accepts everything on
  # SMTP :1025 and shows it at :8025; nothing leaves the machine.
  mailpit:
    image: axllent/mailpit:v1.21
    ports:
      - '127.0.0.1:1025:1025'   # SMTP, for Zitadel
      - '127.0.0.1:8025:8025'   # web UI, for a human
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 'true'
      MP_SMTP_AUTH_ALLOW_INSECURE: 'true'
    healthcheck:
      test: ['CMD', '/mailpit', 'readyz']
      interval: 5s
      timeout: 3s
      retries: 20
    networks:
      - mesh_network

  zitadel:
    image: ghcr.io/zitadel/zitadel:v4.15.3
    depends_on:
      zitadel-db:
        condition: 'service_healthy'
      zitadel-machinekey-init:
        condition: 'service_completed_successfully'
      mailpit:
        condition: 'service_healthy'
    ports:
      - '127.0.0.1:8080:8080'
    environment:
      ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db
      ZITADEL_DATABASE_POSTGRES_PORT: 5432
      ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
      ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
      ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres
      ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres
      ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
      ZITADEL_EXTERNALSECURE: false
      ZITADEL_EXTERNALDOMAIN: localhost
      ZITADEL_EXTERNALPORT: 8080
      ZITADEL_HTTP_CORS_ALLOWORIGIN: "*"
      ZITADEL_HTTP_CORS_ALLOWHEADERS: "*"
      ZITADEL_HTTP_CORS_ALLOWMETHODS: "*"
      ZITADEL_HTTP_CORS_ALLOWCREDENTIALS: true
      ZITADEL_HTTP_CSRF_DISABLED: true
      # v4 defaults new instances to the login-v2 UI, which is a SEPARATE
      # container this stack doesn't run — without this, the authorize flow
      # redirects to /ui/v2/login and dead-ends. Keep the built-in v1 login.
      # (The seeder also flips the instance feature via API, belt-and-braces.)
      ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: false
      # Platform org (approved Zitadel tenant-auth pattern): `mesh` is the
      # home of platform-layer creds (mesh-cli project, test users; Hub when
      # its images land) — same org name as the cloud platform tenant. App
      # tenants get their own orgs, auto-created by `mesh dev --local`
      # (ZitadelTenantIdentity parity); an app with no `mesh:tenant` config
      # lands in the default app-tenant org `local`.
      ZITADEL_FIRSTINSTANCE_ORG_NAME: mesh
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: admin@local.mesh
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: LocalDev1!
      ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
      # Bootstrap machine user for the `mesh start` seeder: its PAT lands on
      # the machinekey volume and the CLI reads it (docker compose cp) to
      # create the Platform project, Mesh CLI OIDC app, and test users.
      # First-instance settings only apply on the FIRST init — a pre-existing
      # zitadel volume needs `mesh stop --destroy` to pick this up.
      ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: local-seeder
      ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Local Seeder
      ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: '2099-01-01T00:00:00Z'
      ZITADEL_FIRSTINSTANCE_PATPATH: /machinekey/pat.txt
      # SMTP for the default instance, pointed at the local mailbox above.
      # DEFAULTINSTANCE settings apply at FIRST INIT only, same as the
      # FIRSTINSTANCE block — a pre-existing zitadel volume keeps whatever it
      # was initialised with, so `mesh start` also reconciles this over the
      # admin API on every run (see seed-zitadel.ts). The env is here so a
      # fresh `mesh start` has working mail before the seeder even runs.
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST: 'mailpit:1025'
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_USER: ''
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_PASSWORD: ''
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_TLS: false
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: 'no-reply@local.mesh'
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME: 'Mesh (local)' 
    command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --init-projections=true'
    volumes:
      - zitadel_machinekey:/machinekey
    networks:
      - mesh_network

  # SpiceDB migration (runs to completion before spicedb serves)
  spicedb-migrate:
    image: authzed/spicedb:v1.42.1
    command: datastore migrate head
    restart: 'no'
    depends_on:
      database:
        condition: service_healthy
    environment:
      SPICEDB_DATASTORE_ENGINE: postgres
      SPICEDB_DATASTORE_CONN_URI: postgres://postgres:postgres@database:5432/spicedb?sslmode=disable
    networks:
      - mesh_network

  # SpiceDB authorization engine
  spicedb:
    image: authzed/spicedb:v1.42.1
    command: serve
    depends_on:
      spicedb-migrate:
        condition: service_completed_successfully
    environment:
      SPICEDB_GRPC_PRESHARED_KEY: local-dev-key
      SPICEDB_DATASTORE_ENGINE: postgres
      SPICEDB_DATASTORE_CONN_URI: postgres://postgres:postgres@database:5432/spicedb?sslmode=disable
      SPICEDB_HTTP_ENABLED: true
    ports:
      - '127.0.0.1:50051:50051'
      - '127.0.0.1:8443:8443'
    networks:
      - mesh_network
    healthcheck:
      test: ['CMD', 'grpc_health_probe', '-addr=localhost:50051']
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

  # ministack — the local AWS fabric (SSM Parameter Store + Secrets Manager +
  # S3, all services on one port). MIT-licensed LocalStack replacement (design
  # §2.5). Point AWS SDK clients at it via AWS_ENDPOINT_URL=http://localhost:4566.
  #
  # State persists across restarts (PERSIST_STATE saves to the volume on
  # graceful shutdown, reloads on boot): the registry and app secrets that
  # `mesh dev` provisions live ONLY here, so without this a Docker Desktop
  # restart silently wiped every provisioned app while its services kept
  # running stale. `mesh start` still re-seeds platform state idempotently;
  # `mesh stop --destroy` removes the volume like every other data volume.
  ministack:
    image: ministackorg/ministack:1.4.1
    ports:
      - '127.0.0.1:4566:4566'
    environment:
      MINISTACK_REGION: us-east-2
      PERSIST_STATE: '1'
      STATE_DIR: /var/lib/ministack-state
    volumes:
      - ministack_state:/var/lib/ministack-state
    networks:
      - mesh_network
    healthcheck:
      # Pure-Python probe — the ministack image ships no curl/wget.
      test: ['CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 5s

  # StackPort — local AWS console over the ministack fabric (MIT). Browse and
  # inspect the registry (SSM), Secrets Manager, and S3 artifacts visually;
  # E4's terminal complement until the local Hub lands. First consumer of the
  # x-local-aws-env anchor. Host 4567 (container serves on 8080 = Zitadel's
  # host port).
  stackport:
    image: davireis/stackport:0.3.4
    ports:
      - '127.0.0.1:4567:8080'
    environment: *local-aws-env
    depends_on:
      ministack:
        condition: service_healthy
    networks:
      - mesh_network

  # ── Observability — the SAME backends the hosted platform runs ──────────
  # OTel collector → Loki (logs, OTLP) + Tempo (traces, OTLP gRPC), Prometheus
  # scraping the collector's metrics exporter. hub-api's LOKI_URL / TEMPO_URL /
  # PROMETHEUS_URL point here, so the Hub's logs/traces/metrics views run the
  # exact hosted code paths. `mesh dev --local` service logs are tailed from
  # ${MESH_LOCAL_LOGS} (written via tmux pipe-pane) and labeled with the same
  # k8s_namespace_name/k8s_deployment_name scheme the in-cluster
  # k8sattributes processor produces.
  loki:
    image: grafana/loki:3.4.2
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - '3100:3100'
    volumes:
      # The image's default local-config keeps all state under /tmp/loki.
      - loki_data:/tmp/loki
    networks:
      - mesh_network

  tempo:
    image: grafana/tempo:2.7.1
    command: -config.file=/etc/tempo/tempo.yaml
    ports:
      - '3200:3200'
    volumes:
      - ./observability/tempo.yaml:/etc/tempo/tempo.yaml
      - tempo_data:/var/tempo
    networks:
      - mesh_network

  prometheus:
    image: prom/prometheus:v3.2.1
    ports:
      - '9090:9090'
    volumes:
      - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml
      # External-service probe targets (blackbox file_sd) — written by
      # `mesh dev --externals` / ExternalService wiring, same labels the hosted
      # Probe CRs carry (type/tenant/env/app/external_service).
      - ${MESH_LOCAL_PROBES:-/tmp/mesh-local/probes}:/etc/prometheus/probes
      - prometheus_data:/prometheus
    networks:
      - mesh_network

  # Blackbox exporter — the same prober the hosted monitoring stack runs for
  # ExternalService health checks; Prometheus drives it via the
  # external-services file_sd job.
  blackbox-exporter:
    image: prom/blackbox-exporter:v0.25.0
    command: --config.file=/etc/blackbox/blackbox.yml
    ports:
      - '9115:9115'
    networks:
      - mesh_network
    volumes:
      # IPv4-pinned http_2xx/tcp_connect modules — host.docker.internal's
      # IPv6 ULA is unreachable from the container network.
      - ./observability/blackbox.yml:/etc/blackbox/blackbox.yml
    extra_hosts:
      # Probe targets run on the HOST (mesh dev services/mocks).
      - 'host.docker.internal:host-gateway'

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.121.0
    command: --config=/etc/otelcol/config.yaml
    ports:
      - '4317:4317' # OTLP gRPC (apps: OTEL_EXPORTER_OTLP_ENDPOINT)
      - '4318:4318' # OTLP HTTP
      - '13133:13133' # health_check extension (mesh status probe)
    volumes:
      - ./observability/otel-collector.yaml:/etc/otelcol/config.yaml
      # mesh dev --local writes {namespace}/{service}.log here (the CLI passes
      # MESH_LOCAL_LOGS on every compose call; the fallback keeps raw
      # `docker compose` usable).
      - ${MESH_LOCAL_LOGS:-/tmp/mesh-local/logs}:/var/log/mesh-apps:ro
    depends_on:
      - loki
      - tempo
    networks:
      - mesh_network
    healthcheck:
      test: ['CMD', '/otelcol-contrib', '--version']
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  postgres_data:
    driver: local
  ministack_state:
    driver: local
  elasticsearch_data:
    driver: local
  zitadel_postgres_data:
    driver: local
  zitadel_machinekey:
    driver: local
  loki_data:
    driver: local
  tempo_data:
    driver: local
  prometheus_data:
    driver: local

networks:
  mesh_network:
    driver: bridge
