Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,15 @@ K6_USERS=5
K6_TIME=20s
K6_P95_MS=300
K6_ERROR_RATE=0.01
KEYCLOAK_URL=http://localhost:8081
CHANNEL_URL=http://host.docker.internal:18084
ACTION_URL=http://host.docker.internal:18081
CALENDAR_TEST_URL=http://host.docker.internal:18082
OIDC_REALM=portable-agent
OIDC_CLIENT_ID=portable-agent-local

# Keep local credentials and test API keys in ignored .env, not in Git.
# TEST_USERNAME=local-user
# TEST_PASSWORD=change-me
# CALENDAR_TEST_API_KEY=change-me

9 changes: 5 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: go-task/setup-task@v2
with:
version: 3.53.1
- name: Проверить конфигурацию
shell: pwsh
run: ./scripts/check.ps1
run: task verify
- name: Запустить k6 smoke
shell: pwsh
run: ./scripts/run-load.ps1
run: task test:smoke
security:
uses: portable-agent/.github/.github/workflows/reusable-security.yml@main

6 changes: 4 additions & 2 deletions .github/workflows/weekly-load.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: go-task/setup-task@v2
with:
version: 3.53.1
- name: Запустить расширенный локальный тест
shell: pwsh
env:
K6_USERS: "20"
K6_TIME: 2m
run: ./scripts/run-load.ps1
run: task test:smoke

8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
## Команды

```powershell
pwsh ./scripts/check.ps1
pwsh ./scripts/run-load.ps1
task verify
task test:smoke
task test:e2e
```

`test:e2e` запускается против уже поднятого `deploy`-окружения. Не переноси сюда управление жизненным
циклом сервисов: `test-lab` владеет сценариями и результатами, `deploy` владеет стендом.
44 changes: 32 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@

```powershell
Copy-Item .env.example .env
pwsh ./scripts/check.ps1
pwsh ./scripts/run-load.ps1
task verify
task test:smoke
```

Все повседневные команды собраны в `Taskfile.yml`. Выполни `task --list`, чтобы увидеть их. На
Windows Task сам использует Windows PowerShell, поэтому отдельная установка `pwsh` не нужна.

По умолчанию Compose поднимает только локальный fake-service. Для внешней среды явно передай
`TARGET_URL`; production URL скрипты отклоняют. Пороги k6 хранятся рядом со сценарием.

Expand All @@ -21,18 +24,35 @@ pwsh ./scripts/run-load.ps1
действие ждёт подтверждения, после подтверждения завершается и создаёт ровно одно событие. Повтор с
тем же `requestKey` не создаёт дубль.

До появления Temporal worker и `fake-calendar` этот тест намеренно красный. После запуска полного
локального стенда получи JWT тестового пользователя и выполни:
Сначала подними полный локальный стенд в репозитории `deploy`. Затем заполни в локальном `.env`
учётные данные только тестового пользователя и ключ проверочного API:

```dotenv
TEST_USERNAME=local-user
TEST_PASSWORD=<пароль локального пользователя>
CALENDAR_TEST_API_KEY=<локальный ключ Calendar MCP>
```

После этого запусти:

```powershell
$env:ACTION_TOKEN = "<local-test-token с audience agent-runtime и action-service>"
$env:CALENDAR_TEST_API_KEY = "<тот же локальный секрет, что у Calendar MCP>"
pwsh ./scripts/run-calendar.ps1
task test:e2e
```

Скрипт принимает только локальные HTTP-адреса. Проверочный API `fake-calendar` доступен только в
тестовом режиме и требует отдельный `X-Test-Key`; секрет не хранится в Git.
Runner сам получает короткоживущий JWT у локального Keycloak. В CI вместо тестового логина и пароля
можно передать готовый `ACTION_TOKEN`. Скрипт принимает только локальные HTTP-адреса. Проверочный API
`fake-calendar` доступен только в тестовом режиме и требует отдельный `X-Test-Key`; секреты не
хранятся в Git.

Путь начинается с публичной границы `Channel Gateway`, затем проходит через Agent Runtime, Action
Service, Temporal и Calendar MCP. Один JWT передаётся по этому пути; каждый защищённый сервис
самостоятельно проверяет подпись, issuer, срок и свой audience. Идентификаторы пользователя и tenant
не передаются в JSON запроса: сервисы получают их из проверенных claims `sub` и `tenant_id`.

## Границы тестов

