Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,114 @@
|
||||
# SAFE TEMPLATE ONLY. Copy outside Git and replace every REPLACE_* value.
|
||||
# This file contains references and ordinary configuration, never credentials.
|
||||
|
||||
# Operator-provided host paths and identity
|
||||
UNRAID_HOST=REPLACE_WITH_UNRAID_LAN_HOST_OR_IP
|
||||
APPDATA_ROOT=/srv/example/appdata/itworx-mcp-hub
|
||||
SECRETS_DIR=/srv/example/appdata/itworx-mcp-hub/secrets
|
||||
PROJECTS_READONLY_ROOT=/srv/example/appdata/itworx-mcp-hub/sources
|
||||
KNOWLEDGE_SOURCES_JSON=[{"project":"REPLACE_PROJECT","sourceRef":"repository","path":"REPLACE_RELATIVE_SOURCE_PATH","sourceType":"documentation","revision":"REPLACE_WITH_IMMUTABLE_COMMIT"}]
|
||||
BACKUP_ROOT=/srv/example/appdata/itworx-mcp-hub/backups
|
||||
PUID=REPLACE_WITH_NUMERIC_UID
|
||||
PGID=REPLACE_WITH_NUMERIC_GID
|
||||
DOCKER_SOCKET_GID=REPLACE_WITH_DOCKER_SOCKET_NUMERIC_GID
|
||||
|
||||
# Operator-provided bind addresses and generated Compose ports
|
||||
HUB_BIND_IP=REPLACE_WITH_UNRAID_LAN_IP
|
||||
MCP_BIND_IP=REPLACE_WITH_UNRAID_LAN_IP
|
||||
HUB_PORT=1100
|
||||
MCP_PORT=18442
|
||||
INSPECTOR_BIND_IP=REPLACE_ONLY_IF_INSPECTOR_IS_ENABLED
|
||||
INSPECTOR_PORT=18443
|
||||
|
||||
# Registry references. REGISTRY_CREDENTIAL_REF names an external credential
|
||||
# helper/login context; it is not a password.
|
||||
REGISTRY_HOST=REPLACE_WITH_REGISTRY_HOST
|
||||
REGISTRY_NAMESPACE=REPLACE_WITH_REGISTRY_NAMESPACE
|
||||
REGISTRY_CREDENTIAL_REF=REPLACE_WITH_OPERATOR_CREDENTIAL_REFERENCE
|
||||
|
||||
# Immutable owned images: registry/path:tag@sha256:<64 lowercase hex>.
|
||||
HUB_CONTROL_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
HUB_WORKER_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
HUB_DATAPLANE_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
HUB_WEB_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
CONNECTOR_KNOWLEDGE_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
CONNECTOR_GITEA_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
CONNECTOR_UNRAID_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
CONNECTOR_PROJECTBRAIN_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
CONNECTOR_APPOPS_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
CONNECTOR_STITCH_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
DOCKER_READ_BROKER_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
OBSERVABILITY_COLLECTOR_IMAGE=REPLACE_WITH_IMMUTABLE_IMAGE
|
||||
|
||||
# Authentik/OIDC ordinary configuration. Secret values live only in
|
||||
# $SECRETS_DIR/authentik_oidc_client_secret and hub_session_signing_key.
|
||||
HUB_OIDC_ISSUER=https://REPLACE_WITH_AUTHENTIK_HOST/application/o/itworx/
|
||||
HUB_OIDC_AUDIENCE=itworx-mcp-hub
|
||||
HUB_OIDC_CLIENT_ID=REPLACE_WITH_AUTHENTIK_CLIENT_ID
|
||||
HUB_OIDC_REDIRECT_URI=https://REPLACE_WITH_MCP_HOST/auth/callback
|
||||
HUB_OIDC_ROLE_GROUP_MAP={"REPLACE_WITH_ADMIN_GROUP":"admin","REPLACE_WITH_OPERATOR_GROUP":"operator"}
|
||||
HUB_MCP_ADDITIONAL_OIDC_ISSUER=https://REPLACE_WITH_AUTHENTIK_HOST/application/o/chatgpt-itworx-gitea/
|
||||
HUB_MCP_ADDITIONAL_OIDC_AUDIENCE=chatgpt-itworx-gitea
|
||||
HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID=chatgpt-itworx-gitea
|
||||
HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI=https://chatgpt.com/connector/oauth/REPLACE_WITH_CONNECTOR_ID
|
||||
HUB_PUBLIC_MCP_URL=https://REPLACE_WITH_MCP_HOST
|
||||
HUB_RATE_LIMIT_PER_MINUTE=600
|
||||
HUB_UPSTREAM_MAX_CONCURRENCY=24
|
||||
HUB_UPSTREAM_QUEUE_TIMEOUT_SECONDS=5
|
||||
AUTHENTIK_PROVIDER_ID=REPLACE_WITH_PROVIDER_ID
|
||||
AUTHENTIK_HUB_APPLICATION_ID=REPLACE_WITH_APPLICATION_ID
|
||||
AUTHENTIK_SCOPES=openid,profile,email,groups,offline_access
|
||||
AUTHENTIK_ALLOWED_GROUPS=REPLACE_WITH_COMMA_SEPARATED_GROUPS
|
||||
AUTHENTIK_SERVICE_ACCOUNTS=["REPLACE_WITH_SCOPED_SERVICE_ACCOUNT"]
|
||||
MCP_CLIENT_BINDINGS_JSON=[{"clientId":"REPLACE_WITH_UUID","subject":"REPLACE_WITH_OIDC_SUBJECT","project":"REPLACE_PROJECT","environment":"prod","enabled":true}]
|
||||
|
||||
# ContextForge internal endpoint/configuration. Credentials and key material
|
||||
# live in the named files under SECRETS_DIR.
|
||||
HUB_CONTEXTFORGE_URL=http://contextforge-control:4444
|
||||
CONTEXTFORGE_DB_HOST=postgres
|
||||
CONTEXTFORGE_DB_NAME=contextforge
|
||||
CONTEXTFORGE_DB_USER=contextforge
|
||||
REDIS_AUTH_ENABLED=true
|
||||
|
||||
# Existing Gitea; this deploys only the connector, never another Gitea server.
|
||||
GITEA_ENDPOINT=https://REPLACE_WITH_GITEA_HOST
|
||||
GITEA_REPOSITORIES_JSON=[{"project":"REPLACE_PROJECT","owner":"REPLACE_OWNER","name":"REPLACE_REPOSITORY"}]
|
||||
GITEA_ADDITIONAL_PROJECTS=gitea-loop
|
||||
GITEA_AUTO_DISCOVER_PROJECT=
|
||||
GITEA_WRITE_OWNER=
|
||||
GITEA_TOKEN_SCOPE=read:repository
|
||||
GITEA_WRITE_TOKEN_SCOPE=write:repository,write:issue
|
||||
GITEA_ENABLE_PR_WRITES=true
|
||||
|
||||
# Existing ProjectBrain; the Hub uses a dedicated server-to-server secret file.
|
||||
PROJECTBRAIN_ENDPOINT=https://projectbrain.example.com
|
||||
PROJECTBRAIN_PROJECTS=gitea-all
|
||||
|
||||
# Existing private AppOps MCP; the Hub reuses its dedicated MCP service token.
|
||||
APPOPS_ENDPOINT=http://198.18.10.150:1216
|
||||
APPOPS_PROJECTS=server-ops
|
||||
|
||||
# Google Stitch remote MCP. The API key lives only in $SECRETS_DIR/stitch_api_key.
|
||||
STITCH_HUB_PROJECTS=stitch-design
|
||||
|
||||
# Existing NPM and DNS inputs. NETWORK_TARGETS_JSON is checked only with the
|
||||
# explicit --verify-network-targets flag.
|
||||
#
|
||||
# Production uses a single public hostname (see
|
||||
# infrastructure/nginx-proxy-manager/ROUTING.md): one NPM proxy host and one
|
||||
# DNS record for REPLACE_WITH_MCP_HOST, routed by path (default location ->
|
||||
# web:8080, "/mcp" custom location -> dataplane-router:8080). The *_HUB_*
|
||||
# variables below are the management-surface half of that single-host
|
||||
# routing config, not a second host -- set them to the exact same hostname
|
||||
# value as the *_MCP_* variables. Never set a distinct hub.example.com value.
|
||||
NPM_NETWORK_NAME=REPLACE_WITH_EXISTING_NPM_NETWORK
|
||||
NPM_HUB_PROXY_HOST=REPLACE_WITH_MCP_HOST
|
||||
NPM_MCP_PROXY_HOST=REPLACE_WITH_MCP_HOST
|
||||
NPM_CERTIFICATE_REF=REPLACE_WITH_CERTIFICATE_REFERENCE
|
||||
DNS_HUB_HOSTNAME=REPLACE_WITH_MCP_HOST
|
||||
DNS_MCP_HOSTNAME=REPLACE_WITH_MCP_HOST
|
||||
DNS_EXPECTED_ADDRESSES_JSON={"REPLACE_WITH_MCP_HOST":["REPLACE_WITH_INGRESS_IP"]}
|
||||
NETWORK_TARGETS_JSON={"registry":"REPLACE_WITH_REGISTRY_HOST:443","gitea":"REPLACE_WITH_GITEA_HOST:443"}
|
||||
|
||||
# Generated during deployment; do not hand-edit into credentials.
|
||||
UNRAID_REGISTRATIONS_FILE=/srv/example/appdata/itworx-mcp-hub/config/registered-targets.json
|
||||
@@ -0,0 +1,52 @@
|
||||
# Docker Compose implementation requirements
|
||||
|
||||
`docker-compose.blueprint.yml` is an executable architecture blueprint, not a release manifest. `WP-020` must replace every `:?` image variable with an exact tag **and digest**, build local images, and generate a validated production override.
|
||||
|
||||
## Compose project
|
||||
|
||||
- fixed project name: `itworx-mcp-hub`;
|
||||
- dedicated appdata root, default proposal: `/srv/example/appdata/itworx-mcp-hub`;
|
||||
- no modification of unrelated containers, networks, volumes, or proxy hosts;
|
||||
- no service uses `network_mode: host`;
|
||||
- only the explicit management, dataplane, and optional Inspector bindings may publish host ports;
|
||||
- PostgreSQL, Redis, ContextForge, workers, connectors, and broker remain internal;
|
||||
- all local services run non-root, drop Linux capabilities, use `no-new-privileges`, and use a read-only root filesystem where practical;
|
||||
- each service has bounded memory/CPU/logging and a real healthcheck;
|
||||
- startup ordering uses health conditions but applications must still tolerate dependency restarts;
|
||||
- Redis is non-authoritative and may be emptied without data loss;
|
||||
- the Docker socket is mounted only into `docker-read-broker`, read-only;
|
||||
- target-system credentials are Compose secrets or equivalent mounted files, never normal environment values;
|
||||
- release deployments use immutable image digests and record them in release evidence.
|
||||
|
||||
## Required profiles
|
||||
|
||||
- default/core: production Hub services;
|
||||
- `project-connectors`: optional connectors requiring external project credentials;
|
||||
- `inspector`: MCP Inspector bound to an explicitly configured LAN address only;
|
||||
- `observability`: optional OTel Collector/Grafana stack if not reusing existing services;
|
||||
- `fake`: deterministic fake targets for clean-room acceptance.
|
||||
|
||||
## Required generated outputs in WP-020
|
||||
|
||||
- `docker-compose.yml` or an equivalent production base;
|
||||
- Unraid-specific override;
|
||||
- development/fake override;
|
||||
- exact `.env.example` without secrets;
|
||||
- all Dockerfiles and healthchecks;
|
||||
- `docker compose config` evidence;
|
||||
- image pin/digest inventory;
|
||||
- network and published-port assertion tests.
|
||||
|
||||
## Production build-fallback (WP-200 / DIAG-02)
|
||||
|
||||
`docker-compose.prod.yml` must cancel every owned service's inherited `build:`
|
||||
block using the Compose Specification's `!reset` YAML tag — `build: !reset
|
||||
null` — never plain `build: null`. Plain `null` is not treated as "delete
|
||||
this key" by the real Compose merge engine; the base file's `build:` mapping
|
||||
survives and `docker compose up -d` will attempt a local build (and fail in a
|
||||
confusing way, or in the worst case succeed and deploy an unscanned image)
|
||||
if a registry pull ever fails. `tests/test_compose.py`'s
|
||||
`test_real_compose_config_has_no_build_for_owned_services` enforces this
|
||||
against the real `docker compose config` CLI output; any new owned service
|
||||
added to `docker-compose.prod.yml` must be added to that test's service list
|
||||
and use the same `!reset` pattern.
|
||||
@@ -0,0 +1,17 @@
|
||||
name: itworx-mcp-hub
|
||||
|
||||
services:
|
||||
postgres:
|
||||
volumes: [postgres-data-dev:/var/lib/postgresql/data]
|
||||
contextforge:
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 4444
|
||||
AUTH_REQUIRED: "true"
|
||||
MCPGATEWAY_UI_ENABLED: "false"
|
||||
MCPGATEWAY_ADMIN_API_ENABLED: "false"
|
||||
inspector:
|
||||
profiles: [inspector]
|
||||
|
||||
volumes:
|
||||
postgres-data-dev: {name: itworx-mcp-hub-postgres-dev}
|
||||
@@ -0,0 +1,528 @@
|
||||
name: itworx-mcp-hub
|
||||
|
||||
x-owned-service: &owned-service
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
tmpfs: ["/tmp:size=64m,mode=1777"]
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 1.0
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "10m", max-file: "3"}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)"]
|
||||
interval: 60s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
x-third-party: &third-party
|
||||
restart: unless-stopped
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "10m", max-file: "3"}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
<<: *third-party
|
||||
image: postgres:18.4@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a
|
||||
entrypoint: ["sh", "/opt/itworx/postgres-entrypoint.sh"]
|
||||
command: ["postgres"]
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_superuser_password
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
SECRET_EXPECTED_UID: "${PUID:-1000}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-1000}"
|
||||
secrets: [postgres_superuser_password, hub_database_password, contextforge_database_password]
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ../postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
- ../images/postgres/entrypoint.sh:/opt/itworx/postgres-entrypoint.sh:ro
|
||||
tmpfs: ["/run/itworx-secrets:size=1m,mode=0700"]
|
||||
networks: [hub-data]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
<<: *third-party
|
||||
image: redis:8.10.0@sha256:c29e49ab2f85760a3827b53882e6dd9f5c6c3f0bb7d724e07bb31cbf275a5236
|
||||
entrypoint: ["sh", "/opt/itworx/redis-entrypoint.sh"]
|
||||
environment:
|
||||
REDIS_AUTH_ENABLED: "${REDIS_AUTH_ENABLED:-false}"
|
||||
SECRET_EXPECTED_UID: "${PUID:-1000}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-1000}"
|
||||
volumes:
|
||||
- ../images/redis/entrypoint.sh:/opt/itworx/redis-entrypoint.sh:ro
|
||||
- ../images/redis/healthcheck.sh:/opt/itworx/redis-healthcheck.sh:ro
|
||||
secrets: [redis_password]
|
||||
tmpfs: ["/run/itworx:size=1m,mode=0700"]
|
||||
networks: [hub-data]
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "/opt/itworx/redis-healthcheck.sh"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
contextforge:
|
||||
<<: *third-party
|
||||
image: ghcr.io/ibm/mcp-context-forge:v1.0.6@sha256:049b8e61fa1187f72773d94bcf91fc63338f4fea7808949a8a5daacbc89ef92e
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
entrypoint: ["sh", "/opt/itworx/contextforge-entrypoint.sh"]
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 4444
|
||||
AUTH_REQUIRED: "true"
|
||||
MCPGATEWAY_UI_ENABLED: "false"
|
||||
MCPGATEWAY_ADMIN_API_ENABLED: "false"
|
||||
MCPGATEWAY_MCP_APPS_ENABLED: "true"
|
||||
ENABLE_HEADER_PASSTHROUGH: "true"
|
||||
API_ALLOW_BASIC_AUTH: "false"
|
||||
PLATFORM_ADMIN_EMAIL: "${CONTEXTFORGE_PLATFORM_ADMIN_EMAIL:-contextforge-admin@example.com}"
|
||||
SSRF_PROTECTION_ENABLED: "true"
|
||||
SSRF_ALLOW_PRIVATE_NETWORKS: "false"
|
||||
SSRF_DNS_FAIL_CLOSED: "true"
|
||||
SSRF_ALLOWED_NETWORKS: '["203.0.113.0/24"]'
|
||||
UAID_ALLOWED_DOMAINS: '["example.com"]'
|
||||
GUNICORN_WORKERS: "2"
|
||||
# ContextForge only receives authenticated internal Hub traffic. Its
|
||||
# generic limiter groups every caller behind the router's single IP and
|
||||
# can lock out all ChatGPT sessions at once. Admission control therefore
|
||||
# belongs to the public dataplane router below.
|
||||
RATE_LIMITING_ENABLED: "false"
|
||||
REDIS_AUTH_ENABLED: "${REDIS_AUTH_ENABLED:-false}"
|
||||
SECRET_EXPECTED_UID: "${PUID:-1000}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-1000}"
|
||||
volumes:
|
||||
- ../images/contextforge/entrypoint.sh:/opt/itworx/contextforge-entrypoint.sh:ro
|
||||
secrets: [contextforge_database_password, contextforge_auth_encryption_secret, contextforge_jwt_secret, contextforge_admin_credential, redis_password]
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
redis: {condition: service_healthy}
|
||||
networks:
|
||||
hub-control:
|
||||
aliases: [contextforge-control]
|
||||
hub-connectors: {}
|
||||
hub-data: {}
|
||||
hub-observe: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:4444/health', timeout=3)\""]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
|
||||
control-api:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: control-api}}
|
||||
image: local/itworx-mcp-hub-control:dev
|
||||
environment:
|
||||
HUB_OIDC_ISSUER: "${HUB_OIDC_ISSUER:-https://auth.dev.invalid/application/o/itworx/}"
|
||||
HUB_OIDC_AUDIENCE: "${HUB_OIDC_AUDIENCE:-itworx-mcp-hub}"
|
||||
HUB_OIDC_CLIENT_ID: "${HUB_OIDC_CLIENT_ID:-itworx-mcp-hub-dev}"
|
||||
HUB_OIDC_REDIRECT_URI: "${HUB_OIDC_REDIRECT_URI:-https://hub.dev.invalid/auth/callback}"
|
||||
HUB_OIDC_CLIENT_SECRET_FILE: /run/secrets/authentik_oidc_client_secret
|
||||
HUB_OIDC_ROLE_GROUP_MAP: '${HUB_OIDC_ROLE_GROUP_MAP:-{"hub-admin":"admin"}}'
|
||||
HUB_SESSION_SIGNING_KEY_FILE: /run/secrets/hub_session_signing_key
|
||||
HUB_CONTEXTFORGE_URL: "${HUB_CONTEXTFORGE_URL:-http://contextforge-control:4444}"
|
||||
HUB_CONTEXTFORGE_TOKEN_FILE: /run/secrets/contextforge_api_token
|
||||
HUB_CONTEXTFORGE_AUTH_SCHEME: Bearer
|
||||
HUB_DATABASE_PASSWORD_FILE: /run/secrets/hub_database_password
|
||||
HUB_DATABASE_HOST: postgres
|
||||
HUB_DATABASE_NAME: itworx_mcp_hub
|
||||
HUB_DATABASE_USER: itworx_mcp_hub
|
||||
HUB_CATALOG_ROOT: /opt/hub/catalog
|
||||
INGESTION_CONNECTOR_ENABLED: "${INGESTION_CONNECTOR_ENABLED:-false}"
|
||||
HUB_PUBLIC_MCP_URL: "${HUB_PUBLIC_MCP_URL:-https://mcp.example.com}"
|
||||
HUB_BACKUP_STATUS_FILE: /var/lib/itworx/backup-status.json
|
||||
HUB_BACKUP_STALE_AFTER_HOURS: "${HUB_BACKUP_STALE_AFTER_HOURS:-26}"
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/authentik_oidc_client_secret,/run/secrets/hub_session_signing_key,/run/secrets/contextforge_api_token,/run/secrets/hub_database_password,/run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [authentik_oidc_client_secret, hub_session_signing_key, contextforge_api_token, hub_database_password, connector_identity_signing_key]
|
||||
volumes:
|
||||
- "${BACKUP_STATUS_FILE_HOST:-../../examples/backup-status.json}:/var/lib/itworx/backup-status.json:ro"
|
||||
- "../../catalog:/opt/hub/catalog:ro"
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
redis: {condition: service_healthy}
|
||||
contextforge: {condition: service_healthy}
|
||||
networks: [hub-control, hub-connectors, hub-data, hub-observe, hub-egress]
|
||||
|
||||
worker:
|
||||
<<: *owned-service
|
||||
user: "${PUID:-10001}:${PGID:-10001}"
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: worker}}
|
||||
image: local/itworx-mcp-hub-worker:dev
|
||||
environment:
|
||||
HUB_OIDC_ISSUER: "${HUB_OIDC_ISSUER:-https://auth.dev.invalid/application/o/itworx/}"
|
||||
HUB_CONTEXTFORGE_URL: "${HUB_CONTEXTFORGE_URL:-http://contextforge-control:4444}"
|
||||
HUB_CONTEXTFORGE_TOKEN_FILE: /run/secrets/contextforge_api_token
|
||||
HUB_CONTEXTFORGE_AUTH_SCHEME: Bearer
|
||||
HUB_DATABASE_PASSWORD_FILE: /run/secrets/hub_database_password
|
||||
HUB_DATABASE_HOST: postgres
|
||||
HUB_DATABASE_NAME: itworx_mcp_hub
|
||||
HUB_DATABASE_USER: itworx_mcp_hub
|
||||
HUB_CATALOG_ROOT: /opt/hub/catalog
|
||||
INGESTION_CONNECTOR_ENABLED: "${INGESTION_CONNECTOR_ENABLED:-false}"
|
||||
MCP_CLIENT_BINDINGS_JSON: '${MCP_CLIENT_BINDINGS_JSON:-[]}'
|
||||
REQUIRED_SECRET_FILES: /run/secrets/hub_database_password,/run/secrets/contextforge_api_token
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [hub_database_password, contextforge_api_token]
|
||||
volumes:
|
||||
- "../../catalog:/opt/hub/catalog:ro"
|
||||
- "../../services/runtime/state_sync.py:/opt/hub/services/runtime/state_sync.py:ro"
|
||||
depends_on:
|
||||
control-api: {condition: service_healthy}
|
||||
redis: {condition: service_healthy}
|
||||
connector-knowledge: {condition: service_healthy}
|
||||
connector-gitea: {condition: service_healthy}
|
||||
connector-unraid: {condition: service_healthy}
|
||||
connector-mobilityops: {condition: service_healthy}
|
||||
connector-projectbrain: {condition: service_healthy}
|
||||
connector-appops: {condition: service_healthy}
|
||||
connector-stitch: {condition: service_healthy}
|
||||
networks: [hub-control, hub-connectors, hub-data, hub-observe]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=2)"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 18
|
||||
start_period: 10s
|
||||
|
||||
dataplane-router:
|
||||
<<: *owned-service
|
||||
# Docker bind-mounted secret files retain the Unraid PUID/PGID. Run the
|
||||
# router as that already unprivileged identity so its fail-closed secret
|
||||
# validation can read files that remain owner-only (0600).
|
||||
user: "${PUID:-10001}:${PGID:-10001}"
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: dataplane-router}}
|
||||
image: local/itworx-mcp-hub-dataplane:dev
|
||||
environment:
|
||||
HUB_OIDC_ISSUER: "${HUB_OIDC_ISSUER:-https://auth.dev.invalid/application/o/itworx/}"
|
||||
HUB_OIDC_AUDIENCE: "${HUB_OIDC_AUDIENCE:-itworx-mcp-hub}"
|
||||
HUB_OIDC_CLIENT_ID: "${HUB_OIDC_CLIENT_ID:-itworx-mcp-hub-dev}"
|
||||
HUB_OIDC_REDIRECT_URI: "${HUB_OIDC_REDIRECT_URI:-https://hub.dev.invalid/auth/callback}"
|
||||
HUB_MCP_ADDITIONAL_OIDC_ISSUER: "${HUB_MCP_ADDITIONAL_OIDC_ISSUER:-}"
|
||||
HUB_MCP_ADDITIONAL_OIDC_AUDIENCE: "${HUB_MCP_ADDITIONAL_OIDC_AUDIENCE:-}"
|
||||
HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID: "${HUB_MCP_ADDITIONAL_OIDC_CLIENT_ID:-}"
|
||||
HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI: "${HUB_MCP_ADDITIONAL_OIDC_REDIRECT_URI:-}"
|
||||
HUB_PUBLIC_MCP_URL: "${HUB_PUBLIC_MCP_URL:-https://mcp.example.com}"
|
||||
HUB_CONTEXTFORGE_URL: "${HUB_CONTEXTFORGE_URL:-http://contextforge-control:4444}"
|
||||
HUB_CONTEXTFORGE_TOKEN_FILE: /run/secrets/contextforge_api_token
|
||||
HUB_CONTEXTFORGE_AUTH_SCHEME: Bearer
|
||||
HUB_DATABASE_PASSWORD_FILE: /run/secrets/hub_database_password
|
||||
HUB_DATABASE_HOST: postgres
|
||||
HUB_DATABASE_NAME: itworx_mcp_hub
|
||||
HUB_DATABASE_USER: itworx_mcp_hub
|
||||
HUB_CATALOG_ROOT: /opt/hub/catalog
|
||||
HUB_RATE_LIMIT_PER_MINUTE: "${HUB_RATE_LIMIT_PER_MINUTE:-600}"
|
||||
HUB_UPSTREAM_MAX_CONCURRENCY: "${HUB_UPSTREAM_MAX_CONCURRENCY:-24}"
|
||||
HUB_UPSTREAM_QUEUE_TIMEOUT_SECONDS: "${HUB_UPSTREAM_QUEUE_TIMEOUT_SECONDS:-5}"
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
INGESTION_CONNECTOR_ENABLED: "${INGESTION_CONNECTOR_ENABLED:-false}"
|
||||
DEMO_TOKEN_KEY_FILE: /run/secrets/demo_token_signing_key
|
||||
DEMO_TOKEN_REVOCATIONS_FILE: /etc/itworx/demo-token-revocations.json
|
||||
DEMO_TOKEN_RATE_LIMIT_PER_MINUTE: "${DEMO_TOKEN_RATE_LIMIT_PER_MINUTE:-10}"
|
||||
REQUIRED_SECRET_FILES: /run/secrets/contextforge_api_token,/run/secrets/hub_database_password,/run/secrets/connector_identity_signing_key,/run/secrets/demo_token_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [contextforge_api_token, hub_database_password, connector_identity_signing_key, demo_token_signing_key]
|
||||
volumes:
|
||||
- "../../catalog:/opt/hub/catalog:ro"
|
||||
- "../../services/runtime/dataplane.py:/opt/hub/services/runtime/dataplane.py:ro"
|
||||
# Never silently fall back to the repository example: that would make
|
||||
# the operator's early-revocation file ineffective.
|
||||
# Mount the containing directory: the operator verifier atomically replaces
|
||||
# the file and therefore needs a writable parent directory in its profile.
|
||||
- "${DEMO_TOKEN_REVOCATIONS_DIR_HOST:?Set the host-side demo-token revocations directory}:/etc/itworx:ro"
|
||||
depends_on: {postgres: {condition: service_healthy}, contextforge: {condition: service_healthy}}
|
||||
networks: [hub-edge, hub-control, hub-data, hub-observe, hub-egress]
|
||||
ports: ["${MCP_BIND_IP:-127.0.0.1}:${MCP_PORT:-18442}:8080"]
|
||||
|
||||
operator-tools:
|
||||
# Explicit one-shot profile for operator-only proofs. It receives no Docker
|
||||
# socket and does not start as part of the normal application topology.
|
||||
image: local/itworx-mcp-hub-dataplane:dev
|
||||
profiles: [operator-tools]
|
||||
user: "${PUID:-10001}:${PGID:-10001}"
|
||||
working_dir: /opt/hub
|
||||
entrypoint: ["python", "-m", "scripts.verify_demo_token_flow"]
|
||||
secrets: [demo_token_signing_key]
|
||||
volumes:
|
||||
- "${DEMO_TOKEN_REVOCATIONS_DIR_HOST:?Set the host-side demo-token revocations directory}:/etc/itworx:rw"
|
||||
networks: [hub-edge]
|
||||
|
||||
web:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: web}}
|
||||
image: local/itworx-mcp-hub-web:dev
|
||||
environment:
|
||||
HUB_PUBLIC_MCP_URL: "${HUB_PUBLIC_MCP_URL:-https://mcp.example.com}"
|
||||
HUB_MCP_ADDITIONAL_OIDC_ISSUER: "${HUB_MCP_ADDITIONAL_OIDC_ISSUER:-https://auth.example-host.com/application/o/chatgpt-itworx-gitea/}"
|
||||
HUB_WORKER_URL: "http://worker:8080"
|
||||
volumes:
|
||||
- "../../services/runtime/web.py:/opt/hub/services/runtime/web.py:ro"
|
||||
depends_on:
|
||||
control-api: {condition: service_healthy}
|
||||
worker: {condition: service_healthy}
|
||||
networks: [hub-edge, hub-control, hub-observe]
|
||||
ports: ["${HUB_BIND_IP:-127.0.0.1}:${HUB_PORT:-1100}:8080"]
|
||||
|
||||
connector-knowledge:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-knowledge}}
|
||||
image: local/itworx-mcp-hub-connector-knowledge:dev
|
||||
volumes:
|
||||
- "${PROJECTS_READONLY_ROOT:-../../}:/sources:ro"
|
||||
environment:
|
||||
KNOWLEDGE_SOURCES_JSON: '${KNOWLEDGE_SOURCES_JSON:-[]}'
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [connector_identity_signing_key]
|
||||
networks: [hub-connectors, hub-observe]
|
||||
|
||||
connector-ingestion:
|
||||
<<: *owned-service
|
||||
profiles: [knowledge-intake]
|
||||
# Keep /tmp writable to the non-root connector user; each intake job creates
|
||||
# its own 0700 directory and 0600 file inside this shared sticky tmpfs.
|
||||
tmpfs: ["/tmp:size=128m,mode=1777"]
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-ingestion}}
|
||||
image: local/itworx-mcp-hub-connector-ingestion:dev
|
||||
environment:
|
||||
KNOWLEDGE_HUB_ENDPOINT: "${KNOWLEDGE_HUB_ENDPOINT:-https://knowledge-hub.invalid}"
|
||||
KNOWLEDGE_HUB_ALLOWED_PROJECT_IDS: "${KNOWLEDGE_HUB_ALLOWED_PROJECT_IDS:-}"
|
||||
KNOWLEDGE_HUB_WORKSPACE_READ_ENABLED: "${KNOWLEDGE_HUB_WORKSPACE_READ_ENABLED:-false}"
|
||||
KNOWLEDGE_HUB_INGESTION_TOKEN_FILE: /run/secrets/knowledge_hub_ingestion_token
|
||||
INGESTION_SCANNER_HOST: ingestion-clamd
|
||||
INGESTION_SCANNER_PORT: "3310"
|
||||
KNOWLEDGE_HUB_INGESTION_IDENTITY_KEY_FILE: /run/secrets/knowledge_hub_ingestion_identity_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/knowledge_hub_ingestion_token,/run/secrets/knowledge_hub_ingestion_identity_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [knowledge_hub_ingestion_token, knowledge_hub_ingestion_identity_key]
|
||||
depends_on: {ingestion-clamd: {condition: service_started}}
|
||||
networks: [hub-connectors, hub-observe, hub-egress]
|
||||
|
||||
ingestion-clamd:
|
||||
<<: *third-party
|
||||
profiles: [knowledge-intake]
|
||||
image: clamav/clamav:1.4.6@sha256:a5f03c12a79dbe9f6d8a527b6bb1ea053fa8dd061d3738a26897f055ee2d9303
|
||||
cap_drop: ["ALL"]
|
||||
# /init must chown its signature volume, prepare log files, and drop to clamav.
|
||||
cap_add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"]
|
||||
mem_limit: 3g
|
||||
pids_limit: 128
|
||||
volumes: ["ingestion-clamav-db:/var/lib/clamav"]
|
||||
networks: [hub-connectors, hub-egress]
|
||||
|
||||
connector-gitea:
|
||||
<<: *owned-service
|
||||
tmpfs: ["/tmp:size=512m,mode=1777"]
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-gitea}}
|
||||
image: local/itworx-mcp-hub-connector-gitea:dev
|
||||
environment:
|
||||
GITEA_CONNECTOR_BUILD_SHA: "${GITEA_COMMIT_SHA:-unknown}"
|
||||
GITEA_ENDPOINT: "${GITEA_ENDPOINT:-https://gitea.dev.invalid}"
|
||||
GITEA_TOKEN_FILE: /run/secrets/gitea_mcp_token
|
||||
GITEA_ENABLE_PR_WRITES: "${GITEA_ENABLE_PR_WRITES:-false}"
|
||||
GITEA_WRITE_TOKEN_FILE: /run/secrets/gitea_mcp_write_token
|
||||
GITEA_WRITE_OWNER: '${GITEA_WRITE_OWNER:-}'
|
||||
GITEA_REPOSITORIES_JSON: '${GITEA_REPOSITORIES_JSON:-[{"project":"dev","owner":"dev","name":"dev"}]}'
|
||||
GITEA_ADDITIONAL_PROJECTS: '${GITEA_ADDITIONAL_PROJECTS:-gitea-loop}'
|
||||
GITEA_AUTO_DISCOVER_PROJECT: '${GITEA_AUTO_DISCOVER_PROJECT:-}'
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/gitea_mcp_token,/run/secrets/gitea_mcp_write_token,/run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [gitea_mcp_token, gitea_mcp_write_token, connector_identity_signing_key]
|
||||
volumes:
|
||||
- "../../services/runtime/connector_gitea.py:/opt/hub/services/runtime/connector_gitea.py:ro"
|
||||
depends_on: {contextforge: {condition: service_healthy}}
|
||||
networks: [hub-connectors, hub-observe, hub-egress]
|
||||
|
||||
connector-mobilityops:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-mobilityops}}
|
||||
image: local/itworx-mcp-hub-connector-mobilityops:dev
|
||||
environment:
|
||||
MOBILITYOPS_ENDPOINT: "${MOBILITYOPS_ENDPOINT:-https://mobilityops.dev.invalid}"
|
||||
MOBILITYOPS_SERVICE_TOKEN_FILE: /run/secrets/mobilityops_service_token
|
||||
MOBILITYOPS_PROJECTS_JSON: '${MOBILITYOPS_PROJECTS_JSON:-["mobilityops"]}'
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/mobilityops_service_token,/run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [mobilityops_service_token, connector_identity_signing_key]
|
||||
depends_on: {contextforge: {condition: service_healthy}}
|
||||
networks: [hub-connectors, hub-observe, hub-egress]
|
||||
|
||||
connector-projectbrain:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-projectbrain}}
|
||||
image: local/itworx-mcp-hub-connector-projectbrain:dev
|
||||
environment:
|
||||
PROJECTBRAIN_ENDPOINT: "${PROJECTBRAIN_ENDPOINT:-https://projectbrain.dev.invalid}"
|
||||
PROJECTBRAIN_PROJECTS: "${PROJECTBRAIN_PROJECTS:-gitea-all},gitea-analysis,gitea-loop"
|
||||
PROJECTBRAIN_SERVICE_TOKEN_FILE: /run/secrets/projectbrain_hub_service_token
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/projectbrain_hub_service_token,/run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [projectbrain_hub_service_token, connector_identity_signing_key]
|
||||
depends_on: {contextforge: {condition: service_healthy}}
|
||||
networks: [hub-connectors, hub-observe, hub-egress]
|
||||
|
||||
connector-appops:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-appops}}
|
||||
image: local/itworx-mcp-hub-connector-appops:dev
|
||||
environment:
|
||||
APPOPS_ENDPOINT: "${APPOPS_ENDPOINT:-http://198.18.10.150:1216}"
|
||||
APPOPS_PROJECTS: "${APPOPS_PROJECTS:-server-ops}"
|
||||
APPOPS_SERVICE_TOKEN_FILE: /run/secrets/appops_hub_service_token
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/appops_hub_service_token,/run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [appops_hub_service_token, connector_identity_signing_key]
|
||||
depends_on: {contextforge: {condition: service_healthy}}
|
||||
networks: [hub-connectors, hub-observe, hub-egress]
|
||||
|
||||
connector-stitch:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-stitch}}
|
||||
image: local/itworx-mcp-hub-connector-stitch:dev
|
||||
environment:
|
||||
STITCH_ENDPOINT: "https://stitch.googleapis.com/mcp"
|
||||
STITCH_HUB_PROJECTS: "${STITCH_HUB_PROJECTS:-stitch-design}"
|
||||
STITCH_API_KEY_FILE: /run/secrets/stitch_api_key
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/stitch_api_key,/run/secrets/connector_identity_signing_key
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
secrets: [stitch_api_key, connector_identity_signing_key]
|
||||
depends_on: {contextforge: {condition: service_healthy}}
|
||||
networks: [hub-connectors, hub-observe, hub-egress]
|
||||
|
||||
docker-read-broker:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/docker-read-broker/Dockerfile}
|
||||
image: local/itworx-mcp-hub-docker-read-broker:dev
|
||||
environment:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://observability-collector:4318
|
||||
BROKER_TOKEN_FILE: /run/secrets/docker_broker_service_token
|
||||
BROKER_REGISTRATIONS_FILE: /etc/itworx/registered-targets.json
|
||||
SECRET_EXPECTED_UID: "${PUID:-10002}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10002}"
|
||||
secrets: [docker_broker_service_token]
|
||||
group_add: ["${DOCKER_SOCKET_GID:-10002}"]
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "${UNRAID_REGISTRATIONS_FILE:-../unraid/registered-targets.example.json}:/etc/itworx/registered-targets.json:ro"
|
||||
networks: [hub-broker, hub-observe]
|
||||
|
||||
connector-unraid:
|
||||
<<: *owned-service
|
||||
build: {context: ../.., dockerfile: infrastructure/images/python-service/Dockerfile, args: {SERVICE_NAME: connector-unraid}}
|
||||
image: local/itworx-mcp-hub-connector-unraid:dev
|
||||
environment:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://observability-collector:4318
|
||||
BROKER_URL: http://docker-read-broker:8080
|
||||
BROKER_TOKEN_FILE: /run/secrets/docker_broker_service_token
|
||||
SECRET_EXPECTED_UID: "${PUID:-10001}"
|
||||
SECRET_EXPECTED_GID: "${PGID:-10001}"
|
||||
UNRAID_REGISTRATIONS_FILE: /etc/itworx/registered-targets.json
|
||||
CONNECTOR_IDENTITY_KEY_FILE: /run/secrets/connector_identity_signing_key
|
||||
REQUIRED_SECRET_FILES: /run/secrets/docker_broker_service_token,/run/secrets/connector_identity_signing_key
|
||||
secrets: [docker_broker_service_token, connector_identity_signing_key]
|
||||
volumes:
|
||||
- "${UNRAID_REGISTRATIONS_FILE:-../unraid/registered-targets.example.json}:/etc/itworx/registered-targets.json:ro"
|
||||
depends_on: {docker-read-broker: {condition: service_healthy}}
|
||||
networks: [hub-connectors, hub-broker, hub-observe]
|
||||
|
||||
observability-collector:
|
||||
image: local/itworx-mcp-hub-observability:dev
|
||||
build: {context: ../.., dockerfile: infrastructure/images/observability/Dockerfile}
|
||||
profiles: [observability]
|
||||
networks: [hub-observe]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4318/healthz', timeout=2)"]
|
||||
interval: 60s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
inspector:
|
||||
<<: *third-party
|
||||
image: ghcr.io/modelcontextprotocol/inspector:2.0.0-rc.1@sha256:700500bbce937754d2ac0dd95797c3e057c8ebe2f4faf80f994652b0958a7478
|
||||
profiles: [inspector]
|
||||
networks: [hub-edge]
|
||||
ports: ["${INSPECTOR_BIND_IP:?Set an explicit LAN-only Inspector bind address}:${INSPECTOR_PORT:-18443}:6274"]
|
||||
|
||||
networks:
|
||||
hub-edge:
|
||||
name: itworx-mcp-hub-edge
|
||||
ipam: {config: [{subnet: 203.0.113.0/24}]}
|
||||
hub-egress:
|
||||
name: itworx-mcp-hub-egress
|
||||
ipam: {config: [{subnet: 203.0.113.0/24}]}
|
||||
hub-control:
|
||||
name: itworx-mcp-hub-control
|
||||
internal: true
|
||||
ipam: {config: [{subnet: 203.0.113.0/24}]}
|
||||
hub-connectors:
|
||||
name: itworx-mcp-hub-connectors
|
||||
internal: true
|
||||
ipam:
|
||||
config: [{subnet: 203.0.113.0/24}]
|
||||
hub-broker:
|
||||
name: itworx-mcp-hub-broker
|
||||
internal: true
|
||||
ipam: {config: [{subnet: 203.0.113.0/24}]}
|
||||
hub-data:
|
||||
name: itworx-mcp-hub-data
|
||||
internal: true
|
||||
ipam: {config: [{subnet: 203.0.113.0/24}]}
|
||||
hub-observe:
|
||||
name: itworx-mcp-hub-observe
|
||||
internal: true
|
||||
ipam: {config: [{subnet: 203.0.113.0/24}]}
|
||||
|
||||
volumes:
|
||||
postgres-data: {name: itworx-mcp-hub-postgres}
|
||||
ingestion-clamav-db: {name: itworx-mcp-hub-ingestion-clamav-db}
|
||||
|
||||
secrets:
|
||||
postgres_superuser_password: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/postgres_superuser_password"}
|
||||
hub_database_password: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/hub_database_password"}
|
||||
contextforge_database_password: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/contextforge_database_password"}
|
||||
contextforge_auth_encryption_secret: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/contextforge_auth_encryption_secret"}
|
||||
contextforge_jwt_secret: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/contextforge_jwt_secret"}
|
||||
contextforge_admin_credential: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/contextforge_admin_credential"}
|
||||
contextforge_api_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/contextforge_api_token"}
|
||||
docker_broker_service_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/docker_broker_service_token"}
|
||||
connector_identity_signing_key: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/connector_identity_signing_key"}
|
||||
demo_token_signing_key: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/demo_token_signing_key"}
|
||||
authentik_oidc_client_secret: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/authentik_oidc_client_secret"}
|
||||
hub_session_signing_key: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/hub_session_signing_key"}
|
||||
gitea_mcp_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/gitea_mcp_token"}
|
||||
gitea_mcp_write_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/gitea_mcp_write_token"}
|
||||
mobilityops_service_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/mobilityops_service_token"}
|
||||
projectbrain_hub_service_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/projectbrain_hub_service_token"}
|
||||
appops_hub_service_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/appops_hub_service_token"}
|
||||
stitch_api_key: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/stitch_api_key"}
|
||||
knowledge_hub_ingestion_token: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/knowledge_hub_ingestion_token"}
|
||||
knowledge_hub_ingestion_identity_key: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/knowledge_hub_ingestion_identity_key"}
|
||||
redis_password: {file: "${SECRETS_DIR:?Set a secret directory outside Git}/redis_password"}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Hub database migrations
|
||||
|
||||
The Alembic environment in this directory owns only the ITWorx MCP Hub control
|
||||
database. ContextForge is a separate database/user and is intentionally absent
|
||||
from the metadata import graph.
|
||||
|
||||
Development example:
|
||||
|
||||
```powershell
|
||||
$env:HUB_DATABASE_URL = "sqlite:///./itworx-hub-dev.db"
|
||||
alembic -c infrastructure/database/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
Production migrations are forward-only. A destructive rollback is a restore
|
||||
operation after backup and health verification, not an ad-hoc downgrade.
|
||||
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = infrastructure/database/alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url = sqlite:///./itworx-hub-dev.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Alembic environment for the Hub database, never the ContextForge database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from services.control_api.persistence.models import Base
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
database_url = os.environ.get("HUB_DATABASE_URL") or os.environ.get("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Create the Hub control-plane schema.
|
||||
|
||||
The metadata is deliberately Hub-only. ContextForge has its own database and
|
||||
its tables must never be imported into this migration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
from services.control_api.persistence.models import Base
|
||||
|
||||
revision = "0001_core_schema"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION hub_prevent_audit_mutation()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_events are append-only';
|
||||
END;
|
||||
$$;
|
||||
DROP TRIGGER IF EXISTS trg_audit_events_append_only ON audit_events;
|
||||
CREATE TRIGGER trg_audit_events_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION hub_prevent_audit_mutation();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP TRIGGER IF EXISTS trg_audit_events_append_only ON audit_events")
|
||||
op.execute("DROP FUNCTION IF EXISTS hub_prevent_audit_mutation()")
|
||||
Base.metadata.drop_all(bind=bind, checkfirst=True)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add PostgreSQL lexical search indexes for project knowledge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0002_knowledge_search"
|
||||
down_revision = "0001_core_schema"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_knowledge_chunks_text_fts "
|
||||
"ON knowledge_chunks USING gin (to_tsvector('simple', text))"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_knowledge_documents_title_trgm "
|
||||
"ON knowledge_documents USING gin (title gin_trgm_ops)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_knowledge_documents_external_ref_trgm "
|
||||
"ON knowledge_documents USING gin (external_ref gin_trgm_ops)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name != "postgresql":
|
||||
return
|
||||
op.execute("DROP INDEX IF EXISTS ix_knowledge_chunks_text_fts")
|
||||
op.execute("DROP INDEX IF EXISTS ix_knowledge_documents_title_trgm")
|
||||
op.execute("DROP INDEX IF EXISTS ix_knowledge_documents_external_ref_trgm")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Persist explainable policy traces for decisions and invocation audit events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "0003_policy_trace"
|
||||
down_revision = "0002_knowledge_search"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = inspect(op.get_bind())
|
||||
if "decision_trace" not in {
|
||||
column["name"] for column in inspector.get_columns("policy_decisions")
|
||||
}:
|
||||
op.add_column(
|
||||
"policy_decisions",
|
||||
sa.Column("decision_trace", sa.JSON(), nullable=False, server_default="[]"),
|
||||
)
|
||||
if "policy_trace" not in {column["name"] for column in inspector.get_columns("audit_events")}:
|
||||
op.add_column(
|
||||
"audit_events",
|
||||
sa.Column("policy_trace", sa.JSON(), nullable=False, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("audit_events", "policy_trace")
|
||||
op.drop_column("policy_decisions", "decision_trace")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Persist the bounded retention deadline emitted by the governance audit lifecycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "0004_audit_retention"
|
||||
down_revision = "0003_policy_trace"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
columns = {column["name"] for column in inspect(op.get_bind()).get_columns("audit_events")}
|
||||
if "retention_until" not in columns:
|
||||
op.add_column(
|
||||
"audit_events",
|
||||
sa.Column("retention_until", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("audit_events", "retention_until")
|
||||
@@ -0,0 +1,7 @@
|
||||
# Immutable source/runtime contract for the schema-compatible ContextForge build.
|
||||
# CONTEXTFORGE_LOCAL_IMAGE is a verified local staging tag only; production uses
|
||||
# CONTEXTFORGE_IMAGE as an immutable registry digest supplied by promotion.
|
||||
CONTEXTFORGE_UPSTREAM_COMMIT=1e58a0434a78bbdbe1e975abeb0bed1043ff14c1
|
||||
CONTEXTFORGE_ALEMBIC_HEAD=12d4a0c7789c
|
||||
CONTEXTFORGE_MCP_SDK=1.30.0
|
||||
CONTEXTFORGE_LOCAL_IMAGE=itworx-mcp-hub/contextforge-schema:12d4a0c7789c
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
fail_secret() {
|
||||
echo "ContextForge secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
|
||||
fail_session_topology() {
|
||||
echo "ContextForge multi-worker session topology is unsafe: Redis-backed stateful session affinity is required" >&2
|
||||
exit 78
|
||||
}
|
||||
|
||||
fail_runtime_contract() {
|
||||
echo "ContextForge strict runtime contract verification failed" >&2
|
||||
exit 78
|
||||
}
|
||||
|
||||
read_secret() {
|
||||
[ -f "$1" ] && [ -r "$1" ] && [ -s "$1" ] || fail_secret
|
||||
mode="$(stat -c '%a' "$1" 2>/dev/null || true)"
|
||||
case "$mode" in 400|600) ;; *) fail_secret ;; esac
|
||||
[ "$(stat -c '%u' "$1" 2>/dev/null || true)" = "${SECRET_EXPECTED_UID:?}" ] || fail_secret
|
||||
[ "$(stat -c '%g' "$1" 2>/dev/null || true)" = "${SECRET_EXPECTED_GID:?}" ] || fail_secret
|
||||
value="$(tr -d '\r\n' < "$1")"
|
||||
[ -n "$value" ] || fail_secret
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
cf_password="$(read_secret /run/secrets/contextforge_database_password)"
|
||||
export DATABASE_URL="postgresql+psycopg://contextforge:${cf_password}@postgres:5432/contextforge"
|
||||
export JWT_SECRET_KEY="$(read_secret /run/secrets/contextforge_jwt_secret)"
|
||||
export AUTH_ENCRYPTION_SECRET="$(read_secret /run/secrets/contextforge_auth_encryption_secret)"
|
||||
export BASIC_AUTH_USER="itworx-contextforge-admin"
|
||||
export BASIC_AUTH_PASSWORD="$(read_secret /run/secrets/contextforge_admin_credential)"
|
||||
export PLATFORM_ADMIN_EMAIL="${PLATFORM_ADMIN_EMAIL:-contextforge-admin@example.com}"
|
||||
export PLATFORM_ADMIN_PASSWORD="$BASIC_AUTH_PASSWORD"
|
||||
|
||||
# Base/dev defaults retain the historical safe multi-worker topology. Production
|
||||
# overrides these values to the separately tested stateless single-worker
|
||||
# contract. Multi-worker mode is never allowed to weaken its affinity controls.
|
||||
export CACHE_TYPE="${CACHE_TYPE:-redis}"
|
||||
export USE_STATEFUL_SESSIONS="${USE_STATEFUL_SESSIONS:-true}"
|
||||
export MCPGATEWAY_SESSION_AFFINITY_ENABLED="${MCPGATEWAY_SESSION_AFFINITY_ENABLED:-true}"
|
||||
export MCPGATEWAY_SESSION_AFFINITY_TTL="${MCPGATEWAY_SESSION_AFFINITY_TTL:-3600}"
|
||||
|
||||
case "${GUNICORN_WORKERS:-auto}" in
|
||||
1) ;;
|
||||
*)
|
||||
[ "$CACHE_TYPE" = "redis" ] || fail_session_topology
|
||||
[ "$USE_STATEFUL_SESSIONS" = "true" ] || fail_session_topology
|
||||
[ "$MCPGATEWAY_SESSION_AFFINITY_ENABLED" = "true" ] || fail_session_topology
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${REDIS_AUTH_ENABLED:-false}" = "true" ]; then
|
||||
redis_password="$(read_secret /run/secrets/redis_password)"
|
||||
export REDIS_URL="redis://:${redis_password}@redis:6379/0"
|
||||
unset redis_password
|
||||
else
|
||||
export REDIS_URL="${REDIS_URL:-redis://redis:6379/0}"
|
||||
fi
|
||||
|
||||
# Production strict mode is a hard startup gate. The verifier is read-only: it
|
||||
# proves the embedded compatibility tuple, installed MCP SDK/API, image Alembic
|
||||
# head, database revision and transport settings. It never installs packages or
|
||||
# runs migration mutations. Any failure stops before ContextForge/Gunicorn.
|
||||
if [ "${ITWORX_CONTEXTFORGE_CONTRACT_MODE:-relaxed}" = "strict" ]; then
|
||||
[ -r /opt/itworx/contextforge-runtime-contract.py ] || fail_runtime_contract
|
||||
/app/.venv/bin/python /opt/itworx/contextforge-runtime-contract.py --startup || fail_runtime_contract
|
||||
fi
|
||||
|
||||
exec /app/docker-entrypoint.sh
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema": "itworx.contextforge-runtime-contract/v1",
|
||||
"sourceCommit": "1e58a0434a78bbdbe1e975abeb0bed1043ff14c1",
|
||||
"alembicHead": "12d4a0c7789c",
|
||||
"mcpSdk": "1.30.0",
|
||||
"requiredSymbol": "McpError",
|
||||
"productionEnvironment": {
|
||||
"GUNICORN_WORKERS": "1",
|
||||
"USE_STATEFUL_SESSIONS": "false",
|
||||
"MCPGATEWAY_SESSION_AFFINITY_ENABLED": "false",
|
||||
"JSON_RESPONSE_ENABLED": "true",
|
||||
"EMAIL_AUTH_ENABLED": "false",
|
||||
"MCP_SESSION_POOL_ENABLED": "false",
|
||||
"MCPGATEWAY_SKIP_MIGRATIONS": "true",
|
||||
"ITWORX_CONTEXTFORGE_CONTRACT_MODE": "strict"
|
||||
},
|
||||
"migrationPolicy": {
|
||||
"startupMigrations": "forbidden",
|
||||
"skipEnv": "MCPGATEWAY_SKIP_MIGRATIONS",
|
||||
"skipValue": "true"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.13.14-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91
|
||||
|
||||
COPY infrastructure/images/install-base-security-updates.sh /tmp/install-base-security-updates.sh
|
||||
RUN sh /tmp/install-base-security-updates.sh \
|
||||
&& rm /tmp/install-base-security-updates.sh
|
||||
|
||||
ARG GIT_REVISION
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG BUILD_DATE
|
||||
|
||||
RUN groupadd --gid 10002 broker \
|
||||
&& useradd --uid 10002 --gid 10002 --create-home --shell /usr/sbin/nologin broker
|
||||
|
||||
COPY services/docker_read_broker /opt/broker/docker_read_broker
|
||||
COPY packages/__init__.py /opt/broker/packages/__init__.py
|
||||
COPY packages/hub_runtime /opt/broker/packages/hub_runtime
|
||||
RUN chown -R 10002:10002 /opt/broker
|
||||
|
||||
LABEL org.opencontainers.image.title="itworx-mcp-hub-docker-read-broker" \
|
||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.vendor="ITWorx"
|
||||
|
||||
WORKDIR /opt/broker
|
||||
USER 10002:10002
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["python", "-m", "docker_read_broker.server"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class BrokerHealthHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ITWorxDockerReadBroker/0.1"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path != "/healthz":
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
body = json.dumps({"status": "ok", "capability": "health-only-bootstrap"}).encode()
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", 8080), BrokerHealthHandler).serve_forever() # noqa: S104
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Exact Debian security versions from the staging image scan. Keep these pins
|
||||
# explicit: a release build must fail rather than silently consume newer OS
|
||||
# packages that were not scanned.
|
||||
gzip_version='1.13-1+deb13u1'
|
||||
pcre2_version='10.46-1~deb13u2'
|
||||
sqlite3_version='3.46.1-7+deb13u2'
|
||||
openssl_version='3.5.7-1~deb13u2'
|
||||
perl_base_version='5.40.1-6+deb13u1'
|
||||
util_linux_version='2.41.5-0+deb13u1'
|
||||
bsdutils_version='1:2.41.5-0+deb13u1'
|
||||
login_version='1:4.16.0-2+really2.41.5-0+deb13u1'
|
||||
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||
"bsdutils=${bsdutils_version}" \
|
||||
"gzip=${gzip_version}" \
|
||||
"libblkid1=${util_linux_version}" \
|
||||
"liblastlog2-2=${util_linux_version}" \
|
||||
"libmount1=${util_linux_version}" \
|
||||
"libpcre2-8-0=${pcre2_version}" \
|
||||
"libsqlite3-0=${sqlite3_version}" \
|
||||
"libssl3t64=${openssl_version}" \
|
||||
"libsmartcols1=${util_linux_version}" \
|
||||
"libuuid1=${util_linux_version}" \
|
||||
"login=${login_version}" \
|
||||
"mount=${util_linux_version}" \
|
||||
"openssl=${openssl_version}" \
|
||||
"openssl-provider-legacy=${openssl_version}" \
|
||||
"perl-base=${perl_base_version}" \
|
||||
"util-linux=${util_linux_version}"
|
||||
|
||||
assert_package_version() {
|
||||
actual="$(dpkg-query -W -f='${Version}' "$1")"
|
||||
if [ "${actual}" != "$2" ]; then
|
||||
printf 'Expected %s=%s but found %s\n' "$1" "$2" "${actual}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_package_version "bsdutils" "${bsdutils_version}"
|
||||
assert_package_version "login" "${login_version}"
|
||||
for package in libblkid1 liblastlog2-2 libmount1 libsmartcols1 libuuid1 mount util-linux; do
|
||||
assert_package_version "${package}" "${util_linux_version}"
|
||||
done
|
||||
assert_package_version "gzip" "${gzip_version}"
|
||||
assert_package_version "libpcre2-8-0" "${pcre2_version}"
|
||||
assert_package_version "libsqlite3-0" "${sqlite3_version}"
|
||||
assert_package_version "libssl3t64" "${openssl_version}"
|
||||
assert_package_version "openssl" "${openssl_version}"
|
||||
assert_package_version "openssl-provider-legacy" "${openssl_version}"
|
||||
assert_package_version "perl-base" "${perl_base_version}"
|
||||
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.13.14-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91
|
||||
|
||||
COPY infrastructure/images/install-base-security-updates.sh /tmp/install-base-security-updates.sh
|
||||
RUN sh /tmp/install-base-security-updates.sh \
|
||||
&& rm /tmp/install-base-security-updates.sh
|
||||
|
||||
ARG GIT_REVISION
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG BUILD_DATE
|
||||
|
||||
RUN groupadd --gid 10001 hub \
|
||||
&& useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin hub
|
||||
COPY infrastructure/images/observability/collector.py /opt/hub/collector.py
|
||||
RUN chown -R 10001:10001 /opt/hub
|
||||
|
||||
LABEL org.opencontainers.image.title="itworx-mcp-hub-observability-collector" \
|
||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.vendor="ITWorx"
|
||||
|
||||
USER 10001:10001
|
||||
EXPOSE 4318
|
||||
ENTRYPOINT ["python", "/opt/hub/collector.py"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Minimal internal OTLP/HTTP collector for the optional observer profile.
|
||||
|
||||
It accepts bounded trace batches, keeps only counters in memory, and exposes
|
||||
Prometheus text metrics. It never logs request bodies or trace attributes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Lock
|
||||
|
||||
MAX_BODY = 1024 * 1024
|
||||
_lock = Lock()
|
||||
_spans = 0
|
||||
_batches = 0
|
||||
|
||||
|
||||
class CollectorHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ITWorxOTLPCollector/1.0"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path == "/healthz":
|
||||
self._write(
|
||||
HTTPStatus.OK,
|
||||
b'{"status":"ok","component":"observability-collector"}',
|
||||
"application/json",
|
||||
)
|
||||
return
|
||||
if self.path == "/metrics":
|
||||
with _lock:
|
||||
body = (
|
||||
f"itworx_otel_batches_total {_batches}\\nitworx_otel_spans_total {_spans}\\n"
|
||||
).encode()
|
||||
self._write(HTTPStatus.OK, body, "text/plain; version=0.0.4")
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
global _batches, _spans
|
||||
if self.path != "/v1/traces":
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length <= 0 or length > MAX_BODY:
|
||||
self.send_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
return
|
||||
try:
|
||||
document = json.loads(self.rfile.read(length))
|
||||
batches = document.get("resourceSpans", [])
|
||||
spans = sum(
|
||||
len(scope.get("spans", []))
|
||||
for batch in batches
|
||||
for scope in batch.get("scopeSpans", [])
|
||||
)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
with _lock:
|
||||
_batches += 1
|
||||
_spans += spans
|
||||
self._write(HTTPStatus.ACCEPTED, b"", "text/plain")
|
||||
|
||||
def _write(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", 4318), CollectorHandler).serve_forever() # noqa: S104
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
fail_secret() {
|
||||
echo "PostgreSQL secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
|
||||
source_root=/run/secrets
|
||||
runtime_root=/run/itworx-secrets
|
||||
runtime_uid="$(id -u postgres)"
|
||||
runtime_gid="$(id -g postgres)"
|
||||
|
||||
install -d -o root -g "$runtime_gid" -m 0750 "$runtime_root"
|
||||
|
||||
for name in postgres_superuser_password hub_database_password contextforge_database_password; do
|
||||
source="$source_root/$name"
|
||||
target="$runtime_root/$name"
|
||||
[ -f "$source" ] && [ ! -L "$source" ] && [ -r "$source" ] && [ -s "$source" ] || fail_secret
|
||||
mode="$(stat -c '%a' "$source" 2>/dev/null || true)"
|
||||
case "$mode" in 400|600) ;; *) fail_secret ;; esac
|
||||
[ "$(stat -c '%u' "$source" 2>/dev/null || true)" = "${SECRET_EXPECTED_UID:?}" ] || fail_secret
|
||||
[ "$(stat -c '%g' "$source" 2>/dev/null || true)" = "${SECRET_EXPECTED_GID:?}" ] || fail_secret
|
||||
[ "$(wc -c < "$source")" -le 8192 ] || fail_secret
|
||||
value="$(tr -d '\r\n' < "$source")"
|
||||
[ -n "$value" ] || fail_secret
|
||||
unset value
|
||||
install -o "$runtime_uid" -g "$runtime_gid" -m 0400 "$source" "$target"
|
||||
done
|
||||
|
||||
export POSTGRES_PASSWORD_FILE="$runtime_root/postgres_superuser_password"
|
||||
export POSTGRES_SECRET_ROOT="$runtime_root"
|
||||
export POSTGRES_SECRET_EXPECTED_UID="$runtime_uid"
|
||||
export POSTGRES_SECRET_EXPECTED_GID="$runtime_gid"
|
||||
|
||||
exec /usr/local/bin/docker-entrypoint.sh "$@"
|
||||
@@ -0,0 +1,58 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:24.18.1-bookworm-slim@sha256:235600a8101ab264e117b1768e925532262668dc9b581ef1dd7d96ced463b8e7 AS web-build
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
RUN npm install --global pnpm@10.33.0 \
|
||||
&& pnpm install --frozen-lockfile --filter @itworx-mcp-hub/web...
|
||||
COPY apps/web apps/web
|
||||
RUN pnpm --filter @itworx-mcp-hub/web build
|
||||
|
||||
FROM python:3.13.14-slim@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91
|
||||
|
||||
COPY infrastructure/images/install-base-security-updates.sh /tmp/install-base-security-updates.sh
|
||||
RUN sh /tmp/install-base-security-updates.sh \
|
||||
&& rm /tmp/install-base-security-updates.sh
|
||||
|
||||
ARG SERVICE_NAME=itworx-mcp-hub-service
|
||||
ARG GIT_REVISION
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG BUILD_DATE
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH=/opt/venv/bin:$PATH \
|
||||
SERVICE_NAME=${SERVICE_NAME}
|
||||
|
||||
RUN groupadd --gid 10001 hub \
|
||||
&& useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin hub
|
||||
|
||||
COPY pyproject.toml uv.lock /opt/hub/
|
||||
RUN python -m pip install --no-cache-dir uv==0.10.0 \
|
||||
&& cd /opt/hub \
|
||||
&& UV_PROJECT_ENVIRONMENT=/opt/venv uv sync --locked --no-dev --no-install-project \
|
||||
&& /usr/local/bin/python -m pip uninstall --yes uv
|
||||
|
||||
COPY packages /opt/hub/packages
|
||||
COPY services /opt/hub/services
|
||||
COPY scripts /opt/hub/scripts
|
||||
COPY connectors /opt/hub/connectors
|
||||
COPY catalog /opt/hub/catalog
|
||||
COPY contracts /opt/hub/contracts
|
||||
COPY plugins/itworx-gitea /opt/hub/plugins/itworx-gitea
|
||||
COPY infrastructure/database /opt/hub/infrastructure/database
|
||||
COPY --from=web-build /workspace/apps/web/dist /opt/hub/web-dist
|
||||
RUN chown -R 10001:10001 /opt/hub
|
||||
|
||||
LABEL org.opencontainers.image.title="itworx-mcp-hub-${SERVICE_NAME}" \
|
||||
org.opencontainers.image.revision="${GIT_REVISION}" \
|
||||
org.opencontainers.image.source="https://gitea.example.com/jens/itworx-mcp-hub" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.vendor="ITWorx"
|
||||
|
||||
WORKDIR /opt/hub
|
||||
USER 10001:10001
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["python", "-m", "services.runtime"]
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from packages.hub_observability import build_telemetry, trace_from_headers, valid_trace_id
|
||||
from packages.hub_runtime.secret_files import SecretFileError, validate_required_secret_paths
|
||||
from services.observability.health import DependencyHealth, aggregate_health
|
||||
|
||||
|
||||
def _validate_startup() -> None:
|
||||
try:
|
||||
validate_required_secret_paths(os.environ.get("REQUIRED_SECRET_FILES", ""))
|
||||
except (SecretFileError, ValueError):
|
||||
sys.stderr.write("required runtime secret validation failed\n")
|
||||
raise SystemExit(78) from None
|
||||
|
||||
|
||||
_validate_startup()
|
||||
|
||||
_telemetry = build_telemetry(os.environ.get("SERVICE_NAME", "unknown"))
|
||||
|
||||
|
||||
def _trace_id(handler: BaseHTTPRequestHandler) -> str:
|
||||
candidate = handler.headers.get("X-Trace-Id", "")
|
||||
if valid_trace_id(candidate):
|
||||
return candidate.lower()
|
||||
return trace_from_headers({"traceparent": handler.headers.get("traceparent", "")}).trace_id
|
||||
|
||||
|
||||
def _dependencies() -> list[DependencyHealth]:
|
||||
raw = os.environ.get("DEPENDENCY_HEALTH_JSON", "")
|
||||
if not raw:
|
||||
return [DependencyHealth(os.environ.get("SERVICE_NAME", "unknown"), "healthy")]
|
||||
try:
|
||||
document = json.loads(raw)
|
||||
except ValueError:
|
||||
return [
|
||||
DependencyHealth(
|
||||
"configuration", "unhealthy", detail="invalid dependency health configuration"
|
||||
)
|
||||
]
|
||||
if not isinstance(document, dict):
|
||||
return [
|
||||
DependencyHealth(
|
||||
"configuration", "unhealthy", detail="dependency health must be an object"
|
||||
)
|
||||
]
|
||||
values = []
|
||||
for name, value in document.items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
values.append(
|
||||
DependencyHealth(
|
||||
str(name)[:64],
|
||||
str(value.get("status", "unknown")),
|
||||
bool(value.get("required", True)),
|
||||
bool(value.get("authoritative", True)),
|
||||
str(value.get("detail", "")),
|
||||
)
|
||||
)
|
||||
return values or [
|
||||
DependencyHealth("configuration", "unhealthy", detail="no dependencies configured")
|
||||
]
|
||||
|
||||
|
||||
class HealthHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ITWorxHealth/0.2"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
trace_id = _trace_id(self)
|
||||
if self.path == "/metrics":
|
||||
body = _telemetry.metrics.render_prometheus().encode("utf-8")
|
||||
self._write(HTTPStatus.OK, body, "text/plain; version=0.0.4", trace_id)
|
||||
return
|
||||
if self.path == "/health/aggregate":
|
||||
aggregate = aggregate_health(_dependencies())
|
||||
status = (
|
||||
HTTPStatus.OK if aggregate.status != "unhealthy" else HTTPStatus.SERVICE_UNAVAILABLE
|
||||
)
|
||||
self._write_json(
|
||||
status,
|
||||
aggregate.payload(
|
||||
service=os.environ.get("SERVICE_NAME", "unknown"), trace_id=trace_id
|
||||
),
|
||||
trace_id,
|
||||
)
|
||||
return
|
||||
if self.path not in {"/health", "/healthz", "/ready"}:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
_telemetry.metrics.inc("itworx_health_requests_total", labels={"path": self.path})
|
||||
_telemetry.logger.emit("HEALTH_REQUEST", trace_id, fields={"path": self.path})
|
||||
self._write_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"status": "ok",
|
||||
"service": os.environ.get("SERVICE_NAME", "unknown"),
|
||||
"traceId": trace_id,
|
||||
},
|
||||
trace_id,
|
||||
)
|
||||
|
||||
def _write_json(self, status: HTTPStatus, payload: dict[str, object], trace_id: str) -> None:
|
||||
self._write(
|
||||
status,
|
||||
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
||||
"application/json",
|
||||
trace_id,
|
||||
)
|
||||
|
||||
def _write(self, status: HTTPStatus, body: bytes, content_type: str, trace_id: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("X-Trace-Id", trace_id)
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", 8080), HealthHandler).serve_forever() # noqa: S104
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
echo "Redis secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
|
||||
if [ "${REDIS_AUTH_ENABLED:-false}" = "true" ]; then
|
||||
secret=/run/secrets/redis_password
|
||||
[ -f "$secret" ] && [ -r "$secret" ] || fail
|
||||
[ -s "$secret" ] || fail
|
||||
mode="$(stat -c '%a' "$secret" 2>/dev/null || true)"
|
||||
case "$mode" in 400|600) ;; *) fail ;; esac
|
||||
[ "$(stat -c '%u' "$secret" 2>/dev/null || true)" = "${SECRET_EXPECTED_UID:?}" ] || fail
|
||||
[ "$(stat -c '%g' "$secret" 2>/dev/null || true)" = "${SECRET_EXPECTED_GID:?}" ] || fail
|
||||
password="$(tr -d '\r\n' < "$secret")"
|
||||
[ -n "$password" ] || fail
|
||||
umask 077
|
||||
{
|
||||
printf 'save ""\nappendonly no\nmaxmemory-policy allkeys-lru\nrequirepass '
|
||||
printf '%s' "$password" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
printf '\n'
|
||||
} > /run/itworx/redis.conf
|
||||
unset password
|
||||
exec redis-server /run/itworx/redis.conf
|
||||
fi
|
||||
|
||||
exec redis-server --save "" --appendonly no --maxmemory-policy allkeys-lru
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${REDIS_AUTH_ENABLED:-false}" = "true" ]; then
|
||||
secret=/run/secrets/redis_password
|
||||
[ -f "$secret" ] && [ -r "$secret" ] && [ -s "$secret" ] || exit 1
|
||||
REDISCLI_AUTH="$(tr -d '\r\n' < "$secret")"
|
||||
export REDISCLI_AUTH
|
||||
fi
|
||||
|
||||
response="$(redis-cli ping 2>/dev/null)"
|
||||
[ "$response" = "PONG" ]
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
read_secret() {
|
||||
local name="$1"
|
||||
local path="${POSTGRES_SECRET_ROOT:-/run/secrets}/${name}"
|
||||
[[ -f "$path" && -r "$path" && -s "$path" ]] || {
|
||||
echo "PostgreSQL secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
local mode
|
||||
mode="$(stat -c '%a' "$path" 2>/dev/null || true)"
|
||||
[[ "$mode" == "400" || "$mode" == "600" ]] || {
|
||||
echo "PostgreSQL secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
[[ "$(stat -c '%u' "$path" 2>/dev/null || true)" == "${POSTGRES_SECRET_EXPECTED_UID:-${SECRET_EXPECTED_UID:?}}" ]] || {
|
||||
echo "PostgreSQL secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
[[ "$(stat -c '%g' "$path" 2>/dev/null || true)" == "${POSTGRES_SECRET_EXPECTED_GID:-${SECRET_EXPECTED_GID:?}}" ]] || {
|
||||
echo "PostgreSQL secret-file validation failed" >&2
|
||||
exit 78
|
||||
}
|
||||
local value
|
||||
value="$(tr -d '\r\n' < "$path")"
|
||||
[[ -n "$value" ]] || { echo "PostgreSQL secret-file validation failed" >&2; exit 78; }
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
HUB_PASSWORD="$(read_secret hub_database_password)"
|
||||
CF_PASSWORD="$(read_secret contextforge_database_password)"
|
||||
|
||||
psql --username "$POSTGRES_USER" --dbname postgres --set=ON_ERROR_STOP=1 \
|
||||
--set=hub_password="$HUB_PASSWORD" --set=cf_password="$CF_PASSWORD" <<'SQL'
|
||||
SELECT format('CREATE ROLE itworx_mcp_hub LOGIN PASSWORD %L', :'hub_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'itworx_mcp_hub')\gexec
|
||||
SELECT format('CREATE ROLE contextforge LOGIN PASSWORD %L', :'cf_password')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'contextforge')\gexec
|
||||
SELECT 'CREATE DATABASE itworx_mcp_hub OWNER itworx_mcp_hub'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'itworx_mcp_hub')\gexec
|
||||
SELECT 'CREATE DATABASE contextforge OWNER contextforge'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'contextforge')\gexec
|
||||
REVOKE ALL ON DATABASE itworx_mcp_hub FROM PUBLIC;
|
||||
REVOKE ALL ON DATABASE contextforge FROM PUBLIC;
|
||||
SQL
|
||||
@@ -0,0 +1,26 @@
|
||||
# Secret provisioning
|
||||
|
||||
Create one file per secret under the deployed `${SECRETS_DIR}` with mode `0600` and an owner readable only by the intended deployment operator. Do not commit the directory or copy its contents into evidence.
|
||||
|
||||
Required core secrets:
|
||||
|
||||
- `postgres_superuser_password`
|
||||
- `hub_database_password`
|
||||
- `contextforge_database_password`
|
||||
- `contextforge_auth_encryption_secret`
|
||||
- `contextforge_jwt_secret`
|
||||
- `contextforge_admin_credential`
|
||||
- `contextforge_api_token` (generated by the controlled ContextForge bootstrap)
|
||||
- `stitch_api_key` (Google Stitch API key; connector-only, never exposed to MCP clients)
|
||||
- `authentik_oidc_client_secret`
|
||||
- `hub_session_signing_key`
|
||||
|
||||
`contextforge_api_token` is absent during the initial predeploy check. Generate
|
||||
it with `scripts/contextforge_bootstrap.py` after ContextForge is healthy and
|
||||
scope it to the bootstrap's explicit gateway, tool, prompt and virtual-server
|
||||
permissions. Rotate an older token when the required permission set changes,
|
||||
then start the Hub services; the poststart preflight requires it.
|
||||
|
||||
Optional connector secrets are added only when that connector is enabled. Use least-privilege, read-only credentials in v1.0.
|
||||
|
||||
The application may show whether a secret reference is configured and when it was last verified. It must never show, return, audit, or log the value.
|
||||
Reference in new issue
Block a user