name: rsync-ai # ───────────────────────────────────────────────────────────────────────────── # Kafka client security, for a broker this file did not start. # # Point KAFKA_BROKERS at a managed cluster (MSK, Confluent Cloud, Aiven) and the # clients below still default to PLAINTEXT unless these names reach them. Compose # injects only what a service DECLARES, so a credential sitting in .env reaches # interpolation but not the container: the operator sets a password, the client # connects anonymously, and an ACL-enforcing broker rejects it with an error # naming the BROKER rather than the missing setting. # # Every key defaults to empty and empty means "unset" -- kafkaclient reads # `protocolWasSet := c.SecurityProtocol != ""` (shared/go/kafkaclient/config.go), # so the bundled-broker default renders exactly as it did before these keys # existed. Kept key-for-key in lockstep with the same anchor in # docker-compose.quickstart.yml; tests/test_compose_kafka_security_env.py fails # if the two drift. # ───────────────────────────────────────────────────────────────────────────── x-kafka-security: &kafka-security KAFKA_SECURITY_PROTOCOL: ${KAFKA_SECURITY_PROTOCOL:-} KAFKA_SASL_MECHANISM: ${KAFKA_SASL_MECHANISM:-} KAFKA_SASL_USERNAME: ${KAFKA_SASL_USERNAME:-} KAFKA_SASL_PASSWORD: ${KAFKA_SASL_PASSWORD:-} # OIDC client-credentials, for KAFKA_SASL_MECHANISM=OAUTHBEARER. KAFKA_SASL_OAUTHBEARER_CLIENT_ID: ${KAFKA_SASL_OAUTHBEARER_CLIENT_ID:-} KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET: ${KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET:-} KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT: ${KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT:-} KAFKA_SASL_OAUTHBEARER_SCOPE: ${KAFKA_SASL_OAUTHBEARER_SCOPE:-} KAFKA_SASL_OAUTHBEARER_EXTENSIONS: ${KAFKA_SASL_OAUTHBEARER_EXTENSIONS:-} # Re-permits a plain-http token endpoint off loopback, which every runtime # otherwise refuses at start-up (the client-credentials grant POSTs # CLIENT_SECRET on every fetch). Carried here for ONE reason: kafka-connect # and the kafka-init job read their whole security profile from this anchor, # so without a passthrough a rig with a throwaway http IdP can configure the # Go and Python services but not the JVM ones -- the same image behaving # differently under compose than under the chart, which does make it # reachable. The default is empty, i.e. unset, i.e. the refusal stays armed; # a passthrough is not a setting. Never give it a value in this file or in # docker-compose.prod.yml -- # llm-service/tests/test_insecure_token_endpoint_rule_is_one_rule.py fails if # any file but the Kafka security matrix harness sets it to true. KAFKA_SASL_OAUTHBEARER_ALLOW_INSECURE_TOKEN_ENDPOINT: ${KAFKA_SASL_OAUTHBEARER_ALLOW_INSECURE_TOKEN_ENDPOINT:-} # JVM-only; in this file only kafka-connect has a JVM. Carried anyway because # Debezium's schema-history client is configured from the connector # container's environment, so the name has to survive the trip. KAFKA_SASL_OAUTHBEARER_LOGIN_CALLBACK_HANDLER: ${KAFKA_SASL_OAUTHBEARER_LOGIN_CALLBACK_HANDLER:-} # AWS_MSK_IAM. The Go services implement it (kafkaclient/tokenauth/msk.go); the # Python tier and the JVM Connect image do not, so an IAM-only cluster runs the # Go data plane and leaves the CDC profile unable to authenticate. KAFKA_AWS_REGION: ${KAFKA_AWS_REGION:-} # Paths INSIDE the container. Mount the files yourself — this file cannot guess # a host path. KAFKA_SSL_CA_LOCATION: ${KAFKA_SSL_CA_LOCATION:-} # mTLS, the two-path shape the Go and Python clients read. KAFKA_SSL_CERT_LOCATION: ${KAFKA_SSL_CERT_LOCATION:-} KAFKA_SSL_KEY_LOCATION: ${KAFKA_SSL_KEY_LOCATION:-} # The SAME keypair in the shape a JVM can load: ONE file holding chain + key. # Optional -- kafka-init and kafka-connect build that file from the pair above # when this is unset (the connect image's connect-entrypoint.sh). Set it only # to use a combined file you already have; it then wins over the pair. Inert # in the Go and Python services. KAFKA_SSL_KEYSTORE_LOCATION: ${KAFKA_SSL_KEYSTORE_LOCATION:-} # Disables certificate verification, and is logged unconditionally when set: a # self-signed lab, not prod. # # TWO SPELLINGS FOR ONE SETTING, and that is not tidy-uppable here. Python # reads only KAFKA_SSL_SKIP_VERIFY (kafka_security.py:54); Go reads both # (config.go:80 and the alias at :115). Shipping the Go spelling alone leaves # verification silently ON for the Python half -- the exact split the Go alias # was added to end. So both names are emitted, each defaulting to the other, # and an operator who sets either one gets both. # TWO SPELLINGS FOR ONE SETTING, and that is not tidy-uppable here. Python # reads only KAFKA_SSL_SKIP_VERIFY (kafka_security.py:54); Go reads both # (config.go:80 and the alias at :115). Shipping the Go spelling alone leaves # verification silently ON for the Python half -- the exact split the Go alias # was added to end. So both names are emitted, each defaulting to the other, # and an operator who sets either one gets both. Identical to the pair in # docker-compose.quickstart.yml; the key SETS of the two anchors must match, # which llm-service/tests/test_compose_kafka_security_env.py asserts. KAFKA_SSL_SKIP_VERIFY: ${KAFKA_SSL_SKIP_VERIFY:-${KAFKA_SSL_INSECURE_SKIP_VERIFY:-}} KAFKA_SSL_INSECURE_SKIP_VERIFY: ${KAFKA_SSL_INSECURE_SKIP_VERIFY:-${KAFKA_SSL_SKIP_VERIFY:-}} # ───────────────────────────────────────────────────────────────────────────── # Build info baked into the api-gateway, orchestrator, temporal-adapter and # llm-service images: each serves it on GET /version, and the admin drift check # (GET /api/v1/admin/drift) compares the commits. Compose cannot run git, so the # caller exports these -- scripts/deploy-service.sh does, and so do the upgrade # steps in docs/deployment/self-hosting.md. A build without them bakes in # "dev"/"unknown", which the drift check reports as "commit unknown". # Published images get the release sha from .github/workflows/docker-publish.yml. # ───────────────────────────────────────────────────────────────────────────── x-build-info: &build-info GIT_COMMIT: ${GIT_COMMIT:-dev} BUILD_TIME: ${BUILD_TIME:-unknown} services: # ============================================ # INFRASTRUCTURE # ============================================ # NOTE: this bundle ships an OTel Collector but no telemetry backend. # Services export to otel-collector:4317; the collector forwards to whatever # OTLP endpoint OTLP_BACKEND_ENDPOINT names. Leave it at the default and # nothing is stored — the supported story here is `docker compose logs`. # Read-only Docker socket proxy. Lets api-gateway surface LIVE MCP container # status (docker_status=running, port), and lets the orchestrator's sentinel tell # a connector this host never deployed from one that is deployed and broken — # both without mounting the raw socket or running as root, since it can only GET # /containers/json (POST disabled). Wired via MCP_DOCKER_API_URL on both. Prod # defines its own copy for Traefik in docker-compose.prod.yml; this base copy # also serves api-gateway and the orchestrator locally. docker-socket-proxy: # Pinned, not `latest`. This container IS the docker-socket boundary, and `latest` # moved v0.4.2 -> v0.5.0 on 2026-07-27 with no change on our side; deploy-service.sh # runs `--force-recreate`, so that arrives on the next deploy of this service. # v0.5.0 is the digest `latest` resolves to today, so this pin froze it, not changed it. image: tecnativa/docker-socket-proxy:v0.5.0 container_name: rsync-ai-docker-socket-proxy logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped environment: CONTAINERS: 1 NETWORKS: 1 SERVICES: 1 TASKS: 1 VERSION: 1 POST: 0 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - default # PostgreSQL Database (with CDC/Logical Replication enabled) postgres: image: postgres:16-alpine container_name: postgres logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" ports: # Every host port in this file binds 127.0.0.1. A port with no host IP binds # 0.0.0.0, and Docker writes its own iptables rules AHEAD of ufw/firewalld, # so "the firewall is on" does not close it -- on a laptop that is the coffee # shop's Wi-Fi reaching an unauthenticated Postgres, Kafka, Redis and the # orchestrator's internal routes. The two user-facing ports (frontend, # api-gateway) read RSYNC_BIND_ADDR like docker-compose.quickstart.yml does, # so a dev who wants the UI on the LAN sets that one knob and exposes only # those two. Nothing else here is meant to be reachable off the box. - "127.0.0.1:${RSYNC_HP_POSTGRES:-5432}:5432" environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=pipeline_db command: - "postgres" - "-c" - "wal_level=logical" - "-c" - "max_wal_senders=1" - "-c" - "max_replication_slots=1" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user -d pipeline_db"] interval: 5s timeout: 5s retries: 5 # Kafka (KRaft Mode - No ZooKeeper) # Uses apache/kafka — Apache 2.0 licensed (replaces Confluent cp-kafka which is CCL licensed) # apache/kafka uses the same KAFKA_* env var convention as cp-kafka — no rename needed. kafka: image: apache/kafka:3.7.0 hostname: kafka container_name: rsync-ai-kafka logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" ports: - "127.0.0.1:${RSYNC_HP_KAFKA:-9092}:9092" environment: KAFKA_NODE_ID: 1 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT' KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092' KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 # Partition counts for the two INTERNAL topics. Kafka defaults both to 50, # and nothing in this repo overrode them, so a single bundled broker was # carrying 100 internal partitions -- each one a log dir with its own # segment plus offset/time index, against KAFKA_HEAP_OPTS of -Xmx512m. # This broker serves one platform with a handful of consumer groups; 8 is # ample coordinator spread and cuts the internal partition count by ~84%. # # Takes effect on a FRESH cluster ONLY. __consumer_offsets and # __transaction_state are created once, on first use, with whatever this # said at the time; an existing volume keeps its 50 and ignores the # setting. So this buys nothing on a running deployment and everything on # a new self-host install -- do NOT delete the topics to "apply" it, that # discards every committed offset. KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS: 8 KAFKA_TRANSACTION_STATE_LOG_NUM_PARTITIONS: 8 # Consumer group rebalance settings (CRITICAL for multi-worker stability) KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 3000 KAFKA_GROUP_MAX_SESSION_TIMEOUT_MS: 180000 KAFKA_GROUP_MIN_SESSION_TIMEOUT_MS: 6000 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_PROCESS_ROLES: 'broker,controller' KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093' # Bind to 0.0.0.0 (canonical fix for i/o timeouts) KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092' KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT' KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' KAFKA_LOG_DIRS: '/var/lib/kafka/data' # Broker cap on a single record. The 1 MB default rejects CDC change events for # rows with large JSON/text/blob columns (RecordTooLargeException → Debezium task # FAILED → restart loop). Kept in LOCKSTEP with CONNECT_PRODUCER_MAX_REQUEST_SIZE # (kafka-connect, producer side) and the kafka-sink-worker ReaderConfig.MaxBytes # (consumer side, which must exceed this). Topics inherit this broker default. KAFKA_MESSAGE_MAX_BYTES: 15728640 # Retention policy for topics rsync.ai does NOT create itself. # # This is not redundant with kafka/topology.go: Debezium auto-creates the # rsync.cdc-* topics before the orchestrator's topology manager would, so # those topics carry NO per-topic overrides and take whatever the broker # says. Verified on the GCP host 2026-09-23 — `kafka-configs.sh --describe` # returns an empty dynamic config set for the live CDC topic, so the code's # values had never applied to it. This line is therefore the only place the # 7-day policy actually reaches them. # # 168h/delete matches Apache's own defaults, so this changes no behaviour # today. It pins it: an image whose defaults differ can no longer silently # re-open the unbounded-growth hole that fills the disk and stops BOTH data # planes. Topics rsync.ai creates explicitly still carry their own config, and # the one deliberate never-expire exception (the Debezium schema-history # topic) sets retention.ms=-1 per topic and overrides this. KAFKA_LOG_RETENTION_HOURS: 168 KAFKA_LOG_CLEANUP_POLICY: 'delete' CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk' KAFKA_HEAP_OPTS: "-Xms256m -Xmx512m -XX:+ExitOnOutOfMemoryError" volumes: - kafka_data:/var/lib/kafka/data healthcheck: test: /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1 interval: 30s timeout: 15s retries: 5 start_period: 60s # One-shot bootstrapper for the platform topics (pipeline.domain.events, # pii.scan.request, pii.scan.response); see scripts/kafka-init-new-topics.sh. # Ensures test suites that expect topics to exist are deterministic. kafka-init: image: apache/kafka:3.7.0 container_name: rsync-ai-kafka-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false entrypoint: ["/bin/bash", "-lc"] # A bare `command: |` block is split into argv by Compose, so `bash -lc` only # ever received the first token ("export") and the container exited 0 without # running the script -- a green init that created nothing. The single-element # list keeps the whole script as one argument. Verified with # `docker compose config`: `command:` must render as ONE list item. command: - | export PATH="/opt/kafka/bin:$$PATH"; # Same broker the orchestrator and kafka-connect use. Hardcoding # kafka:29092 here gave KAFKA_REPLICATION_FACTOR two meanings in one # file -- "RF on the customer's BYO cluster" for every other service, # "RF on the bundled single broker" for this one -- so the BYO operator # who correctly sets RF=3 is the one whose bootstrap dies with # InvalidReplicationFactorException against the bundled broker. # A BYO cluster with SASL/TLS also needs a client.properties, which # kafka-init-new-topics.sh now builds from the x-kafka-security env # merged in below and passes as --command-config. export KAFKA_BROKER="${KAFKA_BROKERS:-kafka:29092}"; export PARTITIONS="3"; bash /scripts/kafka-init-new-topics.sh environment: # The topic bootstrapper is a Kafka CLIENT too. Without these it speaks # PLAINTEXT to a SASL listener, which does not error -- it BLOCKS in the # handshake until the CLI's default timeout gives up, so the run appears # to hang rather than to be misconfigured. See x-kafka-security at the top # of this file; scripts/kafka-init-new-topics.sh turns them into the # --command-config the Kafka CLIs need. <<: *kafka-security # The bootstrapper must land on the same names the services subscribe to. # Bare `-`, matching kafkaclient.TopicPrefix(): an explicitly empty prefix # is the migration lever and must survive, which `:-` would overwrite. KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} # Same durability knobs the orchestrator reads, so topics created by the # bootstrapper and topics created at runtime agree. Passed as env rather # than exported in `command:` above so an operator override actually # reaches the script -- a hardcoded `export REPLICATION_FACTOR="1"` there # would win over anything set here. KAFKA_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR:-} KAFKA_MIN_INSYNC_REPLICAS: ${KAFKA_MIN_INSYNC_REPLICAS:-} volumes: - ./scripts:/scripts:ro restart: "no" # Schema Registry — OPTIONAL, Apache 2.0 (Apicurio replaces Confluent cp-schema-registry) # Enable with: docker compose --profile schema-registry up -d schema-registry: image: apicurio/apicurio-registry:3.0.6 hostname: schema-registry container_name: schema-registry logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" profiles: - schema-registry depends_on: kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false ports: - "127.0.0.1:${RSYNC_HP_SCHEMA_REGISTRY:-8085}:8080" environment: # No KAFKA_*/REGISTRY_* keys here, on purpose. Apicurio 3 reads apicurio.*-prefixed # config; KAFKA_BOOTSTRAP_SERVERS and REGISTRY_KAFKASQL_TOPIC (the Apicurio-2 spelling) # occur ZERO times in the runner jar, in all 356 shipped lib jars, and in the # entrypoint -- they were inert. Storage here is the image default sql/H2, NOT kafkasql, # so there is no broker to name. # # THE x-kafka-security ANCHOR ABOVE CANNOT SECURE THIS IMAGE, and an earlier version of # this comment wrongly said a kafkasql switch would need it. All 17 of its keys are as # inert here as the two deleted above: in both the env spelling and SmallRye's dotted # equivalent (34 spellings) they occur 0 times in the runner jar (2,419 entries) and # 0 times across all 356 lib jars (57,339 entries) -- against live controls measured in # the same scan (security.protocol 3 runner / 6 lib entries, sasl.mechanism 7 / 3, # bootstrap.servers 8 / 5). Merging it would read as secured and speak PLAINTEXT. # # What the image DOES read for a kafkasql client is declared by # io/apicurio/registry/storage/impl/kafkasql/KafkaSqlFactory.class in the runner jar: # APICURIO_KAFKASQL_SECURITY_PROTOCOL plus APICURIO_KAFKASQL_SECURITY_SASL_{ENABLED, # MECHANISM,CLIENT_ID,CLIENT_SECRET,TOKEN_ENDPOINT,LOGIN_CALLBACK_HANDLER_CLASS}. Image # defaults (application.properties): sasl.enabled=false (:22), mechanism=OAUTHBEARER # (:196), client-id/client-secret=sa (:157/:6), token.endpoint=http://localhost:8090 # (:87), login.callback.handler.class=Strimzi's JaasClientOauthLoginCallbackHandler # (:162). Two of those properties are spelled with a HYPHEN -- `...sasl.client-id`, # `...sasl.client-secret` -- and the env names above are SmallRye's documented mapping # of dots AND dashes to `_`, so the underscore spelling is correct despite the hyphen. # # A real switch is therefore APICURIO_STORAGE_KIND=kafkasql + # APICURIO_KAFKASQL_BOOTSTRAP_SERVERS + that family, NOT the anchor. # test_compose_kafka_security_env.py requires exactly that family (and refuses to let # the anchor stand in for it) for any service naming the Apicurio broker key. # # NOTE: H2 here is in-memory and this service declares no volumes: -- schemas do NOT # survive a restart. QUARKUS_HTTP_PORT: 8080 healthcheck: test: curl -f http://localhost:8080/health/ready || exit 1 interval: 10s timeout: 5s retries: 5 # ============================================ # TEMPORAL - Workflow Orchestration # ============================================ # Temporal Server temporal: image: temporalio/auto-setup:1.22.7 container_name: temporal logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: postgres: condition: service_healthy # Other half of docker-compose.byo-postgres.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false environment: - DB=postgresql - DB_PORT=5432 - POSTGRES_USER=user - POSTGRES_PWD=password - POSTGRES_SEEDS=postgres - DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development-sql.yaml ports: - "127.0.0.1:${RSYNC_HP_TEMPORAL:-7233}:7233" healthcheck: test: ["CMD", "tctl", "--address", "temporal:7233", "workflow", "list"] interval: 10s timeout: 5s retries: 5 volumes: - ./deploy/temporal:/etc/temporal/config/dynamicconfig # Temporal UI temporal-ui: image: temporalio/ui:2.21.3 container_name: temporal-ui logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: - temporal environment: - TEMPORAL_ADDRESS=temporal:7233 - TEMPORAL_CORS_ORIGINS=http://localhost:3000 ports: - "127.0.0.1:${RSYNC_HP_TEMPORAL_UI:-8233}:8080" # Temporal Admin Tools temporal-admin-tools: image: temporalio/admin-tools:1.22.7 container_name: temporal-admin-tools logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: - temporal environment: - TEMPORAL_ADDRESS=temporal:7233 stdin_open: true tty: true # Temporal Adapter (Bridges Temporal ↔ Kafka) temporal-adapter: build: # Root context: the adapter `replace`s ../shared/go/kafkaclient, which a # ./backend-temporal-adapter context cannot reach. Matches api-gateway # and orchestrator, which already build from the repo root. context: . dockerfile: backend-temporal-adapter/Dockerfile args: *build-info container_name: rsync-ai-temporal-adapter depends_on: temporal: condition: service_healthy kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false environment: # Kafka client security for a BYO/managed broker. See x-kafka-security at # the top of this file; every key defaults to empty and empty means unset, # so the bundled broker is unaffected. <<: *kafka-security TEMPORAL_ADDRESS: "temporal:7233" KAFKA_BOOTSTRAP_SERVERS: "${KAFKA_BROKERS:-kafka:29092}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: "${KAFKA_TOPIC_PREFIX-rsync.}" LOG_FORMAT: "json" LOG_LEVEL: "info" ENVIRONMENT: "development" # SECURITY: Must match api-gateway/orchestrator ENCRYPTION_KEY for decrypting connection configs in healing activities ENCRYPTION_KEY: "${ENCRYPTION_KEY:-dev-encryption-key-32-bytes-long!!}" # Database (StateUpdateActivity / connection validation lookups) POSTGRES_HOST: "postgres" POSTGRES_PORT: "5432" POSTGRES_USER: "user" POSTGRES_PASSWORD: "password" POSTGRES_DB: "pipeline_db" # V2 Correlation Store (Phase D) REDIS_ADDRESS: "redis:6379" REDIS_PASSWORD: "${REDIS_PASSWORD:-}" # Scheduled saved-query model runs (RunModelActivity) call the gateway's internal # endpoint over authenticated S2S. Without both of these a fired schedule fails # closed instead of rebuilding its table. API_GATEWAY_URL: "http://api-gateway:8080" INTERNAL_SERVICE_SECRET: "${INTERNAL_SERVICE_SECRET:-}" # OpenTelemetry OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317" OTEL_SERVICE_NAME: "temporal-adapter" OTEL_ENABLED: "${OTEL_ENABLED:-false}" ports: - "127.0.0.1:${RSYNC_HP_TEMPORAL_ADAPTER:-8082}:8082" volumes: # Required for connector filesystem checks + (optional) connector generation output - ./shared:/app/shared extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # ============================================ # CDC & DATA MOVEMENT # ============================================ # Kafka Connect (Debezium) — required for CDC pipelines kafka-connect: build: context: ./shared/internal/infra/kafka-connect dockerfile: Dockerfile container_name: kafka-connect # Without this the container defaults to RestartPolicy=no, and a single OOM # kill stops CDC capture PERMANENTLY: Debezium is the only producer into the # rsync.cdc-* topics, so the sink drains what is already there, every topic # reaches lag 0, and the pipeline still reads `running` in the control plane. # A dead producer and a caught-up one are indistinguishable from lag alone. # Measured on the demo VM 2026-09-23: 877k source rows silently uncaptured # until the container was started by hand. kafka-mcp-sink-mcp, the consuming # half of the same path, has carried this policy all along. restart: unless-stopped logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false ports: - "127.0.0.1:${RSYNC_HP_CONNECT:-8083}:8083" environment: # ── debezium/connect env-var convention (NOT Confluent cp-kafka-connect) ── # The Debezium image's entrypoint reads these UNPREFIXED names to build # connect-distributed.properties. The old CONNECT_* names were silently # ignored → "CONFIG_STORAGE_TOPIC must be set" crash. BOOTSTRAP_SERVERS: "${KAFKA_BROKERS:-kafka:29092}" GROUP_ID: "rsync-connect-cluster" CONFIG_STORAGE_TOPIC: "_rsync-connect-configs" OFFSET_STORAGE_TOPIC: "_rsync-connect-offsets" STATUS_STORAGE_TOPIC: "_rsync-connect-status" # Replication factor for Connect's three internal topics. # # These MUST carry the CONNECT_ prefix. The unprefixed spelling used here # before (CONFIG_STORAGE_REPLICATION_FACTOR: 1) was silently ignored: the # entrypoint hand-exports a FIXED list of unprefixed names (BOOTSTRAP_SERVERS, # GROUP_ID, the three *_STORAGE_TOPICs, the converters, the flush timers) and # the string "REPLICATION" appears nowhere in it, so anything else unprefixed # is dropped without a word. It read as working only because the value being # ignored (1) happened to equal the image's shipped default. Verified against # quay.io/debezium/connect:3.1.3.Final: with the unprefixed name set to 7 the # generated connect-distributed.properties still said 1. # # min.insync.replicas has no Connect worker property, so RF is the only lever # here -- which makes it the one that matters. On a BYO cluster whose broker # default is min.insync.replicas=2 (MSK, most managed clusters running a # pre-3.7 broker), RF=1 internal topics are created successfully and are then # unwritable: the worker starts, the REST API answers, and storing a connector # config hangs. CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-1}" CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-1}" CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-1}" KEY_CONVERTER: "org.apache.kafka.connect.json.JsonConverter" VALUE_CONVERTER: "org.apache.kafka.connect.json.JsonConverter" OFFSET_FLUSH_INTERVAL_MS: 10000 ADVERTISED_HOST_NAME: "kafka-connect" # JSON converters without embedded schemas (Debezium honors CONNECT_-prefixed # extras as additional worker properties). CONNECT_KEY_CONVERTER_SCHEMAS_ENABLE: "false" CONNECT_VALUE_CONVERTER_SCHEMAS_ENABLE: "false" CONNECT_PRODUCER_ACKS: "all" CONNECT_PRODUCER_RETRIES: 10 # Worker-level producer cap so EVERY connector (incl. Debezium CDC) may emit records # for rows with large JSON/text/blob columns instead of failing the task with # RecordTooLargeException. Worker-level (not per-connector producer.override.*) so it # needs no client-config override policy. LOCKSTEP with KAFKA_MESSAGE_MAX_BYTES (broker) # and the kafka-sink-worker ReaderConfig.MaxBytes (consumer). CONNECT_PRODUCER_MAX_REQUEST_SIZE: 15728640 CONNECT_CONSUMER_AUTO_OFFSET_RESET: "earliest" # ── BYO-Kafka security: SASL / TLS ─────────────────────────────────────── # Mapping convention, read out of the pinned base image's # /docker-entrypoint.sh (quay.io/debezium/connect:3.1.3.Final, the # "Process all environment variables that start with 'CONNECT_'" loop): # every env var matching ^CONNECT_ becomes one connect-distributed.properties # line -- strip CONNECT_, lowercase, translate '_' to '.'. So # CONNECT_SASL_JAAS_CONFIG -> sasl.jaas.config. A variable that does NOT # match ^CONNECT_ is discarded in silence, which is why these carry the # prefix while BOOTSTRAP_SERVERS/GROUP_ID/*_STORAGE_TOPIC above deliberately # do not (those few are re-exported as CONNECT_* by the entrypoint itself). # # The KAFKA_* contract every other service reads (x-kafka-security at the # top of this file) still arrives here, because this image runs its own # entrypoint first (shared/internal/infra/kafka-connect/connect-entrypoint.sh, # the same code Helm runs) and it derives the CONNECT_* lines from it: # * KAFKA_SASL_USERNAME/PASSWORD, or the KAFKA_SASL_OAUTHBEARER_* set, # become the JAAS line for the mechanism; # * KAFKA_SSL_CERT_LOCATION + KAFKA_SSL_KEY_LOCATION become the one-file # PEM keystore a JVM needs (KAFKA_SSL_KEYSTORE_LOCATION, if set, wins). # Mount the certificate files into this container too, at the same paths. <<: *kafka-security # # Connect needs this in THREE places. The worker's own coordination/admin # client is a different client from the producer each source task writes # with and the consumer each sink task reads with, and the task-level ones # do NOT inherit reliably. Configure only the worker and the failure is # silent-partial: the worker authenticates, joins the group, serves the REST # API and reports connectors RUNNING, while every task fails its own # handshake and not one row moves. # # Unset, these render the pre-existing local behaviour exactly: # security.protocol=PLAINTEXT, and sasl.mechanism is inert because no SASL # handshake is attempted. CONNECT_SECURITY_PROTOCOL: "${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}" CONNECT_SASL_MECHANISM: "${KAFKA_SASL_MECHANISM:-PLAIN}" CONNECT_PRODUCER_SECURITY_PROTOCOL: "${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}" CONNECT_PRODUCER_SASL_MECHANISM: "${KAFKA_SASL_MECHANISM:-PLAIN}" CONNECT_CONSUMER_SECURITY_PROTOCOL: "${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}" CONNECT_CONSUMER_SASL_MECHANISM: "${KAFKA_SASL_MECHANISM:-PLAIN}" # # An OVERRIDE, normally left unset: the entrypoint builds the JAAS line from # KAFKA_SASL_USERNAME/PASSWORD (or the OAuth set), choosing the login module # the mechanism needs and escaping the values for the two grammars they # cross. Set KAFKA_SASL_JAAS_CONFIG only for a login module it has no arm # for; a non-empty value is used as-is and nothing is built. CONNECT_SASL_JAAS_CONFIG: "${KAFKA_SASL_JAAS_CONFIG:-}" CONNECT_PRODUCER_SASL_JAAS_CONFIG: "${KAFKA_SASL_JAAS_CONFIG:-}" CONNECT_CONSUMER_SASL_JAAS_CONFIG: "${KAFKA_SASL_JAAS_CONFIG:-}" # # Truststore PATH -- and only the path -- is a bare pass-through key with no # `${...:-}` default. A key with no value is OMITTED from the container # environment when the host variable is unset, whereas `${VAR:-}` renders it # present-but-empty, and the debezium entrypoint writes every CONNECT_ var it # sees, empty included. Kafka then reads an empty ssl.truststore.location as a # real path: "Failed to load PEM SSL keystore" / NoSuchFileException, worker # exit 2 (reproduced against this image). An empty default would therefore # break the commonest BYO case outright -- Confluent Cloud and MSK present # certificates chaining to a public CA that is already in the JVM default # truststore, so they need NO truststore setting at all. # # Compose cannot omit a key conditionally on a DIFFERENTLY named variable, so # the path keeps the CONNECT_ names themselves, and all THREE must be set. # Setting only the worker one is the classic Connect partial failure: the # worker starts and the REST API answers, while source-task producers and # sink-task consumers fall back to the default truststore and fail their own # handshake later, far from the config that caused it. # # export CONNECT_SSL_TRUSTSTORE_LOCATION=/connect-certs/ca.pem # export CONNECT_PRODUCER_SSL_TRUSTSTORE_LOCATION="$CONNECT_SSL_TRUSTSTORE_LOCATION" # export CONNECT_CONSUMER_SSL_TRUSTSTORE_LOCATION="$CONNECT_SSL_TRUSTSTORE_LOCATION" # export KAFKA_SSL_TRUSTSTORE_TYPE=PEM # one variable, reaches all three # CONNECT_SSL_TRUSTSTORE_LOCATION: CONNECT_PRODUCER_SSL_TRUSTSTORE_LOCATION: CONNECT_CONSUMER_SSL_TRUSTSTORE_LOCATION: # # The PASSWORD is pass-through for the same reason, and the reason is sharper # than it looks. Kafka rejects a truststore password outright for PEM stores: # # InvalidConfigurationException: SSL trust store password cannot be # specified for PEM format. # # and an EMPTY string counts as specified (ConfigDef turns "" into a # non-null Password). So `${KAFKA_SSL_TRUSTSTORE_PASSWORD:-}` would kill the # worker at boot for every operator using a PEM CA bundle -- the modern, # password-less form -- while blaming a password they never set. Verified # against this image: PEM + empty password exits 2, PEM + absent password # starts and reaches the broker. CONNECT_SSL_TRUSTSTORE_PASSWORD: CONNECT_PRODUCER_SSL_TRUSTSTORE_PASSWORD: CONNECT_CONSUMER_SSL_TRUSTSTORE_PASSWORD: # The TYPE is safe to default and so takes one variable for all three # clients: it is consulted only when a location is set, and JKS is Kafka's # own default, so an always-present ssl.truststore.type=JKS changes nothing. CONNECT_SSL_TRUSTSTORE_TYPE: "${KAFKA_SSL_TRUSTSTORE_TYPE:-JKS}" CONNECT_PRODUCER_SSL_TRUSTSTORE_TYPE: "${KAFKA_SSL_TRUSTSTORE_TYPE:-JKS}" CONNECT_CONSUMER_SSL_TRUSTSTORE_TYPE: "${KAFKA_SSL_TRUSTSTORE_TYPE:-JKS}" # Hostname verification. Bare `-`, not `:-`: unset means Kafka's default # ("https", verify), while an explicitly EMPTY value is Kafka's documented off # switch and has to survive as empty -- `:-` would substitute the default back # over that deliberate choice. Same idiom as KAFKA_TOPIC_PREFIX. CONNECT_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: "${KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM-https}" CONNECT_PRODUCER_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: "${KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM-https}" CONNECT_CONSUMER_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: "${KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM-https}" # ── FileConfigProvider ── # Resolve ${file:/connect-secrets/.properties:key} references at task # start so source DB passwords never enter the Kafka config topic nor the # GET /connectors//config REST response. debezium-mcp writes the # per-connector secret file to the shared connect_secrets volume; this worker # reads it (ro). allowed.paths confines the provider to the secrets dir # (Kafka >= 3.5; debezium/connect:3.0 ships Kafka 3.8). CONNECT_CONFIG_PROVIDERS: "file" CONNECT_CONFIG_PROVIDERS_FILE_CLASS: "org.apache.kafka.common.config.provider.FileConfigProvider" CONNECT_CONFIG_PROVIDERS_FILE_PARAM_ALLOWED_PATHS: "/connect-secrets" # JVM heap (avoid OOM exit 137 on dev machines) KAFKA_HEAP_OPTS: "-Xms256M -Xmx768M -XX:+ExitOnOutOfMemoryError" volumes: # Read-only: the worker only RESOLVES secrets that debezium-mcp writes. - connect_secrets:/connect-secrets:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8083/"] interval: 30s timeout: 10s retries: 5 start_period: 60s # ============================================ # INTERNAL MCP SERVICES (CORE / SELF-HOSTED) # ============================================ # These are platform/internal connectors that run in the core `rsync-ai` project. # Only user/external MCP connectors should run in the separate `rsync-ai-mcp` project. debezium-mcp: build: context: ./shared/mcp-connectors/internal/debezium/versions/v1.0.0 dockerfile: Dockerfile container_name: rsync-ai-debezium-v1-0-0-mcp logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" labels: - rsync-ai.mcp=true - rsync-ai.connector=debezium environment: # Kafka client credentials. This connector configures Debezium's *schema # history* producer AND consumer, which are separate Kafka clients from # the Connect worker's own — it reads KAFKA_SECURITY_PROTOCOL / # KAFKA_SASL_* / KAFKA_SSL_* straight out of this env # (versions/v1.0.0/test_schema_history_security.py proves each name). # The Dockerfile's KAFKA_BOOTSTRAP_SERVERS default supplies only an # ADDRESS, so without the anchor a BYO broker is dialled anonymously over # PLAINTEXT and the failure reads as an ordinary broker error rather than # a missing-config one. Address and credentials must travel together: a # correct key aimed at the bundled broker is the same outage with a more # confusing message, which is why KAFKA_BROKERS is declared right here # and not left to the image default. <<: *kafka-security KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} PORT: "8000" MCP_PORT: "8000" MCP_HTTP_MODE: "true" DOCKER_CONTAINER: "true" LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: development # Externalize per-connector secrets to this shared-with-kafka-connect volume so # the source password stays OUT of the Kafka config topic. Unset ⇒ inline config # (backward compatible). See connector.py externalize_secrets(). DEBEZIUM_SECRETS_DIR: /connect-secrets restart: unless-stopped volumes: # Read-write: debezium-mcp WRITES the secret .properties the worker resolves. - connect_secrets:/connect-secrets healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s start_period: 10s retries: 3 # MinIO S3-compatible object storage backend. # Required for claim-check staging when batch payloads exceed the inline Kafka size cap. # Without this, minio-mcp and kafka-mcp-sink fall back to chunked inline Kafka writes. minio: # Pinned: an unpinned object store silently changes under a re-pull, and this one # holds staged pipeline data. Pinned by digest because cgr.dev/chainguard serves # only `latest` (and `latest-dev`) anonymously, so a digest is the only pin there is. # Chainguard's build, not MinIO's: MinIO withdrew anonymous pulls from # docker.io/minio/* (2026-09-12) and then from quay.io/minio/* (401). One multi-arch # image carries both `minio` (its entrypoint) and `mc`, so the healthcheck below # and minio-lifecycle-init use it too. It has no curl: health checks go through mc. image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 # Root, like the image this replaces. A volume written by v0.1.5 is root-owned # 0755, and this image's default user (65532) cannot write it: the server exits # "Unable to write to the backend" and restarts forever, and everything that # waits on its health never starts. user: "0:0" container_name: rsync-ai-minio logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY_ID:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_ACCESS_KEY:-minioadmin} command: server /data --console-address ":9001" volumes: - minio_data:/data healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 5 # One-shot: ensure the claim-check bucket exists and apply an expiration # lifecycle rule on the staging prefix. This is the BACKSTOP for MinIO disk # growth — the sink deletes each claim-check object after a successful write, # but if a delete is missed (worker crash, etc.) this rule reclaims the object # after MINIO_STAGING_EXPIRE_DAYS. # # TTL MUST be >= the Kafka batch-topic retention (7 days, see # backend-orchestrator/internal/kafka/topology.go retention.ms). For a >256KB # batch only a pointer goes to Kafka while the rows live as a MinIO object; if # the staged object expired BEFORE its Kafka pointer, a lagging sink would read # NoSuchKey and lose those rows. With uncapped chunked transfers a sink can lag # for hours/days on a multi-million-row backfill, so the old 2-day default was # SHORTER than retention and opened a loss window — default raised to 7 days. minio-lifecycle-init: # The server image above, same digest, for the same reasons; mc writes the # lifecycle rule. Chainguard's mc-only image is distroless (no /bin/sh for the # entrypoint below), so the server image, which ships both, serves here too. # No `user:` needed: nothing here writes the data volume. image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 container_name: rsync-ai-minio-lifecycle-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: minio: condition: service_healthy environment: - MINIO_BUCKET=${MINIO_BUCKET:-pipeline-data} - MINIO_PREFIX=${MINIO_PREFIX:-staging} - MINIO_ACCESS_KEY_ID=${MINIO_ACCESS_KEY_ID:-minioadmin} - MINIO_SECRET_ACCESS_KEY=${MINIO_SECRET_ACCESS_KEY:-minioadmin} - MINIO_STAGING_EXPIRE_DAYS=${MINIO_STAGING_EXPIRE_DAYS:-7} entrypoint: ["/bin/sh", "-c"] command: - | set -e until mc alias set local http://minio:9000 "$$MINIO_ACCESS_KEY_ID" "$$MINIO_SECRET_ACCESS_KEY"; do echo "waiting for minio..."; sleep 2; done mc mb --ignore-existing "local/$$MINIO_BUCKET" # Apply expiration on the staging prefix (newer mc: `ilm rule add`, older: `ilm add`) mc ilm rule add "local/$$MINIO_BUCKET" --expire-days "$$MINIO_STAGING_EXPIRE_DAYS" --prefix "$$MINIO_PREFIX/" \ || mc ilm add --expiry-days "$$MINIO_STAGING_EXPIRE_DAYS" --prefix "$$MINIO_PREFIX/" "local/$$MINIO_BUCKET" \ || echo "WARN: could not set ILM rule (mc version?); sink-side delete is primary cleanup" echo "✅ minio-init: bucket + lifecycle ready" restart: "no" # The blob lane's staging store: a second MinIO on the connector network ONLY. # Blob passthrough has the SOURCE connector stage each object here and the # DESTINATION connector fetch it, and connectors join only rsync-ai-mcp # (SEC-M-06). `minio` above is on `default` only, so a connector cannot resolve # it, and bridging it would hand every connector the store that holds every # pipeline's staged claim-check rows. This one holds only staged blobs, under # its own login. Keep ":9000" in its URL: the connectors' staging client uses # path-style addressing only for an endpoint with "minio", "localhost" or # ":9000" in it (base_connector.py _get_staging_client). # KI-BLOB-LANE-STAGING-UNREACHABLE-FROM-CONNECTORS. blob-staging: image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 container_name: ${STACK_PREFIX:-rsync-ai}-blob-staging logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped environment: MINIO_ROOT_USER: ${BLOB_STAGING_ACCESS_KEY:-${MINIO_ACCESS_KEY_ID:-minioadmin}} MINIO_ROOT_PASSWORD: ${BLOB_STAGING_SECRET_KEY:-${MINIO_SECRET_ACCESS_KEY:-minioadmin}} command: server /data volumes: - blob_staging_data:/data networks: - rsync-ai-mcp healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 5 # Bucket + expiry for blob-staging. Nothing deletes a staged blob per message: # blobs are content-addressed and shared between messages, so this rule is the # only cleanup. Same TTL floor as minio-lifecycle-init, for the same reason. blob-staging-init: image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 container_name: ${STACK_PREFIX:-rsync-ai}-blob-staging-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: blob-staging: condition: service_healthy environment: - MINIO_BUCKET=${MINIO_BUCKET:-pipeline-data} - MINIO_PREFIX=${MINIO_PREFIX:-staging} - BLOB_STAGING_ACCESS_KEY=${BLOB_STAGING_ACCESS_KEY:-${MINIO_ACCESS_KEY_ID:-minioadmin}} - BLOB_STAGING_SECRET_KEY=${BLOB_STAGING_SECRET_KEY:-${MINIO_SECRET_ACCESS_KEY:-minioadmin}} - MINIO_STAGING_EXPIRE_DAYS=${MINIO_STAGING_EXPIRE_DAYS:-7} networks: - rsync-ai-mcp entrypoint: ["/bin/sh", "-c"] command: - | set -e until mc alias set local http://blob-staging:9000 "$$BLOB_STAGING_ACCESS_KEY" "$$BLOB_STAGING_SECRET_KEY"; do echo "waiting for blob-staging..."; sleep 2; done mc mb --ignore-existing "local/$$MINIO_BUCKET" mc ilm rule add "local/$$MINIO_BUCKET" --expire-days "$$MINIO_STAGING_EXPIRE_DAYS" --prefix "$$MINIO_PREFIX/" \ || mc ilm add --expiry-days "$$MINIO_STAGING_EXPIRE_DAYS" --prefix "$$MINIO_PREFIX/" "local/$$MINIO_BUCKET" \ || echo "WARN: could not set the blob staging expiry rule" echo "blob-staging-init: bucket + lifecycle ready" restart: "no" # Internal: MinIO staging MCP (claim-check upload/read/delete) minio-mcp: build: context: ./shared/mcp-connectors/internal/minio/versions/v1.0.0 dockerfile: Dockerfile container_name: rsync-ai-minio-v1-0-0-mcp logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" labels: - rsync-ai.mcp=true - rsync-ai.connector=minio - rsync-ai.connector_version=v1.0.0 environment: - PORT=8000 - MCP_PORT=8000 - MCP_HTTP_MODE=true - DOCKER_CONTAINER=true - LOG_FORMAT=json - LOG_LEVEL=info - ENVIRONMENT=development # Optional defaults (can be overridden per request via params.config) # Pin to the unambiguous container_name, NOT the bare `minio` alias: the # e2e overlay (docker-compose.e2e.dbs.yml) ships a second container that # also aliases `minio` on the shared network, so `http://minio:9000` # DNS-round-robins between two MinIO stores. Writes landing on one and # reads hitting the other → NoSuchKey → DLQ → silent claim-check drop. # `rsync-ai-minio` resolves to exactly one container. Override via env for # external object stores. - MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL:-http://rsync-ai-minio:9000} - MINIO_ACCESS_KEY_ID=${MINIO_ACCESS_KEY_ID:-minioadmin} - MINIO_SECRET_ACCESS_KEY=${MINIO_SECRET_ACCESS_KEY:-minioadmin} - MINIO_REGION=${MINIO_REGION:-us-east-1} - MINIO_BUCKET=${MINIO_BUCKET:-pipeline-data} - MINIO_PREFIX=${MINIO_PREFIX:-staging} # Same class of silent disagreement as RSYNC_LLM_DIAGNOSER_ENABLED above: # connector.py:36 reads RSYNC_CLAIM_CHECK_GZIP, and no compose file passed # it through, so an operator who set it in .env got plain JSON and no error # said why. Default stays empty (= off), so wiring it changes nothing until # someone sets it deliberately. The reader is flag-INDEPENDENT magic-byte # autodetect, so turning it on mid-flight still reads back legacy plain # objects -- it is safe to flip without draining staging. - RSYNC_CLAIM_CHECK_GZIP=${RSYNC_CLAIM_CHECK_GZIP:-} depends_on: minio: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s start_period: 10s retries: 3 kafka-mcp-sink-mcp: build: # Use repo root as build context so the worker can import shared/go/transforms via replace. context: . dockerfile: shared/mcp-connectors/internal/kafka-mcp-sink/Dockerfile container_name: rsync-ai-kafka-mcp-sink-v1-0-0-mcp labels: - rsync-ai.mcp=true - rsync-ai.connector=kafka-mcp-sink environment: # Kafka client credentials. The sink worker is a consumer group member on # every pipeline topic — initKafkaSecurity() in # worker-src/cmd/kafka-sink-worker/kafka_security.go reads these names, # and kafka_security_test.go pins them. Without the anchor a BYO broker is # dialled anonymously over PLAINTEXT. KAFKA_BROKERS is declared alongside # so credentials can never be aimed at a cluster other than the one they # belong to. <<: *kafka-security KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} PORT: "8000" # How many destination flushes this sink runs at once. Unset/empty = 4. # # A CDC data topic is born with min(3, brokers) partitions and Debezium gives # every source table a topic of its own, so one worker is assigned tens of # partitions. It consumed all of them already, but drained them one at a time # because the destination write ran inline on the consume goroutine — one slow # table stalled every other partition. Lanes are sharded on (topic, partition), # so per-row order is untouched. # # Set to 0 or 1 to restore the old inline behaviour exactly; this is the kill # switch. Raising it opens more concurrent connections to the DESTINATION, which # is somebody else's database — 4 is deliberately modest. RSYNC_SINK_FLUSH_LANES: ${RSYNC_SINK_FLUSH_LANES:-} # Mirror of the orchestrator's schema-drift switch. The sink worker reports the # columns CDC adds on its own to rsync.healer.schema-changes only when this is # "true" (newSchemaDriftWriter); off, it writes nothing there, because the # orchestrator then neither creates nor consumes that topic. Must stay # byte-identical to the orchestrator line; a census test pins it. RSYNC_SCHEMA_DRIFT_ENABLED: "${RSYNC_SCHEMA_DRIFT_ENABLED:-false}" MCP_PORT: "8000" MCP_HTTP_MODE: "true" DOCKER_CONTAINER: "true" LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: development # Allow kafka-mcp-sink to fetch claim-check objects from MinIO (S3-compatible) # Pin to the unambiguous container_name, NOT the bare `minio` alias: the # e2e overlay (docker-compose.e2e.dbs.yml) ships a second container that # also aliases `minio` on the shared network, so `http://minio:9000` # DNS-round-robins between two MinIO stores. Writes landing on one and # reads hitting the other → NoSuchKey → DLQ → silent claim-check drop. # `rsync-ai-minio` resolves to exactly one container. Override via env for # external object stores. MINIO_ENDPOINT_URL: ${MINIO_ENDPOINT_URL:-http://rsync-ai-minio:9000} MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY_ID:-minioadmin} MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_ACCESS_KEY:-minioadmin} MINIO_REGION: ${MINIO_REGION:-us-east-1} MINIO_BUCKET: ${MINIO_BUCKET:-pipeline-data} MINIO_PREFIX: ${MINIO_PREFIX:-staging} # Sink writes the durable ACK ledger (pipeline_batch_acks). Without # this URL pgDB was nil and the ledger stayed empty — the postflight # silent-drop invariant then had to fall back to sink-emitted stats # events, which is a weaker signal. With it, Postgres is the single # source of truth for "has this Kafka offset been written to dest?". # URL is built from the same env vars the `postgres` service uses, so # changing creds in one place updates every consumer. POSTGRES_USER: ${POSTGRES_USER:-user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} POSTGRES_DB: ${POSTGRES_DB:-pipeline_db} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_PORT: "${POSTGRES_PORT:-5432}" POSTGRES_URL: "postgres://${POSTGRES_USER:-user}:${POSTGRES_PASSWORD:-password}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-pipeline_db}?sslmode=${DB_SSLMODE:-disable}" # Redis for the ACK ledger / offset checkpointing. Must carry the password # when Redis runs with --requirepass (staging and prod) — otherwise the # sink worker crashes on every start with "NOAUTH Authentication required". REDIS_URL: "redis://:${REDIS_PASSWORD:-}@redis:6379/0" # Sink worker calls destination MCPs (e.g. rsync-ai-postgresql-v1-0-0-mcp) # by container name. JIT-deployed MCPs live on rsync-ai-mcp; without # bridging into that network the sink resolves nothing and every batch # write fails with "no such host". This is the silent failure mode that # let pipelines report `completed` while leaving the destination empty. networks: - default - rsync-ai-mcp restart: unless-stopped # Ship the sink worker's (now structured-JSON) stdout/stderr to fluent-bit so # the destination-write leg is visible + trace-correlated downstream. Without # this driver the sink is the one data-plane stage absent from the flow view. F-Obs-2. logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s start_period: 10s retries: 3 # Context7 MCP Service (Documentation Lookup) context7-mcp: build: context: ./shared/mcp-connectors/internal/context7 dockerfile: Dockerfile container_name: rsync-ai-context7-v1-0-0-mcp environment: - PORT=8080 # Use context7.com base URL - api. subdomain doesn't resolve - CONTEXT7_API_URL=https://context7.com/api # Provide via your shell environment or env file (do not commit keys). - CONTEXT7_API_KEY=${CONTEXT7_API_KEY:-} - CONTEXT7_DEFAULT_TOKENS=10000 - CONTEXT7_TIMEOUT=30 - LOG_FORMAT=json - LOG_LEVEL=info - ENVIRONMENT=development # OpenTelemetry Configuration - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 - OTEL_SERVICE_NAME=context7-mcp - OTEL_ENABLED=true ports: - "127.0.0.1:${RSYNC_HP_CONTEXT7:-8087}:8080" # Add DNS servers to resolve external hostnames dns: - 8.8.8.8 - 8.8.4.4 - 1.1.1.1 healthcheck: test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8080/health', timeout=2)"] interval: 10s timeout: 5s retries: 3 extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped # Redis (State Store for Orchestrator & Caching) redis: image: redis:7-alpine container_name: redis logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" ports: - "127.0.0.1:${RSYNC_HP_REDIS:-6379}:6379" command: - sh - -c # The password is written to a 0600 file and never appears on the # redis-server ARGV, so it stays out of `ps` on the host and out of # the rendered compose spec (`docker inspect` / `compose config`). - | umask 077 : > /tmp/redis.conf if [ -n "$$REDIS_PASSWORD" ]; then printf 'requirepass %s\n' "$$REDIS_PASSWORD" > /tmp/redis.conf fi exec redis-server /tmp/redis.conf --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru environment: - REDIS_PASSWORD=${REDIS_PASSWORD:-} volumes: - redis_data:/data healthcheck: # REDISCLI_AUTH is read from the environment by redis-cli, so the password # never reaches ARGV (a healthcheck re-runs every 5s, once per interval). test: ["CMD-SHELL", "if [ -n \"$$REDIS_PASSWORD\" ]; then REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli --no-auth-warning ping; else redis-cli ping; fi"] interval: 5s timeout: 3s retries: 5 # Backend Orchestrator (Go) orchestrator: build: context: . dockerfile: backend-orchestrator/Dockerfile args: *build-info container_name: rsync-ai-orchestrator restart: unless-stopped # How long `docker compose stop/down/restart` waits after SIGTERM before it # SIGKILLs, which skips every deferred Stop/Close in main.go. Left unset, # the daemon decides: 10 s on Docker Engine, 1 s on the Docker Desktop # (engine 28.5.1) this was measured on. Shutdown drains HTTP for up to # 10 s, then gives the Kafka consumer groups up to 15 s to leave # (consumerCloseTimeout in backend-orchestrator/internal/kafka/manager.go), # on top of the agents' own Stop calls; an idle dev stack takes 3-9 s. # backend-orchestrator/internal/kafka/stop_grace_test.go pins this value # and its copies in docker-compose.quickstart.yml and the Helm chart. stop_grace_period: 45s deploy: # SCALING: # One replica. Static membership is OPT-IN via # KAFKA_STATIC_MEMBERSHIP=true and derives instance_id from $HOSTNAME # (unique per pod); it only reduces rebalance churn on restarts. replicas: 1 ports: - "127.0.0.1:${RSYNC_HP_ORCHESTRATOR:-8081}:8080" # Internal port for debugging environment: # Kafka client security for a BYO/managed broker. See x-kafka-security at # the top of this file; every key defaults to empty and empty means unset, # so the bundled broker is unaffected. <<: *kafka-security DB_HOST: "postgres" DB_PORT: "5432" DB_USER: "user" DB_PASSWORD: "password" DB_NAME: "pipeline_db" KAFKA_BROKERS: "${KAFKA_BROKERS:-kafka:29092}" # Durability for every topic the orchestrator creates. Read by # internal/kafka/replication.go (EnvReplicationFactor / EnvMinInsyncReplicas); # empty means "operator stated nothing", which is not the same as zero -- the # built-in derivation (1 on a single broker, min(3, brokers) beyond) applies. # # These two travel together on purpose. min.insync.replicas > replication # factor produces a topic that is CREATED, listed and subscribable, and then # rejects every acks=all produce with NOT_ENOUGH_REPLICAS: a pipeline that # reports running and streams zero rows. The Go side clamps misr down to the # topic's final RF for exactly that reason, so setting only one of these # cannot wedge a cluster. # # Version boundary, measured rather than assumed: on the bundled # apache/kafka:3.7.0 broker an RF=1 topic carrying min.insync.replicas=2 # still ACCEPTS acks=all -- 3.7 caps the effective value at the replica count # (kafka.cluster.Partition#effectiveMinIsr, present in that broker's # kafka_2.13-3.7.0.jar). The trap is therefore real for the OLDER brokers a # BYO/self-host deployment actually points at (MSK and most managed clusters # still run 2.8-3.6), which is exactly the case these variables exist for. # Clamp regardless: on a 3.7+ broker the clamp is a no-op. KAFKA_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-}" KAFKA_MIN_INSYNC_REPLICAS: "${KAFKA_MIN_INSYNC_REPLICAS:-}" # Partition count for every CDC data topic (backend-orchestrator/internal/kafka # partitions.go EnvCDCTopicPartitions). Empty means "operator stated nothing", so # the derivation applies: 1 on a single broker, min(3, brokers) beyond that. Set # it only to go WIDER than 3 on a large cluster. # # It is a birth setting, not a live one. Kafka cannot reduce a partition count at # all, and RAISING one on a topic that is already streaming rehashes every key # onto a different partition and breaks per-key ordering, so rsync.ai never resizes # an existing topic -- a change here reaches only topics created after it. KAFKA_CDC_TOPIC_PARTITIONS: "${KAFKA_CDC_TOPIC_PARTITIONS:-}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: "${KAFKA_TOPIC_PREFIX-rsync.}" # Blob-passthrough staging (universal-blob-passthrough plan §3): the # executor's blob lane hands these to the source connector (to stage object # bytes) and, via the sink message, to the destination connector (to fetch # them). blob-staging, NOT minio: connectors join only rsync-ai-mcp and # cannot resolve minio (KI-BLOB-LANE-STAGING-UNREACHABLE-FROM-CONNECTORS). # The lane reads no other MinIO credential, so the orchestrator holds none. # MINIO_BUCKET/PREFIX stay: the staged blob's bucket must be in the sink's # allow-list and its prefix under blob-staging-init's expiry rule. BLOB_STAGING_ENDPOINT_URL: "${BLOB_STAGING_ENDPOINT_URL:-http://blob-staging:9000}" BLOB_STAGING_ACCESS_KEY_ID: "${BLOB_STAGING_ACCESS_KEY:-${MINIO_ACCESS_KEY_ID:-minioadmin}}" BLOB_STAGING_SECRET_ACCESS_KEY: "${BLOB_STAGING_SECRET_KEY:-${MINIO_SECRET_ACCESS_KEY:-minioadmin}}" MINIO_REGION: "${MINIO_REGION:-us-east-1}" MINIO_BUCKET: "${MINIO_BUCKET:-pipeline-data}" MINIO_PREFIX: "${MINIO_PREFIX:-staging}" JWT_SECRET: "dev_secret_key_change_in_prod" # S2S secret the gateway's internal routes check (namespace lock, heal, # OAuth refresh) and requirePrincipal accepts. Same value as api-gateway, or # every one of those calls is refused 401/503 and only logged as a warning. INTERNAL_SERVICE_SECRET: "${INTERNAL_SERVICE_SECRET:-}" # Guarded operator endpoint for recovering FAILED Debezium connectors. # Default off for safety. Enable explicitly with CDC_RECOVERY_ENABLED=true. CDC_RECOVERY_ENABLED: "${CDC_RECOVERY_ENABLED:-false}" # Autonomous CDC sink self-heal (Bug B, #678): bounded per-pipeline sink-worker # auto-restart on a GENUINE 3-signal wedge (corrected lag + stale + flat). Default # OFF everywhere (operational-safety gate mirroring CDC_RECOVERY_ENABLED, NOT an # edition gate — preserves OSS/cloud parity). Arm by setting the env var to true. CDC_SINK_AUTORESTART_ENABLED: "${CDC_SINK_AUTORESTART_ENABLED:-false}" # Orphan sink-worker reaper (KI-CDC-SINK-WORKER-NO-REAPER): off | dry_run | enforce. # Default OFF, same op-safety gate; stops only workers whose pipeline is deleted or stopped. SINK_WORKER_REAPER_MODE: "${SINK_WORKER_REAPER_MODE:-off}" # Self-healing schema-drift detector (P0/P1): the drift-detect -> approve loop. # Default OFF. Flip to true to activate the executor's baseline+diff+emit # (schema_drift.go) AND the healer's schema-change consumer (StartSchemaOnly in # workers/executor.go). When false, both paths are inert no-ops -> zero behavior change. RSYNC_SCHEMA_DRIFT_ENABLED: "${RSYNC_SCHEMA_DRIFT_ENABLED:-false}" # Additive auto-apply, a SEPARATE opt-in from the line above (healer.go # autoApplyEnabled()). It was read by the orchestrator and documented in # .env.prod.example + docs/design/self-healing-drift-approve.md, but no compose # file passed it through — so setting it in an env file did nothing and the # operator had no way to tell. That silent disagreement is the bug; the default # stays `false`, so wiring it changes nothing until someone sets it deliberately. # Destructive classes ignore it entirely (DROP/TRUNCATE always need approval). RSYNC_SCHEMA_DRIFT_AUTOAPPLY: "${RSYNC_SCHEMA_DRIFT_AUTOAPPLY:-false}" # LLM error diagnoser, chained BEHIND the rules (pkg/diagnose/llm.go # NewFromEnv). The RuleBasedDiagnoser always runs first; the LLM is asked # only about a CategoryUnknown result, and its confidence is clamped below # heal's auto-execute band, so it can suggest but never auto-act. # # Exactly the same silent disagreement as the line above: the code read # RSYNC_LLM_DIAGNOSER_ENABLED and NO compose file passed it through, so an # operator who set it in .env got nothing and no error said why. The # default stays `false`, so wiring it changes nothing until someone sets # it deliberately — but turning it ON sends llmscrub-scrubbed failure text # over the network to the LLM service, which is a privacy-surface change, # not a config tidy-up. RSYNC_LLM_DIAGNOSER_ENABLED: "${RSYNC_LLM_DIAGNOSER_ENABLED:-false}" # Optional overrides, both empty by default (empty = fall back in code). # Model: RSYNC_LLM_DIAGNOSER_MODEL -> LLM_MODEL -> gpt-4o-mini. # URL is diagnoser-specific ON PURPOSE: LLM_SERVICE_URL is also read by # internal/workers/intent.go with a DIFFERENT fallback (planner:5011), so # passing that name through here would silently repoint the intent worker. RSYNC_LLM_DIAGNOSER_MODEL: "${RSYNC_LLM_DIAGNOSER_MODEL:-}" RSYNC_LLM_DIAGNOSER_URL: "${RSYNC_LLM_DIAGNOSER_URL:-}" # SECURITY: ENCRYPTION_KEY must be 32+ characters. Generate with: openssl rand -base64 32 # WARNING: Changing this key will make existing encrypted connection configs unreadable! # For production: Override via .env file or environment variable ENCRYPTION_KEY: "${ENCRYPTION_KEY:-dev-encryption-key-32-bytes-long!!}" TOOLS_DIR: "/app/shared/mcp-connectors" # Self-healing MCP deployment (tool-generator has Docker access via docker.sock) TOOL_GENERATOR_URL: "http://tool-generator:5010" # Sentinel core-service probes (sentinel/service_probes.go). Each is probed only # when its address is set; after 3 failed 30s checks the service is recorded # unhealthy and every admin gets an INFRASTRUCTURE_DOWN alert. Redis is always # probed, at the address the correlation client uses. TEMPORAL_ADDRESS is also the # (parked) native Temporal worker's dial address, whose default is this same value. TEMPORAL_ADDRESS: "temporal:7233" SENTINEL_PROBE_TEMPORAL_ADAPTER_URL: "http://temporal-adapter:8082" SENTINEL_PROBE_CONNECTOR_DEPLOYER_URL: "http://connector-deployer:5011" SENTINEL_PROBE_LLM_SERVICE_URL: "http://llm-service:5000" # Redis Configuration (State Store) REDIS_HOST: "redis" REDIS_PORT: "6379" REDIS_DB: "0" REDIS_PASSWORD: "${REDIS_PASSWORD:-}" # Correlation Store (V2 Request/Reply Pattern) # Must be >= longest expected request processing time to avoid duplicate claims. CORRELATION_CLAIM_TTL: "1h" # Serialization (Demo-2 default: JSON; Schema Registry is optional behind a profile) KAFKA_USE_AVRO: "false" SCHEMA_REGISTRY_URL: "http://schema-registry:8080" AVRO_AUTO_REGISTER_SCHEMAS: "false" # Logging Configuration (JSON for log collection) LOG_FORMAT: "json" LOG_LEVEL: "info" ENVIRONMENT: "development" # Scheduler is deprecated (schedules are managed via API Gateway + Temporal). # Disable to avoid legacy DB queries against removed columns. ENABLE_SCHEDULER: "false" # OpenTelemetry Configuration (exports to the bundled OTel Collector) OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317" OTEL_SERVICE_NAME: "orchestrator" OTEL_ENABLED: "${OTEL_ENABLED:-false}" OTEL_TRACES_EXPORTER: "otlp" OTEL_METRICS_EXPORTER: "otlp" # Enable Debezium topic consumer that emits "captured" CDC table stats # (incoming inserts/updates/deletes), so UI can show incoming vs applied like DMS. ENABLE_CDC_TABLE_STATS: "true" # User MCP connectors run on the rsync-ai-mcp network (separate from internal services) DOCKER_NETWORK: "${STACK_PREFIX:-rsync-ai}-mcp" # Drives isolated-stack MCP container-name discovery in the orchestrator # (infra_preflight health URLs + mcp.containerNameCandidates). Default # rsync-ai; an isolated CI stack sets rsync-ci. STACK_PREFIX: "${STACK_PREFIX:-rsync-ai}" # Phase 3: Temporal-native executor dispatch — ⛔ PARKED, keep these OFF. # The native path is dead scaffolding: nothing populates the workflow's # ExecutorDispatch field, so all pipelines use the correlation path and the # native worker (if started) only idles. EXECUTOR_DISPATCH is not even read in # code. Leave both at their defaults below. EXECUTOR_NATIVE_WORKER: "${EXECUTOR_NATIVE_WORKER:-false}" EXECUTOR_DISPATCH: "${EXECUTOR_DISPATCH:-correlation}" # Export page size (rows/batch). Empty = code default (10000). Raise to cut # staging round-trips for large tables; opt-in per CAPABILITIES (batch-size lever). EXECUTOR_TABLE_BATCH_ROWS: "${EXECUTOR_TABLE_BATCH_ROWS:-}" # Batches exported per dispatch before signaling needs_continuation (chunk # boundary = this × EXECUTOR_TABLE_BATCH_ROWS rows). Empty = code default (200). # Lower it to force the chunked-continuation resume path on small tables. EXECUTOR_TABLE_CHUNK_BATCHES: "${EXECUTOR_TABLE_CHUNK_BATCHES:-}" # Hard per-table runaway ceiling (rows). Empty/0 = unlimited; the chunk loop # runs to natural EOF. Set >0 only to bound a suspected pagination bug. EXECUTOR_TABLE_MAX_ROWS: "${EXECUTOR_TABLE_MAX_ROWS:-}" # Which MCP connector containers exist on THIS host, for sentinel's connector # health sweep (see the docker-socket-proxy service above). The sweep used to # infer "not deployed here" from a name failing to resolve, which is only sound # on a resolver that answers an unknown name with a clean NXDOMAIN. On a GCP VM # it is not: /etc/resolv.conf carries `search ....internal` with ndots:0, Docker's # resolver forwards those variants upstream, and one non-NXDOMAIN answer left # every undeployed connector looking like a fault — 18 of them on prod. The # Docker API needs no resolver semantics, and `all=true` additionally tells a # stopped container (a real fault) from one that was never here, which DNS # cannot. Unset (quickstart/self-host runs no proxy) → the resolver fallback, # which now records nothing rather than guessing. MCP_DOCKER_API_URL: "http://docker-socket-proxy:2375" depends_on: postgres: condition: service_healthy # Other half of docker-compose.byo-postgres.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false redis: condition: service_healthy # Read-only Docker socket proxy for the connector-deployment census above. docker-socket-proxy: condition: service_started networks: - default - rsync-ai-mcp volumes: - ./shared/mcp-connectors:/app/shared/mcp-connectors extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # API Gateway (Go) api-gateway: build: context: . dockerfile: api-gateway/Dockerfile args: *build-info container_name: rsync-ai-api-gateway # main() exits when the database is unreachable at boot (db.Init has already # retried for DB_CONNECT_TIMEOUT). The restart is what turns that exit into a # recovery once Postgres is up; without it the container just stays down. restart: unless-stopped ports: - "${RSYNC_BIND_ADDR:-127.0.0.1}:${RSYNC_HP_API_GATEWAY:-5001}:8080" # Public API port (5001 to avoid macOS AirPlay on 5000) environment: # Kafka client security for a BYO/managed broker. See x-kafka-security at # the top of this file; every key defaults to empty and empty means unset, # so the bundled broker is unaffected. <<: *kafka-security DATABASE_URL: "postgres://user:password@postgres:5432/pipeline_db?sslmode=disable" KAFKA_BROKERS: "${KAFKA_BROKERS:-kafka:29092}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: "${KAFKA_TOPIC_PREFIX-rsync.}" # Dev/E2E: allow internal-only connectors (e.g. MinIO) to be used from UI/tests. RSYNC_ALLOW_INTERNAL_CONNECTORS: "true" # Live MCP container status via the read-only docker-socket-proxy (see the # docker-socket-proxy service above). Without this, api-gateway can't read # /var/run/docker.sock as non-root, so the catalog reports every connector # as "auto_deploys_on_use" and the UI shows "Running (0)". Unset → falls # back to dialing the local Unix socket directly. MCP_DOCKER_API_URL: "http://docker-socket-proxy:2375" # Admin panel access (beta). Comma-separated allowlist of emails. # Example: RSYNC_ADMIN_EMAILS=alice@company.com,bob@company.com RSYNC_ADMIN_EMAILS: "${RSYNC_ADMIN_EMAILS:-}" # CDC auto-pickup: a pipeline selected as "this whole database/schema" # keeps that meaning — the api-gateway re-expands the selection rule on # this interval and adds source tables created since. Default ON; set # false to freeze every CDC pipeline at its current table list. The # interval is floored at 60s (discovery runs once per CDC pipeline # per tick). CDC_TABLE_AUTOPICKUP_ENABLED: "${CDC_TABLE_AUTOPICKUP_ENABLED:-true}" CDC_TABLE_WATCH_INTERVAL_SECONDS: "${CDC_TABLE_WATCH_INTERVAL_SECONDS:-300}" # Temporal is required for the agentic pipeline stage-by-stage execution + progress events. # Without this, the gateway falls back to legacy direct-Kafka which doesn't drive the V2 UI timeline. TEMPORAL_ADDRESS: "temporal:7233" ORCHESTRATOR_URL: "http://orchestrator:8080" AGENT_ORCHESTRATOR_URL: "http://orchestrator:8080" TOOL_GENERATOR_URL: "http://tool-generator:5010" JWT_SECRET: "dev_secret_key_change_in_prod" # V2 is the ONLY workflow (V1 deleted) REDIS_ADDRESS: "redis:6379" REDIS_PASSWORD: "${REDIS_PASSWORD:-}" # SECURITY: Must match api-gateway ENCRYPTION_KEY for shared credential access ENCRYPTION_KEY: "${ENCRYPTION_KEY:-dev-encryption-key-32-bytes-long!!}" # CSRF protection (double-submit). Default off in dev; enable in staging with RSYNC_CSRF_ENFORCE=true. RSYNC_CSRF_ENFORCE: "${RSYNC_CSRF_ENFORCE:-false}" # Verifies X-Internal-Secret on internal S2S endpoints (e.g. the scheduled # model-run endpoint the temporal-adapter calls). Unset → those endpoints 503. INTERNAL_SERVICE_SECRET: "${INTERNAL_SERVICE_SECRET:-}" # Read-only mirror of the orchestrator's schema-drift switch, so the pipeline's # "Schema change alerts" card can say when detection is off for the whole # installation (schema_evolution.go schemaDriftDetectorEnabled). Must stay # byte-identical to the orchestrator line; a census test pins it. RSYNC_SCHEMA_DRIFT_ENABLED: "${RSYNC_SCHEMA_DRIFT_ENABLED:-false}" # Serialization (Demo-2 default: JSON; Schema Registry is optional behind a profile) KAFKA_USE_AVRO: "false" SCHEMA_REGISTRY_URL: "http://schema-registry:8080" AVRO_AUTO_REGISTER_SCHEMAS: "false" # Logging Configuration (JSON for log collection) LOG_FORMAT: "json" LOG_LEVEL: "info" ENVIRONMENT: "development" # OAuth Configuration OAUTH_CALLBACK_URL: "http://localhost:5001/oauth/callback" GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}" GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}" GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}" GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}" HUBSPOT_CLIENT_ID: "${HUBSPOT_CLIENT_ID}" HUBSPOT_CLIENT_SECRET: "${HUBSPOT_CLIENT_SECRET}" SALESFORCE_CLIENT_ID: "${SALESFORCE_CLIENT_ID}" SALESFORCE_CLIENT_SECRET: "${SALESFORCE_CLIENT_SECRET}" PIPEDRIVE_CLIENT_ID: "${PIPEDRIVE_CLIENT_ID}" PIPEDRIVE_CLIENT_SECRET: "${PIPEDRIVE_CLIENT_SECRET}" SLACK_CLIENT_ID: "${SLACK_CLIENT_ID}" SLACK_CLIENT_SECRET: "${SLACK_CLIENT_SECRET}" SHOPIFY_CLIENT_ID: "${SHOPIFY_CLIENT_ID:-}" SHOPIFY_CLIENT_SECRET: "${SHOPIFY_CLIENT_SECRET:-}" # Notifier (Pillar 6) — delivers pipeline failure alerts to Slack + email. # Leave empty in dev to keep notifications in "persist-only" mode # (queryable via UI, no external delivery). NOTIFIER_SLACK_WEBHOOK_URL: "${NOTIFIER_SLACK_WEBHOOK_URL:-}" # Slack one-click drift approvals (inbound interactivity). SIGNING_SECRET # verifies Slack request signatures; BOT_TOKEN (users:read.email) maps the # clicking Slack user to their rsync.ai account by email. Both unset ⇒ the # inbound receiver is inert and drift alerts keep the plain link button. SLACK_SIGNING_SECRET: "${SLACK_SIGNING_SECRET:-}" SLACK_BOT_TOKEN: "${SLACK_BOT_TOKEN:-}" SMTP_HOST: "${SMTP_HOST:-}" SMTP_PORT: "${SMTP_PORT:-587}" SMTP_USER: "${SMTP_USER:-}" SMTP_PASSWORD: "${SMTP_PASSWORD:-}" SMTP_FROM: "${SMTP_FROM:-}" # Email verification (Resend). Unset → signups auto-verify (dev default). # To TEST email verification locally: set RESEND_API_KEY + RESEND_FROM_ADDR # in the root .env (must be a Resend-verified domain), then recreate api-gateway. RESEND_API_KEY: "${RESEND_API_KEY:-}" RESEND_FROM_ADDR: "${RESEND_FROM_ADDR:-}" APP_BASE_URL: "${APP_BASE_URL:-http://localhost:3000}" # OpenTelemetry Configuration (exports to the bundled OTel Collector) OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317" OTEL_SERVICE_NAME: "api-gateway" OTEL_ENABLED: "${OTEL_ENABLED:-false}" OTEL_TRACES_EXPORTER: "otlp" OTEL_METRICS_EXPORTER: "otlp" # Monitoring UI feature flags (V1) FEATURE_MONITORING_OVERVIEW: "true" FEATURE_MONITORING_INFRA: "true" FEATURE_MONITORING_TRACES: "true" # JIT Integration: Path to generated tools TOOLS_PATH: "/app/shared/mcp-connectors" depends_on: postgres: condition: service_healthy # Other half of docker-compose.byo-postgres.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false redis: condition: service_healthy # Wait for Temporal to accept connections so the startup client.Dial # succeeds instead of losing the cold-boot race (workflows disabled). # Belt-and-suspenders: the gateway also retries + lazily reconnects. temporal: condition: service_healthy orchestrator: condition: service_started # Read-only Docker socket proxy for live MCP container status. docker-socket-proxy: condition: service_started networks: - default - rsync-ai-mcp volumes: # JIT Integration: Read access to generated tools - ./shared/mcp-connectors:/app/shared/mcp-connectors:ro # NOTE: live docker container status (docker_status=running, port, # container_name) is NOT reported by the catalog endpoint because # api-gateway runs as a non-root user that can't read # /var/run/docker.sock. Mounting the socket here returns # "permission denied" without also setting `user: root` or doing # GID mapping — and we don't want to elevate api-gateway just for # an informational field. The catalog instead reports # docker_status="auto_deploys_on_use" for connectors whose # containers will be started on demand by the orchestrator. The # full live-status feature is tracked as a follow-up that would # route the docker query through tool-generator (which already # has its own RW socket mount with the appropriate privileges). extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # LLM Gateway (Python) - Internal service, no host port needed llm-service: build: context: ./llm-service dockerfile: Dockerfile args: *build-info container_name: rsync-ai-llm-service env_file: # Gitignored local override. `required: false` so `docker compose config` # (e.g. the e2e/staging wiring guards) doesn't hard-fail on a runner that # has no local .env; the file is still loaded wherever it exists. - path: ./llm-service/.env required: false environment: # Kafka client credentials. llm-service runs the PII-scanner consumer and # its producer (src/agents/pii_scanner/kafka_consumer.py via # src/utils/kafka_security.py), started as a background task at # src/gateway/main.py:770 whose failure is swallowed into a warning — so # an unauthenticated or unreachable broker here produces NO error surface # at all, just a scanner that never runs. Without the anchor a BYO broker # is dialled anonymously over PLAINTEXT. <<: *kafka-security # The address belongs here for a second reason: docker-compose.byo-kafka.yml # REMOVES the bundled `kafka` service, so an operator who sets # KAFKA_BROKERS=my-msk:9098 redirected every other service while this one # kept the code default `kafka:29092` — a host that no longer exists. KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} # Namespace for every Kafka topic this product creates. A producer and a # consumer resolving different names do not error, the consumer just blocks # on a topic nobody writes — so this must agree with the five services that # already declare it. `-` not `:-` on purpose: an explicit empty value means # "disable" (keep pre-prefix topic names) and `:-` would silently turn that # back into the default. KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} PORT: "5000" LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: development # Sent as X-Internal-Secret on service-to-service calls to the orchestrator # topology API (requirePrincipal). Empty = topology calls 401 in production. INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} # Control-plane DB for best-effort LLM cost/usage logging (llm_usage_events + # check_and_charge_user, migration 057). Without this the cost writer falls # back to an empty-password DSN and fails every write with "fe_sendauth: no # password supplied". Mirrors the api-gateway/orchestrator default; staging & # prod override DATABASE_URL via .env to point at Azure PostgreSQL. DATABASE_URL: ${DATABASE_URL:-postgres://user:password@postgres:5432/pipeline_db?sslmode=disable} # OpenTelemetry Configuration (exports to the bundled OTel Collector) OTEL_EXPORTER_OTLP_ENDPOINT: otel-collector:4317 OTEL_SERVICE_NAME: llm-service OTEL_ENABLED: "${OTEL_ENABLED:-false}" OTEL_TRACES_EXPORTER: otlp OTEL_METRICS_EXPORTER: otlp # No host port exposed - accessed internally by planner/tool-generator via Docker network command: ["python", "-m", "src.gateway.main"] extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # Connector Deployer (SEC-H-02): the small, trusted, no-LLM microservice that # holds /var/run/docker.sock. tool-generator (which runs LLM/user-influenced # connector code) now DROPS root + the socket and calls this service over an # authenticated internal RPC (DEPLOYER_URL) to build/run JIT connector # containers. The security property is allow-by-construction: the deployer # builds the Docker `create` payload itself with a hardcoded-safe HostConfig, # so untrusted input is DATA — never a Docker API call — and no # Privileged/bind/cap field can be smuggled through. Because the HOST daemon # creates the container, it joins rsync-ai-mcp by name/alias and kafka-mcp-sink # still resolves JIT destination connectors there (flat-name reachability, the # decisive constraint). See docs/internal/jit-connector-sandboxing.md and # connector-deployer/CONTRACT.md. connector-deployer: build: context: ./connector-deployer args: # SEC-H-02 increment 2: lock the deployer's buildctl (client) to the buildkitd # sidecar's image tag in docker-compose.prod.yml. Bump both via one env knob. BUILDKIT_VERSION: ${BUILDKIT_VERSION:-v0.31.2} container_name: rsync-ai-connector-deployer environment: - ENVIRONMENT=development - LOG_FORMAT=json - LOG_LEVEL=info # Rotation for the connector containers THIS service creates -- compose's # `logging:` blocks only reach the services compose itself starts. - RSYNC_LOG_MAX_SIZE=${RSYNC_LOG_MAX_SIZE:-10m} - RSYNC_LOG_MAX_FILE=${RSYNC_LOG_MAX_FILE:-3} # S2S shared secret — callers send it as X-Internal-Secret. Unset in dev => # the deployer allows with a warning (CONTRACT auth table); the prod overlay # fail-closes it with :? (mirrors the MinIO C3 pattern). - INTERNAL_SERVICE_SECRET=${INTERNAL_SERVICE_SECRET:-} # Pinned connector network used as NetworkMode — the SEC-M-06 isolation # boundary. Same value tool-generator passed as DOCKER_NETWORK. - DEPLOYER_DOCKER_NETWORK=${STACK_PREFIX:-rsync-ai}-mcp # "Also-join" network for kafka-mcp-sink reachability; == the primary net # here, so the also-join is a no-op (matches tool-generator's prior # MCP_SHARED_NETWORK default of rsync-ai-mcp). - MCP_SHARED_NETWORK=${STACK_PREFIX:-rsync-ai}-mcp # Connector container-name prefix (--vX-Y-Z-mcp). Without it # an isolated stack's compose-managed connectors are not recognised as # protected, so a stopped one can never be restarted. - STACK_PREFIX=${STACK_PREFIX:-rsync-ai} # Named volume for the OAuth TokenManager, mounted at /root/.rsync-ai below. # SAME value tool-generator passed to connectors (docker_builder.py default), # so persisted connector OAuth tokens survive the tool-generator → deployer move. - OAUTH_TOKENS_VOLUME_NAME=rsync-ai-oauth-tokens # Passphrase the TokenManager derives its AES-GCM key from (PBKDF2, random # per-record salt). Without it every token write raised and was swallowed, # so the volume above stayed empty while store_token() logged success — # OAUTH_ENCRYPTION_KEY, which takes precedence, is set by no compose file # and no chart. Reusing ENCRYPTION_KEY keeps this to one secret per # deployment instead of a second one nothing remembers to set. - ENCRYPTION_KEY=${ENCRYPTION_KEY:-dev-encryption-key-32-bytes-long!!} # THE trusted socket holder: runs as root (0:0) and mounts the RW docker.sock. # This is the one small, audited surface that speaks the Docker API — moved # OFF tool-generator, which runs untrusted connector code. user: "0:0" networks: - default - rsync-ai-mcp volumes: # RW docker socket — build images + create/start/stop/rm JIT connector containers. - /var/run/docker.sock:/var/run/docker.sock # Connector artifacts, READ-ONLY: the deployer only reads build contexts, # never writes them. - ./shared/mcp-connectors:/app/shared/mcp-connectors:ro # docker-compose.mcp.yml, READ-ONLY: protected-compose-container detection # (never rebuild/tear down a compose-managed connector serving live CDC). - ./docker-compose.mcp.yml:/app/docker-compose.mcp.yml:ro # The SAME OAuth TokenManager volume tool-generator used, for token persistence. - tool_generator_oauth_tokens:/root/.rsync-ai logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # Connector-tree ownership init (one-shot). SEC-H-02 (#643) correctly dropped # tool-generator to the image's unprivileged appuser (uid 1000) — but that same # service still WRITES generated connector source into the RW-mounted # ./shared/mcp-connectors tree (public//versions/… and oauth/providers.json). # On a root-owned checkout (prod lives at /root/rsync-ai) that tree is root:… 0755, # so uid 1000 can't create public/ and connector GENERATION fails with # PermissionError (regression found on prod 2026-07-20; the #643 staging E2E only # exercised DEPLOYING existing connectors, never generating a new one). This root # one-shot chowns the tree to uid 1000 before tool-generator starts, so a fresh # checkout / re-provision can't silently re-break generation. # # NOT a SEC-H-02 regression: this container holds NO docker.sock and touches ONLY # the connector tree. The host-root surface #643 removed is the docker socket, # which lives solely on connector-deployer — this init never goes near it. # (OSS/quickstart are unaffected: they mount the tree :ro from a named volume # seeded by connector-seed, so they never take this base service.) connector-fs-init: image: busybox:1.37.0 container_name: rsync-ai-connector-fs-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" user: "0:0" command: ["chown", "-R", "1000:1000", "/app/shared/mcp-connectors"] volumes: - ./shared/mcp-connectors:/app/shared/mcp-connectors restart: "no" # Tool Generator Agent tool-generator: build: context: ./llm-service dockerfile: Dockerfile container_name: rsync-ai-tool-generator env_file: # Gitignored local override. `required: false` so `docker compose config` # (e.g. the e2e/staging wiring guards) doesn't hard-fail on a runner that # has no local .env; the file is still loaded wherever it exists. - path: ./llm-service/.env required: false environment: - PORT=5010 - TOOLS_DIR=/app/shared/mcp-connectors # Default: enabled for local dev. In managed mode tool-generator asks the # connector-deployer (DEPLOYER_URL) to build images and start containers on # the rsync-ai-mcp network in response to /v1/deploy (called by the # orchestrator's Preflight agent when a required MCP container isn't already # running). tool-generator no longer touches docker.sock itself (SEC-H-02): # the connector-deployer is the sole trusted socket holder. Override to # "false" for environments where MCPs are managed by an external # docker-compose.mcp.yml stack (docker-compose.prod.yml ships that pattern). - RSYNC_MANAGED_CONNECTORS=${RSYNC_MANAGED_CONNECTORS:-true} # SEC-H-02: JIT build/run is delegated to the connector-deployer over an # authenticated internal RPC. INTERNAL_SERVICE_SECRET is sent as # X-Internal-Secret; unset in dev => no-op (deployer allows with a warning). - DEPLOYER_URL=http://connector-deployer:5011 - INTERNAL_SERVICE_SECRET=${INTERNAL_SERVICE_SECRET:-} - LOG_FORMAT=json - LOG_LEVEL=info - ENVIRONMENT=development # LLM Service URL (internal Docker network) - LLM_GATEWAY_URL=http://llm-service:5000 # Context7 MCP Service URL (internal Docker network) - CONTEXT7_MCP_URL=http://context7-mcp:8080 # Guardrail: require a strong match between connector name and resolved Context7 docs # to avoid false positives for random strings (tunable in prod). - CONTEXT7_VALIDATE_MIN_SCORE=0.90 - CONTEXT7_RESOLVE_MIN_SCORE=0.85 # LLM Provider Configuration # OPENAI_API_KEY must be provided via env_file (./llm-service/.env) or your shell environment. - OLLAMA_BASE_URL=http://host.docker.internal:11434/v1 - OLLAMA_MODEL=${OLLAMA_MODEL:-qwen2.5:7b} - CONTEXT7_MCP_URL=http://context7-mcp:8080 # JIT-deployed (user/LLM-generated) connectors run on the DEDICATED rsync-ai-mcp # network — isolated from the internal services that sit on rsync-ai_default # (kafka, temporal, redis, postgres) so untrusted connector code cannot reach # them by L3 (SEC-M-06). orchestrator, api-gateway, tool-generator, AND # kafka-mcp-sink all bridge into rsync-ai-mcp (see each service's networks: # block), so they still resolve JIT + compose-MCP connectors by container name # / alias there. This matches the compose-MCP topology (the generator script # scripts/mcp_generate_compose.py joins rsync-ai-mcp) and the OSS/quickstart # stacks (which already set DOCKER_NETWORK=rsync-ai-mcp). An earlier revision # put JIT containers on rsync-ai_default to fix a sink "no such host" bug; that # workaround is obsolete now that kafka-mcp-sink bridges into rsync-ai-mcp # (see its networks: block above). - DOCKER_NETWORK=${STACK_PREFIX:-rsync-ai}-mcp # Connector container-name prefix: the orchestrator looks for # --vX-Y-Z-mcp, so /v1/deploy must start that name. - STACK_PREFIX=${STACK_PREFIX:-rsync-ai} # OpenTelemetry Configuration (off by default; set OTEL_ENABLED=true to export to your own collector) - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317 - OTEL_SERVICE_NAME=tool-generator - OTEL_ENABLED=${OTEL_ENABLED:-false} - OTEL_TRACES_EXPORTER=otlp - OTEL_METRICS_EXPORTER=otlp # No host port — tool-generator is internal only, accessed via api-gateway # Entrypoint is src.lifecycle.main, NOT src.agents.tool_generator.service: the # latter is the connector-GENERATION service and is not part of this repo, so # its module would be missing and the container would die on import. This one # serves the connector-lifecycle surface the data plane calls (/v1/deploy). command: ["python", "-m", "src.lifecycle.main"] # SEC-H-02: tool-generator runs the image's default unprivileged appuser # (uid 1000) — it no longer needs root, because it no longer holds the # docker socket. All JIT build/run goes through the connector-deployer # (DEPLOYER_URL), the sole trusted socket holder. This is the security win: # the one service that runs LLM/user-influenced connector code is no longer # host-root-equivalent. networks: - default - rsync-ai-mcp volumes: - ./shared/mcp-connectors:/app/shared/mcp-connectors - ./docker-compose.mcp.yml:/app/docker-compose.mcp.yml # Persist OAuth TokenManager storage across container restarts. The # connector-deployer mounts this SAME named volume (SEC-H-02), so OAuth # tokens stay readable by the connectors it spawns. # NOTE (SEC-H-02): no docker.sock mount here anymore — managed-mode JIT # build/run is delegated to the connector-deployer over its internal RPC. - tool_generator_oauth_tokens:/root/.rsync-ai # Map form (not the short list) so connector-fs-init can gate on completion: # tool-generator must not start until the connector tree is chowned to uid 1000, # or its first generation write races the init and fails with PermissionError. depends_on: llm-service: condition: service_started context7-mcp: condition: service_started connector-deployer: condition: service_started connector-fs-init: condition: service_completed_successfully extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # Planner Agent planner: build: context: ./llm-service dockerfile: Dockerfile container_name: rsync-ai-planner env_file: # Gitignored local override. `required: false` so `docker compose config` # (e.g. the e2e/staging wiring guards) doesn't hard-fail on a runner that # has no local .env; the file is still loaded wherever it exists. - path: ./llm-service/.env required: false environment: # Kafka client security for a BYO/managed broker. See x-kafka-security at # the top of this file; every key defaults to empty and empty means unset, # so the bundled broker is unaffected. <<: *kafka-security PORT: "5011" LOG_FORMAT: "json" LOG_LEVEL: "info" ENVIRONMENT: "development" TOOL_GENERATOR_URL: "http://tool-generator:5010" API_GATEWAY_URL: "http://api-gateway:8080" LLM_GATEWAY_URL: "http://llm-service:5000" KAFKA_BOOTSTRAP_SERVERS: "${KAFKA_BROKERS:-kafka:29092}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: "${KAFKA_TOPIC_PREFIX-rsync.}" # JIT Integration: Planner needs access to check connector existence CONNECTORS_DIR: "/app/shared/mcp-connectors" # OpenTelemetry Configuration (off by default; set OTEL_ENABLED=true to export to your own collector) OTEL_EXPORTER_OTLP_ENDPOINT: "otel-collector:4317" OTEL_SERVICE_NAME: "planner" OTEL_ENABLED: "${OTEL_ENABLED:-false}" OTEL_TRACES_EXPORTER: "otlp" OTEL_METRICS_EXPORTER: "otlp" # Sent to the orchestrator's /api/v1/topology (strategies.py), which # requirePrincipal gates; without it plan-time topic creation gets 401. INTERNAL_SERVICE_SECRET: "${INTERNAL_SERVICE_SECRET:-}" volumes: # JIT Integration: Shared volume for connector existence checks - ./shared/mcp-connectors:/app/shared/mcp-connectors:ro ports: - "127.0.0.1:${RSYNC_HP_PLANNER:-8001}:5011" command: ["python", "-m", "src.agents.planner.service"] # Long-form depends_on: the short list form cannot carry `required: false`, # and the `kafka` entry below needs it — see docker-compose.byo-kafka.yml. # `service_started` on every entry is exactly what the short form meant. depends_on: llm-service: condition: service_started tool-generator: condition: service_started api-gateway: condition: service_started kafka: condition: service_started # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # Sentinel Agent (Infrastructure Watchdog) # Frontend (Next.js) frontend: container_name: rsync-ai-frontend build: context: ./frontend dockerfile: Dockerfile env_file: # Gitignored local override — optional so `docker compose config` works # on a runner without a local .env (loaded normally where it exists). - path: ./frontend/.env required: false ports: - "${RSYNC_BIND_ADDR:-127.0.0.1}:${RSYNC_HP_FRONTEND:-3000}:3000" environment: # Internal URLs for server-side fetches (within Docker network) - API_GATEWAY_INTERNAL_URL=http://api-gateway:8080 - ORCHESTRATOR_INTERNAL_URL=http://orchestrator:8080 # Server-only (NOT NEXT_PUBLIC): the RSC dashboard authenticates its # server-side call to the orchestrator with it (instrumentation.ts warns if unset). - INTERNAL_SERVICE_SECRET=${INTERNAL_SERVICE_SECRET:-} # Public URLs for client-side fetches (from browser via host) - NEXT_PUBLIC_API_URL=http://localhost:5001 - NEXT_PUBLIC_BACKEND_ORCHESTRATOR_URL=http://localhost:8080 - NEXT_PUBLIC_WS_URL=ws://localhost:5001/ws # Planner (agent) service exposed on host port 8001 - NEXT_PUBLIC_LLM_SERVICE_URL=http://localhost:8001 - NEXTAUTH_URL=http://localhost:3000 # OpenTelemetry Configuration (for browser tracing) - NEXT_PUBLIC_OTEL_ENDPOINT=http://localhost:4318 - NEXT_PUBLIC_OTEL_SERVICE_NAME=frontend # Monitoring UI feature flags (V1) - NEXT_PUBLIC_FEATURE_MONITORING_OVERVIEW=true - NEXT_PUBLIC_FEATURE_MONITORING_INFRA=true - NEXT_PUBLIC_FEATURE_MONITORING_TRACES=true depends_on: - api-gateway extra_hosts: - "host.docker.internal:host-gateway" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" volumes: postgres_data: kafka_data: redis_data: minio_data: blob_staging_data: # Debezium source secrets: written by debezium-mcp, read-only-mounted by # kafka-connect, so credentials resolve via FileConfigProvider instead of being # stored plaintext in the Kafka config topic. connect_secrets: tool_generator_oauth_tokens: name: rsync-ai-oauth-tokens networks: # rsync-ai-mcp: dedicated network for USER MCP connector containers — both the # compose-generated connectors (docker-compose.mcp.yml) and the JIT-deployed ones # (tool-generator docker_builder.py). orchestrator, tool-generator, api-gateway, # AND kafka-mcp-sink bridge into it so they can reach those connectors by container # name / alias. Internal services (postgres, redis, kafka, temporal) do NOT join # this network, so user connectors on it cannot reach them by L3 — this is the # SEC-M-06 isolation boundary (JIT connectors are pinned here via DOCKER_NETWORK). rsync-ai-mcp: name: rsync-ai-mcp