Один JWT передаётся в Agent Runtime и Action Service. Оба сервиса независимо проверяют подпись,
issuer, срок и свой audience. Идентификаторы пользователя и tenant не передаются в JSON запроса:
сервисы получают их из проверенных claims `sub` и `tenant_id`.
- unit-, component- и integration-тесты принадлежат репозиторию конкретного сервиса;
- здесь остаются только критические black-box пути пользователя, нагрузочные проверки и безопасные
resilience-сценарии;
- `test:e2e` не поднимает сервисы и не управляет их жизненным циклом: за окружение отвечает `deploy`;
- production-адреса runner отклоняет до запуска теста.
4 changes: 3 additions & 1 deletion SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
| Бизнес-код | отсутствует |
| Основной инструмент | k6; contract-first сценарии |
| Chaos | отдельный ручной запуск, выключен по умолчанию |
| Первый acceptance-путь | Agent Runtime 2.1 → Action Service → Temporal → Calendar MCP |
| Первый acceptance-путь | Channel Gateway → Agent Runtime → Action Service → Temporal → Calendar MCP |
| Локальный запуск | `task test:e2e` против уже поднятого окружения |
| Граница ответственности | Не поднимает сервисы и не содержит их component-тесты |

42 changes: 42 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
version: "3"

silent: true

vars:
POWERSHELL: '{{if eq OS "windows"}}powershell.exe{{else}}pwsh{{end}}'
POWERSHELL_ARGS: '{{if eq OS "windows"}}-NoProfile -ExecutionPolicy Bypass{{else}}-NoProfile{{end}}'

tasks:
default:
desc: Показать доступные команды
cmds:
- task --list

doctor:
desc: Проверить Docker и Task
cmds:
- task --version
- docker version --format "Docker Engine {{`{{.Server.Version}}`}}"
- docker compose version

verify:
desc: Проверить структуру и конфигурацию лаборатории
cmds:
- '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/check.ps1'

test:smoke:
desc: Выполнить быстрый тест локального fake-service
cmds:
- '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-load.ps1'

test:e2e:
desc: Проверить создание встречи через полный локальный срез
cmds:
- '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-calendar.ps1'

test:load:
desc: Выполнить настраиваемый нагрузочный тест
cmds:
- '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-load.ps1 -TargetUrl "{{.TARGET_URL}}"'
requires:
vars: [TARGET_URL]
34 changes: 25 additions & 9 deletions scripts/check.ps1
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
$ErrorActionPreference = "Stop"
foreach ($file in @(".env.example", "compose.yaml", "tests/smoke.js", "tests/calendar-event.js", "scripts/run-calendar.ps1", "chaos/pod-delay.yaml", "README.md", "AGENTS.md", "SERVICE.md")) {
if (-not (Test-Path $file)) { throw "Нет обязательного файла: $file" }
foreach ($file in @(".env.example", "compose.yaml", "Taskfile.yml", "tests/smoke.js", "tests/calendar-event.js", "scripts/run-calendar.ps1", "chaos/pod-delay.yaml", "README.md", "AGENTS.md", "SERVICE.md")) {
if (-not (Test-Path $file)) { throw "Required file is missing: $file" }
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker не найден." }
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker is not installed." }
& docker compose --env-file .env.example config --quiet
if ($LASTEXITCODE -ne 0) { throw "Compose config содержит ошибку." }
if ($LASTEXITCODE -ne 0) { throw "Compose config is invalid." }
$script = Get-Content tests/smoke.js -Raw
foreach ($required in @("TARGET_URL", "thresholds", "http_req_failed", "http_req_duration")) {
if ($script -notmatch $required) { throw "В k6-тесте нет $required." }
if ($script -notmatch $required) { throw "k6 test does not contain $required." }
}
$calendarScript = Get-Content tests/calendar-event.js -Raw
foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed", "CALENDAR_TEST_API_KEY", "X-Test-Key", "availableConnectors", "requiresApproval", "Authorization")) {
if ($calendarScript -notmatch [regex]::Escape($required)) { throw "В calendar acceptance-тесте нет $required." }
foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed", "CALENDAR_TEST_API_KEY", "X-Test-Key", "CHANNEL_URL", "/api/v1/messages", "requiresApproval", "Authorization")) {
if ($calendarScript -notmatch [regex]::Escape($required)) { throw "Calendar acceptance test does not contain $required." }
}
foreach ($oldName in @("utterance", "tenant_id", "actor_id", "available_connectors", "requires_approval")) {
if ($calendarScript -match [regex]::Escape($oldName)) { throw "В calendar acceptance-тесте осталось старое поле $oldName." }
if ($calendarScript -match [regex]::Escape($oldName)) { throw "Calendar acceptance test still contains old field $oldName." }
}
Write-Host "Быстрые проверки test-lab прошли."
if ($calendarScript -match "/api/v1/proposals") { throw "Acceptance-test must start through Channel Gateway." }

$runner = Get-Content scripts/run-calendar.ps1 -Raw
foreach ($required in @("ACTION_TOKEN", "KEYCLOAK_URL", "OIDC_REALM", "OIDC_CLIENT_ID", "TEST_USERNAME", "TEST_PASSWORD", "protocol/openid-connect/token")) {
if ($runner -notmatch [regex]::Escape($required)) { throw "Calendar runner does not support $required." }
}

$taskfile = Get-Content Taskfile.yml -Raw
foreach ($required in @("verify:", "test:smoke:", "test:e2e:", "test:load:")) {
if ($taskfile -notmatch [regex]::Escape($required)) { throw "Taskfile does not contain $required." }
}

$ci = Get-Content .github/workflows/ci.yml -Raw
foreach ($required in @("go-task/setup-task", "task verify", "task test:smoke")) {
if ($ci -notmatch [regex]::Escape($required)) { throw "CI does not use $required." }
}
Write-Host "Test Lab checks passed."

66 changes: 52 additions & 14 deletions scripts/run-calendar.ps1
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
param(
[string]$AgentUrl = "http://host.docker.internal:18080",
[string]$ActionUrl = "http://host.docker.internal:18081",
[string]$CalendarTestUrl = "http://host.docker.internal:18082"
[string]$ChannelUrl = "",
[string]$ActionUrl = "",
[string]$CalendarTestUrl = "",
[string]$KeycloakUrl = ""
)

$ErrorActionPreference = "Stop"

foreach ($url in @($AgentUrl, $ActionUrl, $CalendarTestUrl)) {
function Get-Setting([string]$Name, [switch]$Secret) {
$value = [Environment]::GetEnvironmentVariable($Name)
if (-not $value -and (Test-Path -LiteralPath ".env")) {
$line = Get-Content -LiteralPath ".env" | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1
if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) }
}
if (-not $value -and -not $Secret -and (Test-Path -LiteralPath ".env.example")) {
$line = Get-Content -LiteralPath ".env.example" | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1
if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) }
}
if (-not $value) { throw "Set $Name in the environment or local .env file." }
return $value
}

