From 3443b7af750ca41ae730f0477fff067e639f4880 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Tue, 29 Sep 2026 23:53:53 +0300 Subject: [PATCH] feat: add Google Calendar local stack --- .env.example | 13 ++++ .github/workflows/app-smoke.yml | 9 +++ README.md | 17 +++-- compose/apps.local.yaml | 12 ++++ compose/compose.yaml | 76 +++++++++++++++++++++- compose/keycloak/portable-agent-realm.json | 52 ++++++++++++++- compose/postgres/init/01-users.sh | 11 ++++ config/versions.env | 3 +- docs/development.md | 9 ++- docs/index.md | 5 +- docs/runbook.md | 14 +++- scripts/check-compose.ps1 | 30 +++++++-- scripts/check-keycloak.ps1 | 8 ++- scripts/local-settings.ps1 | 14 ++++ scripts/service-local.ps1 | 3 +- scripts/start-local.ps1 | 3 +- 16 files changed, 254 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index ed7c573..9acc3f9 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,18 @@ CONVERSATION_DB_USER=conversations CONVERSATION_DB_PASSWORD=local_conversations_change_me CONVERSATION_SERVICE_PORT=18085 +CONNECTION_DB_USER=connections +CONNECTION_DB_PASSWORD=local_connections_change_me +CONNECTION_SERVICE_PORT=18088 +CONNECTION_TOKEN_KEY_BASE64=derive-from-local-action-secret + +# Реальный Google OAuth включается только в локальном .env. Секреты не коммитим. +GOOGLE_OAUTH_ENABLED=false +# GOOGLE_OAUTH_CLIENT_ID= +# GOOGLE_OAUTH_CLIENT_SECRET= +# GOOGLE_OAUTH_REDIRECT_URI=http://localhost:18088/api/v1/connections/callback +GOOGLE_CALENDAR_MCP_PORT=18089 + TELEGRAM_DB_USER=telegram TELEGRAM_DB_PASSWORD=local_telegram_db_change_me TELEGRAM_ADAPTER_CLIENT_SECRET=local_telegram_client_change_me @@ -47,6 +59,7 @@ CHANNEL_GATEWAY_CONTEXT=../../portable-agent-channel-gateway ACTION_SERVICE_CONTEXT=../../portable-agent-action-service MCP_GATEWAY_CONTEXT=../../portable-agent-mcp-gateway CALENDAR_MCP_CONTEXT=../../portable-agent-calendar-mcp +CONNECTION_SERVICE_CONTEXT=../../portable-agent-connection-service CONVERSATION_SERVICE_CONTEXT=../../portable-agent-conversation-service TELEGRAM_ADAPTER_CONTEXT=../../portable-agent-telegram-adapter TEST_LAB_PATH=../portable-agent-test-lab diff --git a/.github/workflows/app-smoke.yml b/.github/workflows/app-smoke.yml index 810e747..9d3e2f8 100644 --- a/.github/workflows/app-smoke.yml +++ b/.github/workflows/app-smoke.yml @@ -33,6 +33,7 @@ jobs: CONVERSATION_SERVICE_CONTEXT: ${{ github.workspace }}/repos/conversation-service MCP_GATEWAY_CONTEXT: ${{ github.workspace }}/repos/mcp-gateway CALENDAR_MCP_CONTEXT: ${{ github.workspace }}/repos/calendar-mcp + CONNECTION_SERVICE_CONTEXT: ${{ github.workspace }}/repos/connection-service TELEGRAM_ADAPTER_CONTEXT: ${{ github.workspace }}/repos/telegram-adapter TEST_LAB_PATH: ${{ github.workspace }}/repos/test-lab steps: @@ -50,6 +51,7 @@ jobs: "conversation=$($versions.CONVERSATION_SERVICE_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "gateway=$($versions.MCP_GATEWAY_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "calendar=$($versions.CALENDAR_MCP_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT + "connection=$($versions.CONNECTION_SERVICE_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "telegram=$($versions.TELEGRAM_ADAPTER_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "test_lab=$($versions.TEST_LAB_REF)" >> $env:GITHUB_OUTPUT @@ -95,6 +97,13 @@ jobs: ref: ${{ steps.versions.outputs.calendar }} path: repos/calendar-mcp + - name: Получить Connection Service + uses: actions/checkout@v7 + with: + repository: portable-agent/connection-service + ref: ${{ steps.versions.outputs.connection }} + path: repos/connection-service + - name: Получить Telegram Adapter uses: actions/checkout@v7 with: diff --git a/README.md b/README.md index 3524828..25a216c 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,9 @@ Telegram Update и Bot API response. `TEST_LAB_PATH` по умолчанию указывает на соседний `../portable-agent-test-lab`; путь можно переопределить в `.env`. Channel Gateway доступен на `http://localhost:18084`, Agent Runtime — на `http://localhost:18080`, Action API — на -`http://localhost:18081`, MCP Gateway — на `http://localhost:18083`, а Calendar MCP — на -`http://localhost:18082`. Conversation Service доступен на `http://localhost:18085`. Новый маршрут +`http://localhost:18081`, MCP Gateway — на `http://localhost:18083`, fake Calendar MCP — на +`http://localhost:18082`, а Google Calendar MCP — на `http://localhost:18089`. Conversation Service +доступен на `http://localhost:18085`, Connection Service — на `http://localhost:18088`. Новый маршрут `/api/v1/conversations/messages` в Channel Gateway передаёт сообщения в него; старый маршрут `/api/v1/messages` временно сохранён для обратной совместимости. Решение confirmation-виджета отправляется в `/api/v1/actions/{actionId}/decisions` того же Gateway и только затем попадает в Action @@ -148,18 +149,22 @@ manager, а не из Git. Локальный Keycloak импортирует realm `portable-agent`. Публичный тестовый client `portable-agent-local` и пользователь `local-user` с паролем `local-user-change-me` существуют только в Compose fixture. Отдельный confidential client `action-service` выдаёт worker служебный токен с -`tenant_id`, audience `mcp-gateway` и `calendar-mcp`, scopes `mcp:call` и `calendar:write`. Значения +`tenant_id`, audience `mcp-gateway`, `calendar-mcp` и `connection-service`, scopes `mcp:call`, +`calendar:write` и `connection:token`. Значения локального секрета и tenant приходят из `.env`, а не зашиты в image. Отдельный confidential client `telegram-adapter` включает OAuth Device Authorization Grant и выдаёт пользовательский токен с теми же `tenant_id` и audience backend-пути. PostgreSQL создаёт отдельные БД `actions`, `conversations` и -`telegram_adapter`. +`telegram_adapter` и `connections`. Проверочный API Calendar MCP включён только в локальном профиле `apps`, привязан к loopback-порту и защищён `CALENDAR_TEST_API_KEY`. Хранилище fake-calendar пока находится в памяти: перезапуск контейнера -очищает созданные тестовые встречи. +очищает созданные тестовые встречи. Второй экземпляр того же Calendar MCP использует стратегию +`google-calendar` и получает короткоживущий Google token через Connection Service. OAuth credentials и +зашифрованные пользовательские refresh tokens не попадают в Calendar MCP. После запуска скрипт получает настоящий JWT и сверяет пользователя, `tenant_id` и audience -`channel-gateway`, `conversation-service`, `agent-runtime`, `action-service` и `calendar-mcp` с realm fixture. +`channel-gateway`, `conversation-service`, `agent-runtime`, `action-service`, `calendar-mcp` и +`connection-service` с realm fixture. Один пользовательский токен проходит независимую проверку во всех пользовательских API. Если Keycloak volume создан старой версией fixture, запуск остановится с командой для явного пересоздания локальных данных. diff --git a/compose/apps.local.yaml b/compose/apps.local.yaml index 2fd9bb5..f5cf891 100644 --- a/compose/apps.local.yaml +++ b/compose/apps.local.yaml @@ -35,6 +35,18 @@ services: context: ${CALENDAR_MCP_CONTEXT:-../../portable-agent-calendar-mcp} pull_policy: build + google-calendar-mcp: + image: portable-agent/calendar-mcp:local + build: + context: ${CALENDAR_MCP_CONTEXT:-../../portable-agent-calendar-mcp} + pull_policy: build + + connection-service: + image: portable-agent/connection-service:local + build: + context: ${CONNECTION_SERVICE_CONTEXT:-../../portable-agent-connection-service} + pull_policy: build + telegram-adapter: image: portable-agent/telegram-adapter:local build: diff --git a/compose/compose.yaml b/compose/compose.yaml index 9878f1e..9ff96a9 100644 --- a/compose/compose.yaml +++ b/compose/compose.yaml @@ -16,6 +16,8 @@ services: ACTION_DB_PASSWORD: ${ACTION_DB_PASSWORD:?set ACTION_DB_PASSWORD} CONVERSATION_DB_USER: ${CONVERSATION_DB_USER:?set CONVERSATION_DB_USER} CONVERSATION_DB_PASSWORD: ${CONVERSATION_DB_PASSWORD:?set CONVERSATION_DB_PASSWORD} + CONNECTION_DB_USER: ${CONNECTION_DB_USER:?set CONNECTION_DB_USER} + CONNECTION_DB_PASSWORD: ${CONNECTION_DB_PASSWORD:?set CONNECTION_DB_PASSWORD} TELEGRAM_DB_USER: ${TELEGRAM_DB_USER:?set TELEGRAM_DB_USER} TELEGRAM_DB_PASSWORD: ${TELEGRAM_DB_PASSWORD:?set TELEGRAM_DB_PASSWORD} ports: @@ -112,6 +114,8 @@ services: ACTION_DB_PASSWORD: ${ACTION_DB_PASSWORD:?set ACTION_DB_PASSWORD} CONVERSATION_DB_USER: ${CONVERSATION_DB_USER:?set CONVERSATION_DB_USER} CONVERSATION_DB_PASSWORD: ${CONVERSATION_DB_PASSWORD:?set CONVERSATION_DB_PASSWORD} + CONNECTION_DB_USER: ${CONNECTION_DB_USER:?set CONNECTION_DB_USER} + CONNECTION_DB_PASSWORD: ${CONNECTION_DB_PASSWORD:?set CONNECTION_DB_PASSWORD} TELEGRAM_DB_USER: ${TELEGRAM_DB_USER:?set TELEGRAM_DB_USER} TELEGRAM_DB_PASSWORD: ${TELEGRAM_DB_PASSWORD:?set TELEGRAM_DB_PASSWORD} volumes: @@ -271,6 +275,7 @@ services: profiles: [apps] image: ${CALENDAR_MCP_IMAGE:?set CALENDAR_MCP_IMAGE} environment: + CALENDAR_PROVIDER: fake-calendar CALENDAR_TEST_API_ENABLED: "true" CALENDAR_TEST_API_KEY: ${CALENDAR_TEST_API_KEY:?set CALENDAR_TEST_API_KEY} CALENDAR_OIDC_ISSUER_URL: http://localhost:${KEYCLOAK_PORT:-8081}/realms/portable-agent @@ -292,6 +297,71 @@ services: retries: 30 networks: [platform] + connection-service: + profiles: [apps] + image: ${CONNECTION_SERVICE_IMAGE:?set CONNECTION_SERVICE_IMAGE} + environment: + DATABASE_URL: jdbc:postgresql://postgres:5432/connections + DATABASE_USER: ${CONNECTION_DB_USER:?set CONNECTION_DB_USER} + DATABASE_PASSWORD: ${CONNECTION_DB_PASSWORD:?set CONNECTION_DB_PASSWORD} + OIDC_ISSUER: http://localhost:${KEYCLOAK_PORT:-8081}/realms/portable-agent + OIDC_JWKS_URL: http://keycloak:8080/realms/portable-agent/protocol/openid-connect/certs + OIDC_AUDIENCE: connection-service + OIDC_INTERNAL_CLIENTS: action-service + CONNECTION_TOKEN_KEYS_ENABLED: "true" + CONNECTION_TOKEN_KEYS_CURRENT_VERSION: "1" + CONNECTION_TOKEN_KEYS_ITEMS_0_VERSION: "1" + CONNECTION_TOKEN_KEYS_ITEMS_0_VALUE: ${CONNECTION_TOKEN_KEY_BASE64:?set CONNECTION_TOKEN_KEY_BASE64} + GOOGLE_OAUTH_ENABLED: ${GOOGLE_OAUTH_ENABLED:-false} + GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} + GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-} + GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-} + depends_on: + postgres: + condition: service_healthy + keycloak: + condition: service_healthy + ports: + - "127.0.0.1:${CONNECTION_SERVICE_PORT:-18088}:8080" + healthcheck: + test: + [ + CMD-SHELL, + "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /actuator/health/readiness HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n\" >&3 && grep -q \"HTTP/1.1 200 \" <&3'", + ] + interval: 5s + timeout: 3s + retries: 30 + start_period: 20s + networks: [platform] + + google-calendar-mcp: + profiles: [apps] + image: ${CALENDAR_MCP_IMAGE:?set CALENDAR_MCP_IMAGE} + environment: + CALENDAR_PROVIDER: google-calendar + CALENDAR_OIDC_ISSUER_URL: http://localhost:${KEYCLOAK_PORT:-8081}/realms/portable-agent + CALENDAR_OIDC_JWKS_URL: http://keycloak:8080/realms/portable-agent/protocol/openid-connect/certs + CALENDAR_OIDC_AUDIENCE: calendar-mcp + CALENDAR_MCP_RESOURCE_URL: http://google-calendar-mcp:8080/mcp + CALENDAR_MCP_REQUIRED_SCOPES: '["calendar:write"]' + CALENDAR_MCP_ALLOWED_HOSTS: '["google-calendar-mcp:8080","localhost:*","127.0.0.1:*"]' + CALENDAR_MCP_ALLOWED_ORIGINS: '["http://google-calendar-mcp:8080","http://localhost:*","http://127.0.0.1:*"]' + CALENDAR_CONNECTION_URL: http://connection-service:8080 + depends_on: + keycloak: + condition: service_healthy + connection-service: + condition: service_healthy + ports: + - "127.0.0.1:${GOOGLE_CALENDAR_MCP_PORT:-18089}:8080" + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 5s + timeout: 3s + retries: 30 + networks: [platform] + mcp-gateway: profiles: [apps] image: ${MCP_GATEWAY_IMAGE:?set MCP_GATEWAY_IMAGE} @@ -303,12 +373,14 @@ services: OIDC_JWKS_URL: http://keycloak:8080/realms/portable-agent/protocol/openid-connect/certs OIDC_AUDIENCE: mcp-gateway OIDC_REQUIRED_SCOPE: mcp:call - MCP_CONNECTORS_JSON: '[{"name":"fake-calendar","url":"http://calendar-mcp:8080/mcp","tools":["create_event"]}]' + MCP_CONNECTORS_JSON: '[{"name":"fake-calendar","url":"http://calendar-mcp:8080/mcp","tools":["create_event"]},{"name":"google-calendar","url":"http://google-calendar-mcp:8080/mcp","tools":["create_event"]}]' depends_on: keycloak: condition: service_healthy calendar-mcp: condition: service_healthy + google-calendar-mcp: + condition: service_healthy ports: - "127.0.0.1:${MCP_GATEWAY_PORT:-18083}:8080" healthcheck: @@ -336,7 +408,7 @@ services: MCP_GATEWAY_TOKEN_URL: http://keycloak:8080/realms/portable-agent/protocol/openid-connect/token MCP_GATEWAY_CLIENT_ID: action-service MCP_GATEWAY_CLIENT_SECRET: ${ACTION_SERVICE_CLIENT_SECRET:?set ACTION_SERVICE_CLIENT_SECRET} - MCP_GATEWAY_SCOPE: mcp:call calendar:write + MCP_GATEWAY_SCOPE: mcp:call calendar:write connection:token MCP_GATEWAY_TENANT_ID: ${ACTION_SERVICE_TENANT_ID:?set ACTION_SERVICE_TENANT_ID} MCP_GATEWAY_CONNECT_TIMEOUT: 3s MCP_GATEWAY_READ_TIMEOUT: 10s diff --git a/compose/keycloak/portable-agent-realm.json b/compose/keycloak/portable-agent-realm.json index 9554351..04e3758 100644 --- a/compose/keycloak/portable-agent-realm.json +++ b/compose/keycloak/portable-agent-realm.json @@ -41,6 +41,14 @@ "include.in.token.scope": "true", "display.on.consent.screen": "false" } + }, + { + "name": "connection:token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } } ], "clients": [ @@ -65,6 +73,13 @@ "bearerOnly": true, "protocol": "openid-connect" }, + { + "clientId": "connection-service", + "name": "Connection Service Resource Server", + "enabled": true, + "bearerOnly": true, + "protocol": "openid-connect" + }, { "clientId": "mcp-gateway", "name": "MCP Gateway Resource Server", @@ -90,7 +105,7 @@ "clientAuthenticatorType": "client-secret", "secret": "${ACTION_SERVICE_CLIENT_SECRET}", "protocol": "openid-connect", - "defaultClientScopes": ["basic", "mcp:call", "calendar:write"], + "defaultClientScopes": ["basic", "mcp:call", "calendar:write", "connection:token"], "protocolMappers": [ { "name": "tenant-id", @@ -126,6 +141,17 @@ "access.token.claim": "true", "introspection.token.claim": "true" } + }, + { + "name": "connection-service-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "connection-service", + "access.token.claim": "true", + "introspection.token.claim": "true" + } } ] }, @@ -210,6 +236,18 @@ "access.token.claim": "true", "introspection.token.claim": "true" } + }, + { + "name": "connection-service-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "connection-service", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } } ] }, @@ -299,6 +337,18 @@ "access.token.claim": "true", "introspection.token.claim": "true" } + }, + { + "name": "connection-service-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "connection-service", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } } ] } diff --git a/compose/postgres/init/01-users.sh b/compose/postgres/init/01-users.sh index 066faae..4906713 100644 --- a/compose/postgres/init/01-users.sh +++ b/compose/postgres/init/01-users.sh @@ -10,6 +10,8 @@ set -eu : "${ACTION_DB_PASSWORD:?ACTION_DB_PASSWORD is required}" : "${CONVERSATION_DB_USER:?CONVERSATION_DB_USER is required}" : "${CONVERSATION_DB_PASSWORD:?CONVERSATION_DB_PASSWORD is required}" +: "${CONNECTION_DB_USER:?CONNECTION_DB_USER is required}" +: "${CONNECTION_DB_PASSWORD:?CONNECTION_DB_PASSWORD is required}" : "${TELEGRAM_DB_USER:?TELEGRAM_DB_USER is required}" : "${TELEGRAM_DB_PASSWORD:?TELEGRAM_DB_PASSWORD is required}" @@ -22,6 +24,8 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ --set=action_password="$ACTION_DB_PASSWORD" \ --set=conversation_user="$CONVERSATION_DB_USER" \ --set=conversation_password="$CONVERSATION_DB_PASSWORD" \ + --set=connection_user="$CONNECTION_DB_USER" \ + --set=connection_password="$CONNECTION_DB_PASSWORD" \ --set=telegram_user="$TELEGRAM_DB_USER" \ --set=telegram_password="$TELEGRAM_DB_PASSWORD" <<-'SQL' SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'keycloak_user', :'keycloak_password') @@ -49,6 +53,13 @@ SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'conversation_user', :'co SELECT format('CREATE DATABASE conversations OWNER %I', :'conversation_user') WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'conversations') \gexec +SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'connection_user', :'connection_password') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'connection_user') \gexec +SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'connection_user', :'connection_password') \gexec + +SELECT format('CREATE DATABASE connections OWNER %I', :'connection_user') +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'connections') \gexec + SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'telegram_user', :'telegram_password') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'telegram_user') \gexec SELECT format('ALTER ROLE %I WITH LOGIN PASSWORD %L', :'telegram_user', :'telegram_password') \gexec diff --git a/config/versions.env b/config/versions.env index 216af86..d32ee16 100644 --- a/config/versions.env +++ b/config/versions.env @@ -18,6 +18,7 @@ AGENT_RUNTIME_IMAGE=ghcr.io/portable-agent/agent-runtime:095509e8c51c6bc311b86d6 ACTION_SERVICE_IMAGE=ghcr.io/portable-agent/action-service:aa854b24555984ba92106ce3de4407f2940d7f6a CONVERSATION_SERVICE_IMAGE=ghcr.io/portable-agent/conversation-service:fb069a35c6a8406f88f665e8197ebfbc3564008a MCP_GATEWAY_IMAGE=ghcr.io/portable-agent/mcp-gateway:922390b293345f7483dcdee20d5020f417bd7f39 -CALENDAR_MCP_IMAGE=ghcr.io/portable-agent/calendar-mcp:1bcaf92919c31b9abce62cf4210467051a8cb668 +CALENDAR_MCP_IMAGE=ghcr.io/portable-agent/calendar-mcp:30b869b4f9f1c23a4be59e8cf5a3b3a3d3c89799 +CONNECTION_SERVICE_IMAGE=ghcr.io/portable-agent/connection-service:f86ff7b1b3fa4da16ff580d5e36f29eccdbf1c1a TELEGRAM_ADAPTER_IMAGE=ghcr.io/portable-agent/telegram-adapter:232119576688e36a609546267b483da19d3ccc38 TEST_LAB_REF=6969e1bbae930146cd149276167137ff74fb2e98 diff --git a/docs/development.md b/docs/development.md index 6deb183..f40a8ec 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,7 +23,14 @@ Taskfile — единая точка входа. Внутренние PowerShell `test-lab`; быстрый `task test:e2e` по-прежнему использует детерминированную demo-модель. Допустимые имена: `channel-gateway`, `conversation-service`, `agent-runtime`, `action-service`, -`mcp-gateway`, `calendar-mcp` и `telegram-adapter`. Неизвестное имя отклоняется до вызова Docker. +`mcp-gateway`, `calendar-mcp`, `google-calendar-mcp`, `connection-service` и `telegram-adapter`. +Неизвестное имя отклоняется до вызова Docker. + +Для настоящего Google Calendar добавь только в игнорируемый `.env` значения +`GOOGLE_OAUTH_ENABLED=true`, `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` и +`GOOGLE_OAUTH_REDIRECT_URI`. Redirect URI должен оканчиваться на +`/api/v1/connections/callback` и точно совпадать с URI в Google Cloud Console. Без этих значений +обычный стенд и acceptance-тест используют `fake-calendar` и не обращаются во внешний API. Например, для локальной проверки HTTP-потока диалога выполни: diff --git a/docs/index.md b/docs/index.md index f9a2f9f..529d1c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ # Deploy Репозиторий содержит инженерный полигон Portable Agent. Compose поднимает инфраструктуру и локальный -slice `Telegram Adapter → Channel Gateway → Conversation Service → Agent Runtime → Action Service → MCP Gateway → Calendar MCP`. Минимальный +slice `Telegram Adapter → Channel Gateway → Conversation Service → Agent Runtime → Action Service → MCP Gateway → Calendar MCP → Connection Service`. Минимальный Helm chart устанавливается и проверяется в одноразовом k3d-кластере. Текущий результат: @@ -12,7 +12,8 @@ Helm chart устанавливается и проверяется в одно 4. воспроизводимый Compose с локальной сборкой всех приложений; 5. отдельная БД и Conversation Service, создающий Action и виджет подтверждения; 6. изолированный ephemeral preview namespace для инфраструктурных pull request; -7. Telegram Adapter, Keycloak Device Flow и локальный fake Telegram API без настоящего bot token. +7. Telegram Adapter, Keycloak Device Flow и локальный fake Telegram API без настоящего bot token; +8. два календарных режима: детерминированный fake для CI и Google через отдельный Connection Service. Первый переносимый виджет подтверждения входит в системный acceptance-путь. Публичный preview URL появится после подключения общего Kubernetes-кластера и контроллера жизненного цикла окружений. diff --git a/docs/runbook.md b/docs/runbook.md index 52eee0e..4b7d361 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -20,12 +20,13 @@ 1. Выполни `task status`, затем `task verify`. 2. Проверь, что рядом лежат репозитории `portable-agent-channel-gateway`, `portable-agent-agent-runtime`, `portable-agent-action-service`, `portable-agent-conversation-service`, `portable-agent-mcp-gateway` - и `portable-agent-calendar-mcp`, + `portable-agent-calendar-mcp` и `portable-agent-connection-service`, либо задай их `*_CONTEXT` в `.env`. 3. Если Keycloak сообщает о старом fixture, локально выполни `task reset`. Команда запрашивает подтверждение и удаляет только volumes этого Compose project. 4. Проверь `/health/live` Channel Gateway и Agent Runtime, `/actuator/health/readiness` Action Service, - `/actuator/health` Conversation Service и `/health` двух MCP-сервисов. + `/actuator/health` Conversation Service, `/actuator/health/readiness` Connection Service и + `/health` обоих экземпляров Calendar MCP. 5. Не включай Calendar test API вне локального профиля `apps`. После запуска проверь весь backend-путь одной командой: @@ -42,6 +43,15 @@ Action Service с событием в тестовом календарном к Первый Docker build скачивает Gradle и Python packages и может быть заметно медленнее повторных запусков. BuildKit сохраняет слои для следующих сборок. +## Google Calendar не подключается + +1. Проверь, что `GOOGLE_OAUTH_ENABLED=true` задан в локальном `.env`. +2. Сравни `GOOGLE_OAUTH_REDIRECT_URI` с redirect URI в Google Cloud Console посимвольно. +3. Посмотри `task service:logs SERVICE=connection-service`; OAuth tokens не должны выводиться в лог. +4. Посмотри `task service:logs SERVICE=google-calendar-mcp`; этот экземпляр не хранит credentials, + а запрашивает короткоживущий access token у Connection Service. +5. Не включай Google credentials в `.env.example`, Compose или GitHub Actions. + ## Preview smoke не запускается 1. Выполни `task doctor`, затем проверь `helm version`, `kubectl version --client` и `k3d version`. diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index ca93da5..fc3f25a 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -22,6 +22,10 @@ $calendarClient = $realm.clients | Where-Object clientId -eq "calendar-mcp" if (-not $calendarClient -or -not $calendarClient.bearerOnly) { throw "Нет resource server client calendar-mcp." } +$connectionClient = $realm.clients | Where-Object clientId -eq "connection-service" +if (-not $connectionClient -or -not $connectionClient.bearerOnly) { + throw "Нет resource server client connection-service." +} $agentClient = $realm.clients | Where-Object clientId -eq "agent-runtime" if (-not $agentClient -or -not $agentClient.bearerOnly) { throw "Нет resource server client agent-runtime." @@ -63,6 +67,10 @@ $calendarScope = $realm.clientScopes | Where-Object name -eq "calendar:write" if (-not $calendarScope -or $localClient.defaultClientScopes -notcontains "calendar:write") { throw "Локальный JWT не получает scope calendar:write." } +$connectionScope = $realm.clientScopes | Where-Object name -eq "connection:token" +if (-not $connectionScope -or $actionClient.defaultClientScopes -notcontains "connection:token") { + throw "Service token action-service не получает scope connection:token." +} $gatewayScope = $realm.clientScopes | Where-Object name -eq "mcp:call" if (-not $gatewayScope -or $actionClient.defaultClientScopes -notcontains "mcp:call" ` -or $actionClient.defaultClientScopes -notcontains "calendar:write") { @@ -72,7 +80,8 @@ $localAudiences = @($localClient.protocolMappers | ForEach-Object { $_.config.'i if ($localAudiences -notcontains "channel-gateway" -or $localAudiences -notcontains "agent-runtime" ` -or $localAudiences -notcontains "action-service" ` -or $localAudiences -notcontains "conversation-service" ` - -or $localAudiences -notcontains "calendar-mcp") { + -or $localAudiences -notcontains "calendar-mcp" ` + -or $localAudiences -notcontains "connection-service") { throw "Локальный JWT не получает audience всех пользовательских сервисов." } $tenantMapper = $localClient.protocolMappers | Where-Object { $_.config.'claim.name' -eq "tenant_id" } @@ -82,8 +91,9 @@ if (-not $tenantMapper) { $actionTenantMapper = $actionClient.protocolMappers | Where-Object { $_.config.'claim.name' -eq "tenant_id" } $actionAudiences = @($actionClient.protocolMappers | ForEach-Object { $_.config.'included.client.audience' }) if (-not $actionTenantMapper -or $actionAudiences -notcontains "mcp-gateway" ` - -or $actionAudiences -notcontains "calendar-mcp") { - throw "Service token action-service не содержит tenant_id и обе audience." + -or $actionAudiences -notcontains "calendar-mcp" ` + -or $actionAudiences -notcontains "connection-service") { + throw "Service token action-service не содержит tenant_id и нужные audience." } $localUser = $realm.users | Where-Object username -eq "local-user" if (-not $localUser.email -or -not $localUser.emailVerified) { @@ -96,11 +106,16 @@ $composeText = Get-Content -Raw -LiteralPath "compose/compose.yaml" if ($composeText -notmatch '(?ms)^ keycloak:.*?^ healthcheck:') { throw "У Keycloak нет readiness healthcheck." } -foreach ($service in @("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp")) { +foreach ($service in @("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "google-calendar-mcp", "connection-service")) { if ($composeText -notmatch "(?m)^ $([regex]::Escape($service)):") { throw "В Compose нет приложения $service." } } +if ($composeText -notmatch '(?ms)^ connection-service:.*?DATABASE_URL: jdbc:postgresql://postgres:5432/connections' ` + -or $composeText -notmatch '(?ms)^ google-calendar-mcp:.*?CALENDAR_PROVIDER: google-calendar' ` + -or $composeText -notmatch '"name":"google-calendar"') { + throw "Compose не содержит путь Google Calendar через Connection Service." +} foreach ($service in @("telegram-adapter", "fake-telegram")) { if ($composeText -notmatch "(?m)^ $([regex]::Escape($service)):") { throw "В Compose нет Telegram-компонента $service." @@ -230,7 +245,7 @@ foreach ($action in @("Status", "Stop", "Restart", "Logs")) { } } $appsOverride = Get-Content -Raw -LiteralPath "compose/apps.local.yaml" -foreach ($image in @("portable-agent/channel-gateway:local", "portable-agent/agent-runtime:local", "portable-agent/action-service:local", "portable-agent/conversation-service:local", "portable-agent/mcp-gateway:local", "portable-agent/calendar-mcp:local", "portable-agent/telegram-adapter:local")) { +foreach ($image in @("portable-agent/channel-gateway:local", "portable-agent/agent-runtime:local", "portable-agent/action-service:local", "portable-agent/conversation-service:local", "portable-agent/mcp-gateway:local", "portable-agent/calendar-mcp:local", "portable-agent/connection-service:local", "portable-agent/telegram-adapter:local")) { if ($appsOverride -notmatch [regex]::Escape($image)) { throw "Local override не задаёт отдельный image tag $image." } @@ -274,6 +289,9 @@ if ($versions -notmatch '(?m)^CHANNEL_GATEWAY_IMAGE=ghcr\.io/portable-agent/chan if ($versions -notmatch '(?m)^TELEGRAM_ADAPTER_IMAGE=ghcr\.io/portable-agent/telegram-adapter:[0-9a-f]{40}\r?$') { throw "Telegram Adapter image должен быть закреплён полным Git SHA." } +if ($versions -notmatch '(?m)^CONNECTION_SERVICE_IMAGE=ghcr\.io/portable-agent/connection-service:[0-9a-f]{40}\r?$') { + throw "Connection Service image не закреплён полным Git SHA." +} if ($versions -notmatch '(?m)^TEST_LAB_REF=[0-9a-f]{40}\r?$') { throw "Test Lab должен быть закреплён полным Git SHA." } @@ -338,7 +356,7 @@ if (-not (Test-Path -LiteralPath $appWorkflowPath)) { throw "Нет CI-проверки полного Compose-среза." } $appWorkflow = Get-Content -Raw -LiteralPath $appWorkflowPath -foreach ($required in @("versions.env", "channel-gateway", "agent-runtime", "CONVERSATION_SERVICE_CONTEXT", "repository: portable-agent/conversation-service", 'ref: ${{ steps.versions.outputs.conversation }}', "TELEGRAM_ADAPTER_CONTEXT", "repository: portable-agent/telegram-adapter", 'ref: ${{ steps.versions.outputs.telegram }}', "repository: portable-agent/test-lab", 'ref: ${{ steps.versions.outputs.test_lab }}', "start-local.ps1 -Apps", "run-test-lab.ps1", "stop-local.ps1 -DeleteData", "if: always()")) { +foreach ($required in @("versions.env", "channel-gateway", "agent-runtime", "CONVERSATION_SERVICE_CONTEXT", "repository: portable-agent/conversation-service", 'ref: ${{ steps.versions.outputs.conversation }}', "CONNECTION_SERVICE_CONTEXT", "repository: portable-agent/connection-service", 'ref: ${{ steps.versions.outputs.connection }}', "TELEGRAM_ADAPTER_CONTEXT", "repository: portable-agent/telegram-adapter", 'ref: ${{ steps.versions.outputs.telegram }}', "repository: portable-agent/test-lab", 'ref: ${{ steps.versions.outputs.test_lab }}', "start-local.ps1 -Apps", "run-test-lab.ps1", "stop-local.ps1 -DeleteData", "if: always()")) { if ($appWorkflow -notmatch [regex]::Escape($required)) { throw "CI-проверка полного среза не содержит $required." } diff --git a/scripts/check-keycloak.ps1 b/scripts/check-keycloak.ps1 index b1cbce6..5374a76 100644 --- a/scripts/check-keycloak.ps1 +++ b/scripts/check-keycloak.ps1 @@ -32,7 +32,9 @@ try { -or $audiences -notcontains "agent-runtime" ` -or $audiences -notcontains "action-service" ` -or $audiences -notcontains "conversation-service" ` - -or $audiences -notcontains "calendar-mcp" -or $scopes -notcontains "calendar:write") { + -or $audiences -notcontains "calendar-mcp" ` + -or $audiences -notcontains "connection-service" ` + -or $scopes -notcontains "calendar:write") { throw "JWT claims пользователя не содержат tenant и audience сервисов." } @@ -51,8 +53,10 @@ try { if ($serviceClaims.tenant_id -ne $ExpectedTenant ` -or $serviceAudiences -notcontains "mcp-gateway" ` -or $serviceAudiences -notcontains "calendar-mcp" ` + -or $serviceAudiences -notcontains "connection-service" ` -or $serviceScopes -notcontains "mcp:call" ` - -or $serviceScopes -notcontains "calendar:write") { + -or $serviceScopes -notcontains "calendar:write" ` + -or $serviceScopes -notcontains "connection:token") { throw "Service token action-service не содержит нужные claims." } diff --git a/scripts/local-settings.ps1 b/scripts/local-settings.ps1 index a00562a..a46e138 100644 --- a/scripts/local-settings.ps1 +++ b/scripts/local-settings.ps1 @@ -22,3 +22,17 @@ function Initialize-LocalTelegramKey { $hash.Dispose() } } + +function Initialize-LocalConnectionKey { + $key = Get-LocalSetting "CONNECTION_TOKEN_KEY_BASE64" + if ($key -ne "derive-from-local-action-secret") { return } + + $actionSecret = Get-LocalSetting "ACTION_SERVICE_CLIENT_SECRET" + $hash = [Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes("portable-agent-local-connection:$actionSecret") + $env:CONNECTION_TOKEN_KEY_BASE64 = [Convert]::ToBase64String($hash.ComputeHash($bytes)) + } finally { + $hash.Dispose() + } +} diff --git a/scripts/service-local.ps1 b/scripts/service-local.ps1 index cec47d7..4975ab3 100644 --- a/scripts/service-local.ps1 +++ b/scripts/service-local.ps1 @@ -2,7 +2,7 @@ [Parameter(Mandatory = $true)] [ValidateSet("Status", "Stop", "Restart", "Logs")] [string]$Action, - [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "telegram-adapter")] + [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "google-calendar-mcp", "connection-service", "telegram-adapter")] [string]$Service, [ValidateRange(1, 10000)] [int]$Tail = 100 @@ -11,6 +11,7 @@ $ErrorActionPreference = "Stop" . "$PSScriptRoot/local-settings.ps1" Initialize-LocalTelegramKey +Initialize-LocalConnectionKey if ($Action -ne "Status" -and -not $Service) { throw "Для команды $Action укажи -Service." } diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index 3a96082..16cbf41 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -2,7 +2,7 @@ [switch]$Observe, [switch]$Apps, [switch]$Model, - [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "telegram-adapter")] + [ValidateSet("channel-gateway", "agent-runtime", "action-service", "conversation-service", "mcp-gateway", "calendar-mcp", "google-calendar-mcp", "connection-service", "telegram-adapter")] [string]$Service ) $ErrorActionPreference = "Stop" @@ -19,6 +19,7 @@ if ($runningOnWindows) { $composeFiles += @("-f", "compose/windows.local.yaml") $appComposeFiles = @($composeFiles + @("-f", "compose/apps.local.yaml")) if ($Model) { $appComposeFiles += @("-f", "compose/model.local.yaml") } Initialize-LocalTelegramKey +Initialize-LocalConnectionKey $coreProfiles = @("--profile", "core") if ($Observe) { $coreProfiles += @("--profile", "observe") } & docker compose @envFiles @composeFiles @coreProfiles run --rm postgres-bootstrap