function Assert-LocalUrl([string]$Url) {
$uri = [Uri]$url
if ($uri.Scheme -ne "http" -or $uri.Host -notin @("localhost", "127.0.0.1", "host.docker.internal")) {
throw "Calendar acceptance-тест разрешён только для локальных HTTP-адресов."
throw "Calendar acceptance test allows local HTTP URLs only."
}
}
if (-not $env:ACTION_TOKEN) { throw "Укажи ACTION_TOKEN с JWT тестового пользователя." }
if (-not $env:CALENDAR_TEST_API_KEY) { throw "Укажи CALENDAR_TEST_API_KEY локального Calendar MCP." }
$parts = $env:ACTION_TOKEN.Split('.')
if ($parts.Count -ne 3) { throw "ACTION_TOKEN не похож на JWT." }

$ChannelUrl = if ($ChannelUrl) { $ChannelUrl } else { Get-Setting "CHANNEL_URL" }
$ActionUrl = if ($ActionUrl) { $ActionUrl } else { Get-Setting "ACTION_URL" }
$CalendarTestUrl = if ($CalendarTestUrl) { $CalendarTestUrl } else { Get-Setting "CALENDAR_TEST_URL" }
$KeycloakUrl = if ($KeycloakUrl) { $KeycloakUrl } else { Get-Setting "KEYCLOAK_URL" }

foreach ($url in @($ChannelUrl, $ActionUrl, $CalendarTestUrl, $KeycloakUrl)) {
Assert-LocalUrl $url
}

$token = [Environment]::GetEnvironmentVariable("ACTION_TOKEN")
if (-not $token) {
$tokenResponse = Invoke-RestMethod -Method Post `
-Uri "$KeycloakUrl/realms/$(Get-Setting 'OIDC_REALM')/protocol/openid-connect/token" `
-Body @{
client_id = Get-Setting "OIDC_CLIENT_ID"
username = Get-Setting "TEST_USERNAME" -Secret
password = Get-Setting "TEST_PASSWORD" -Secret
grant_type = "password"
}
$token = $tokenResponse.access_token
}

$parts = $token.Split('.')
if ($parts.Count -ne 3) { throw "ACTION_TOKEN is not a JWT." }
$calendarTestKey = Get-Setting "CALENDAR_TEST_API_KEY" -Secret
$k6Image = Get-Setting "K6_IMAGE"

& docker run --rm `
--volume "${PWD}/tests:/tests:ro" `
--env "AGENT_URL=$AgentUrl" `
--env "CHANNEL_URL=$ChannelUrl" `
--env "ACTION_URL=$ActionUrl" `
--env "CALENDAR_TEST_URL=$CalendarTestUrl" `
--env "CALENDAR_TEST_API_KEY=$env:CALENDAR_TEST_API_KEY" `
--env "ACTION_TOKEN=$env:ACTION_TOKEN" `
"grafana/k6:2.2.0" run /tests/calendar-event.js
--env "CALENDAR_TEST_API_KEY=$calendarTestKey" `
--env "ACTION_TOKEN=$token" `
$k6Image run /tests/calendar-event.js

if ($LASTEXITCODE -ne 0) { throw "Сценарий создания встречи не прошёл." }
if ($LASTEXITCODE -ne 0) { throw "Calendar acceptance scenario failed." }
8 changes: 4 additions & 4 deletions scripts/run-chaos.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ param(
[switch]$AllowChaos
)
$ErrorActionPreference = "Stop"
if (-not $AllowChaos) { throw "Нужен явный флаг -AllowChaos." }
if ($Namespace -match '(?i)prod|production') { throw "Chaos в production запрещён этим репозиторием." }
if (-not $AllowChaos) { throw "Explicit -AllowChaos flag is required." }
if ($Namespace -match '(?i)prod|production') { throw "Chaos in production is forbidden by this repository." }
$label = & kubectl get namespace $Namespace -o 'jsonpath={.metadata.labels.portable-agent\.io/chaos-ready}'
if ($LASTEXITCODE -ne 0 -or $label -ne "true") { throw "У namespace нет метки portable-agent.io/chaos-ready=true." }
if ($LASTEXITCODE -ne 0 -or $label -ne "true") { throw "Namespace must have portable-agent.io/chaos-ready=true label." }
$yaml = (Get-Content chaos/pod-delay.yaml -Raw).Replace("change-me", $Namespace)
$yaml | & kubectl apply -f -
if ($LASTEXITCODE -ne 0) { throw "Chaos manifest не применился." }
if ($LASTEXITCODE -ne 0) { throw "Chaos manifest was not applied." }

6 changes: 3 additions & 3 deletions scripts/run-load.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ $ErrorActionPreference = "Stop"
$envFiles = @("--env-file", ".env.example")
if (Test-Path .env) { $envFiles += @("--env-file", ".env") }
if ($TargetUrl) {
if ($TargetUrl -match '(?i)prod|production') { throw "Запуск против production запрещён." }
if ($TargetUrl -match '(?i)prod|production') { throw "Running against production is forbidden." }
$env:TARGET_URL = $TargetUrl
}
try {
& docker compose @envFiles up -d --wait fake-service
if ($LASTEXITCODE -ne 0) { throw "Fake service не запустился." }
if ($LASTEXITCODE -ne 0) { throw "Fake service did not start." }
& docker compose @envFiles --profile test run --rm k6
if ($LASTEXITCODE -ne 0) { throw "k6-пороги не выполнены." }
if ($LASTEXITCODE -ne 0) { throw "k6 thresholds were not met." }
}
finally {
& docker compose @envFiles --profile test down | Out-Null
Expand Down
17 changes: 9 additions & 8 deletions tests/calendar-event.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import http from 'k6/http';
import { check, fail, sleep } from 'k6';

const agentUrl = requiredUrl('AGENT_URL');
const channelUrl = requiredUrl('CHANNEL_URL');
const actionUrl = requiredUrl('ACTION_URL');
const calendarTestUrl = requiredUrl('CALENDAR_TEST_URL');
const calendarTestKey = required('CALENDAR_TEST_API_KEY');
const userToken = required('ACTION_TOKEN');
const eventData = {
title: 'Обсуждение проекта',
startAt: '2026-09-08T12:00:00+03:00',
endAt: '2026-09-08T12:30:00+03:00',
startAt: '2030-09-08T12:00:00+03:00',
endAt: '2030-09-08T12:30:00+03:00',
timeZone: 'Europe/Moscow',
};

Expand Down Expand Up @@ -69,23 +69,24 @@ export default function () {

function createProposal() {
const response = http.post(
`${agentUrl}/api/v1/proposals`,
`${channelUrl}/api/v1/messages`,
JSON.stringify({
requestKey: `proposal-${Date.now()}-${__VU}-${__ITER}`,
text: `Создай встречу "${eventData.title}" с ${eventData.startAt} до ${eventData.endAt}`,
context: {
locale: 'ru-RU',
timeZone: eventData.timeZone,
availableConnectors: ['fake-calendar'],
},
}),
authHeaders(),
);
expectStatus(response, 200, 'agent-runtime did not create a proposal');
expectStatus(response, 200, 'channel-gateway did not create a proposal');
const body = response.json();
if (!body.proposal || body.clarification) {
fail('agent-runtime returned no complete proposal');
fail('channel-gateway returned no complete proposal');
}
if (!body.proposal.requiresApproval) {
fail('agent-runtime proposal does not require approval');
fail('channel-gateway proposal does not require approval');
}
check(body.proposal.payload, {
'proposal keeps the exact event data': (payload) =>
Expand Down