diff --git a/.github/workflows/preview-smoke.yml b/.github/workflows/preview-smoke.yml new file mode 100644 index 0000000..8992157 --- /dev/null +++ b/.github/workflows/preview-smoke.yml @@ -0,0 +1,37 @@ +name: Pull request preview smoke + +on: + pull_request: + paths: + - ".github/workflows/preview-smoke.yml" + - "charts/**" + - "scripts/check-chart.ps1" + - "scripts/check-preview.ps1" + - "scripts/smoke-preview.ps1" + - "Taskfile.yml" + +permissions: + contents: read + +jobs: + preview: + name: Preview namespace smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: azure/setup-helm@v5.0.1 + with: + version: v4.2.4 + - name: Установить k3d + env: + K3D_VERSION: v5.9.0 + run: | + curl -fsSLo /tmp/k3d-install.sh "https://raw.githubusercontent.com/k3d-io/k3d/${K3D_VERSION}/install.sh" + TAG="${K3D_VERSION}" bash /tmp/k3d-install.sh + - name: Проверить preview namespace + shell: pwsh + run: >- + ./scripts/smoke-preview.ps1 + -PullRequestNumber "${{ github.event.pull_request.number }}" + -ClusterName "pa-preview-${{ github.run_id }}" diff --git a/AGENTS.md b/AGENTS.md index 5ef9b5f..c685845 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ `deploy` управляет окружением, а black-box сценарии принадлежат репозиторию `test-lab`. Не добавляй сюда HTTP-проверки продуктового поведения; вызывай единый сценарий через `run-test-lab.ps1`. +`charts/preview` управляет только namespace guardrails. Сервисы во временном окружении устанавливаются +тем же `charts/service`, который используется в остальных Kubernetes-окружениях. ## Команды diff --git a/README.md b/README.md index 7b80662..088ba8d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - Compose-профиль `apps`: Channel Gateway, Agent Runtime, Action Service, MCP Gateway и Calendar MCP; - Compose-профиль `observe`: OpenTelemetry, Prometheus, Grafana, Tempo и Loki; - безопасный общий chart `charts/service`; +- изолированный PR preview namespace с quota, limits и default-deny сетью; - Argo CD bootstrap `charts/gitops`; - каталог окружений и генератор нового сервиса; - быстрые проверки в каждом PR и полный k3d smoke по расписанию. @@ -117,6 +118,17 @@ task verify ./scripts/smoke-k3d.ps1 ``` +Проверить модель временного окружения для номера pull request: + +```powershell +task preview:smoke PR=123 +``` + +Команда создаёт отдельный k3d-кластер и namespace `portable-agent-pr-123`, применяет guardrails, +устанавливает тестовый сервис и всегда удаляет только созданный кластер. Сейчас это ephemeral +CI-preview без публичного URL. После подключения общего Kubernetes-кластера тот же `charts/preview` +будет создавать долгоживущий namespace, а ingress и DNS останутся ответственностью платформы. + Подготовить delivery-файлы нового сервиса: ```powershell diff --git a/Taskfile.yml b/Taskfile.yml index a4efd78..ba67b8f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -28,6 +28,13 @@ tasks: cmds: - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/start-local.ps1 -Observe' + preview:smoke: + desc: Проверить изолированное preview-окружение для номера PR + requires: + vars: [PR] + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/smoke-preview.ps1 -PullRequestNumber {{.PR}}' + services:up: desc: Собрать и поднять все локальные сервисы cmds: diff --git a/charts/preview/Chart.yaml b/charts/preview/Chart.yaml new file mode 100644 index 0000000..11b0061 --- /dev/null +++ b/charts/preview/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: preview +description: Guardrails for an isolated Portable Agent pull request namespace +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/charts/preview/templates/limitrange.yaml b/charts/preview/templates/limitrange.yaml new file mode 100644 index 0000000..ccdaf98 --- /dev/null +++ b/charts/preview/templates/limitrange.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: LimitRange +metadata: + name: preview-limits + namespace: {{ .Values.namespace.name | quote }} +spec: + limits: + - type: Container + defaultRequest: + cpu: {{ .Values.limits.defaultRequestCpu | quote }} + memory: {{ .Values.limits.defaultRequestMemory | quote }} + default: + cpu: {{ .Values.limits.defaultCpu | quote }} + memory: {{ .Values.limits.defaultMemory | quote }} diff --git a/charts/preview/templates/namespace.yaml b/charts/preview/templates/namespace.yaml new file mode 100644 index 0000000..d205aa9 --- /dev/null +++ b/charts/preview/templates/namespace.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace.name | quote }} + labels: + portable-agent.io/environment: preview + portable-agent.io/pull-request: {{ .Values.namespace.pullRequest | quote }} + annotations: + portable-agent.io/expires-at: {{ .Values.namespace.expiresAt | quote }} diff --git a/charts/preview/templates/networkpolicy.yaml b/charts/preview/templates/networkpolicy.yaml new file mode 100644 index 0000000..a3ea20a --- /dev/null +++ b/charts/preview/templates/networkpolicy.yaml @@ -0,0 +1,10 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: preview-default-deny + namespace: {{ .Values.namespace.name | quote }} +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress diff --git a/charts/preview/templates/resourcequota.yaml b/charts/preview/templates/resourcequota.yaml new file mode 100644 index 0000000..2858f84 --- /dev/null +++ b/charts/preview/templates/resourcequota.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ResourceQuota +metadata: + name: preview-budget + namespace: {{ .Values.namespace.name | quote }} +spec: + hard: + pods: {{ .Values.quota.pods | quote }} + requests.cpu: {{ .Values.quota.requestsCpu | quote }} + requests.memory: {{ .Values.quota.requestsMemory | quote }} + limits.cpu: {{ .Values.quota.limitsCpu | quote }} + limits.memory: {{ .Values.quota.limitsMemory | quote }} diff --git a/charts/preview/values.schema.json b/charts/preview/values.schema.json new file mode 100644 index 0000000..2ae65f9 --- /dev/null +++ b/charts/preview/values.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["namespace", "quota", "limits"], + "properties": { + "namespace": { + "type": "object", + "required": ["name", "pullRequest", "expiresAt"], + "properties": { + "name": { + "type": "string", + "maxLength": 63, + "pattern": "^portable-agent-pr-[1-9][0-9]*$" + }, + "pullRequest": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "expiresAt": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + } + } + }, + "quota": { + "type": "object", + "required": ["pods", "requestsCpu", "requestsMemory", "limitsCpu", "limitsMemory"] + }, + "limits": { + "type": "object", + "required": ["defaultRequestCpu", "defaultRequestMemory", "defaultCpu", "defaultMemory"] + } + } +} diff --git a/charts/preview/values.yaml b/charts/preview/values.yaml new file mode 100644 index 0000000..47fe3d6 --- /dev/null +++ b/charts/preview/values.yaml @@ -0,0 +1,17 @@ +namespace: + name: portable-agent-pr-1 + pullRequest: "1" + expiresAt: "2030-01-01T00:00:00Z" + +quota: + pods: "20" + requestsCpu: "4" + requestsMemory: 8Gi + limitsCpu: "8" + limitsMemory: 16Gi + +limits: + defaultRequestCpu: 100m + defaultRequestMemory: 128Mi + defaultCpu: 500m + defaultMemory: 512Mi diff --git a/docs/architecture.md b/docs/architecture.md index adcae28..9c21f69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,6 +20,11 @@ services/catalog.json -> environments//services//values.yaml кластер. Полный smoke создаёт только временный k3d-кластер и всегда удаляет лишь созданный им кластер. +Для pull request используется отдельный namespace `portable-agent-pr-`. Chart `preview` +создаёт Namespace, ResourceQuota, LimitRange и default-deny NetworkPolicy, после чего приложения +устанавливаются существующим `service` chart. Сейчас CI проверяет эту модель в одноразовом k3d; +постоянный кластер позже добавит ingress, DNS и автоматическое удаление по метке срока жизни. + ## Локальный execution slice ```mermaid diff --git a/docs/decisions/0005-pr-preview.md b/docs/decisions/0005-pr-preview.md new file mode 100644 index 0000000..d2d14bd --- /dev/null +++ b/docs/decisions/0005-pr-preview.md @@ -0,0 +1,26 @@ +# ADR 0005: временное окружение pull request + +Статус: принято. + +## Контекст + +Изменения общих Helm charts нужно проверять до merge в окружении, похожем на Kubernetes. Постоянного +общего кластера пока нет, а отдельный способ установки сервисов для preview быстро разойдётся с +основными окружениями. + +## Решение + +Каждый preview получает namespace `portable-agent-pr-`. Chart `preview` создаёт только +границы окружения: метки PR и срока жизни, ResourceQuota, LimitRange и default-deny NetworkPolicy. +Сервисы устанавливаются существующим `service` chart. + +До появления общего кластера workflow создаёт одноразовый k3d-кластер, проверяет guardrails и один +smoke deployment, затем удаляет созданный кластер. Сценарий не принимает произвольное имя namespace +и не изменяет найденный ранее кластер. + +## Последствия + +- Helm-модель preview проверяется в каждом затрагивающем её PR; +- ограничения ресурсов и сети появляются до запуска приложений; +- сервисы не получают отдельные preview-шаблоны; +- публичный URL, TLS и очистка по времени будут добавлены только вместе с постоянным кластером. diff --git a/docs/development.md b/docs/development.md index b68bced..589a44d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,6 +25,15 @@ Taskfile — единая точка входа. Внутренние PowerShell `task down` не удаляет данные. `task reset` предназначен для явного пересоздания тестового состояния и запрашивает подтверждение. +Перед изменением Helm chart можно локально проверить PR preview: + +```powershell +task preview:smoke PR=123 +``` + +Номер формирует безопасное имя `portable-agent-pr-123`. Скрипт отказывается менять уже существующий +k3d-кластер и удаляет только тот кластер, который создал сам. + Работа идёт по TDD: 1. проверка описывает ожидаемый Kubernetes-ресурс; diff --git a/docs/index.md b/docs/index.md index d4296c2..d28bb0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ Helm chart устанавливается и проверяется в одно 2. единый локальный JWT с отдельными audience сервисов; 3. общий Channel Gateway и Agent Runtime с закрытыми API и контрактом `2.2.0`; 4. воспроизводимый Compose с локальной сборкой всех приложений. +5. изолированный ephemeral preview namespace для инфраструктурных pull request. -Следующий инфраструктурный пакет — preview namespace для pull request. Следующий продуктовый этап — -первый переносимый виджет подтверждения. +Следующий продуктовый этап — первый переносимый виджет подтверждения. Публичный preview URL появится +после подключения общего Kubernetes-кластера и контроллера жизненного цикла окружений. diff --git a/docs/runbook.md b/docs/runbook.md index ffadee5..4c74666 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -40,3 +40,11 @@ Action Service с событием в тестовом календарном к Первый Docker build скачивает Gradle и Python packages и может быть заметно медленнее повторных запусков. BuildKit сохраняет слои для следующих сборок. + +## Preview smoke не запускается + +1. Выполни `task doctor`, затем проверь `helm version`, `kubectl version --client` и `k3d version`. +2. Используй положительный номер PR: `task preview:smoke PR=123`. +3. Если кластер `pa-preview-123` уже существует, скрипт намеренно его не меняет. Удали свой старый + тестовый кластер вручную либо передай другое имя напрямую в `smoke-preview.ps1`. +4. В CI namespace и кластер удаляются в `finally`, даже если установка сервиса завершилась ошибкой. diff --git a/mkdocs.yml b/mkdocs.yml index d85fe3b..d69e495 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,3 +15,4 @@ nav: - Платформа разработки: decisions/0002-developer-platform.md - Единые команды разработчика: decisions/0003-taskfile-workflow.md - Владение сквозными тестами: decisions/0004-test-lab-ownership.md + - Временное окружение PR: decisions/0005-pr-preview.md diff --git a/scripts/check-chart.ps1 b/scripts/check-chart.ps1 index 17c716a..4b06b14 100644 --- a/scripts/check-chart.ps1 +++ b/scripts/check-chart.ps1 @@ -7,6 +7,7 @@ if (-not (Get-Command helm -ErrorAction SilentlyContinue)) { foreach ($item in @( @{ Name = "platform-base"; Path = "charts/platform-base"; Args = @("--set", "environment=smoke") }, @{ Name = "service"; Path = "charts/service"; Args = @("-f", "charts/service/tests/smoke-values.yaml") }, + @{ Name = "preview"; Path = "charts/preview"; Args = @() }, @{ Name = "gitops"; Path = "charts/gitops"; Args = @("-f", "charts/gitops/tests/smoke-values.yaml") } )) { & helm lint $item.Path --strict @($item.Args) diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index b0f703e..1b398b1 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -181,6 +181,9 @@ if (Test-Path -LiteralPath "scripts/check-apps.ps1") { throw "Продуктовый E2E не должен дублироваться в deploy." } $testLabRunner = Get-Content -Raw -LiteralPath $testLabRunnerPath +if ($testLabRunner -notmatch '\$containerIds\s*=\s*@\(\s*\r?\n\s*\(& docker ps') { + throw "Результат docker ps должен сохраняться массивом до обращения по индексу." +} foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_TEST_API_KEY", "DOCKER_NETWORK", "docker inspect", "com.docker.compose.service=channel-gateway", "http://channel-gateway:8080", "http://action-service:8080", "http://calendar-mcp:8080", "task", "test:e2e")) { if ($testLabRunner -notmatch [regex]::Escape($required)) { throw "Адаптер test-lab не содержит $required." diff --git a/scripts/check-config.ps1 b/scripts/check-config.ps1 index 5aec464..080fb79 100644 --- a/scripts/check-config.ps1 +++ b/scripts/check-config.ps1 @@ -1,4 +1,5 @@ $ErrorActionPreference = "Stop" +& "$PSScriptRoot/check-preview.ps1" & "$PSScriptRoot/check-chart.ps1" & "$PSScriptRoot/test-new-service.ps1" if (Get-Command docker -ErrorAction SilentlyContinue) { & "$PSScriptRoot/check-compose.ps1" } diff --git a/scripts/check-docs.ps1 b/scripts/check-docs.ps1 index 31bd4c9..8a666a2 100644 --- a/scripts/check-docs.ps1 +++ b/scripts/check-docs.ps1 @@ -10,7 +10,9 @@ $requiredFiles = @( "docs/development.md", "docs/runbook.md", "docs/decisions/0001-deploy-repository.md", - "docs/decisions/0003-taskfile-workflow.md" + "docs/decisions/0003-taskfile-workflow.md", + "docs/decisions/0004-test-lab-ownership.md", + "docs/decisions/0005-pr-preview.md" ) $missing = $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) } diff --git a/scripts/check-preview.ps1 b/scripts/check-preview.ps1 new file mode 100644 index 0000000..8809a34 --- /dev/null +++ b/scripts/check-preview.ps1 @@ -0,0 +1,51 @@ +$ErrorActionPreference = "Stop" + +$requiredFiles = @( + "charts/preview/Chart.yaml", + "charts/preview/values.yaml", + "charts/preview/values.schema.json", + "charts/preview/templates/namespace.yaml", + "charts/preview/templates/resourcequota.yaml", + "charts/preview/templates/limitrange.yaml", + "charts/preview/templates/networkpolicy.yaml", + "scripts/smoke-preview.ps1", + ".github/workflows/preview-smoke.yml" +) +foreach ($file in $requiredFiles) { + if (-not (Test-Path -LiteralPath $file)) { throw "Required preview file is missing: $file" } +} + +$namespace = Get-Content -Raw -LiteralPath "charts/preview/templates/namespace.yaml" +foreach ($required in @("kind: Namespace", "portable-agent.io/environment", "portable-agent.io/pull-request", "portable-agent.io/expires-at")) { + if ($namespace -notmatch [regex]::Escape($required)) { throw "Preview namespace does not contain $required." } +} + +$quota = Get-Content -Raw -LiteralPath "charts/preview/templates/resourcequota.yaml" +foreach ($required in @("kind: ResourceQuota", "requests.cpu", "requests.memory", "limits.cpu", "limits.memory", "pods")) { + if ($quota -notmatch [regex]::Escape($required)) { throw "Preview quota does not contain $required." } +} + +$limit = Get-Content -Raw -LiteralPath "charts/preview/templates/limitrange.yaml" +foreach ($required in @("kind: LimitRange", "defaultRequest", "default")) { + if ($limit -notmatch [regex]::Escape($required)) { throw "Preview limits do not contain $required." } +} + +$policy = Get-Content -Raw -LiteralPath "charts/preview/templates/networkpolicy.yaml" +foreach ($required in @("kind: NetworkPolicy", "podSelector: {}", "policyTypes", "Ingress", "Egress")) { + if ($policy -notmatch [regex]::Escape($required)) { throw "Preview network policy does not contain $required." } +} + +$smoke = Get-Content -Raw -LiteralPath "scripts/smoke-preview.ps1" +foreach ($required in @("PullRequestNumber", "portable-agent-pr-", "charts/preview", "charts/service", "ResourceQuota", "LimitRange", "NetworkPolicy", "k3d cluster delete")) { + if ($smoke -notmatch [regex]::Escape($required)) { throw "Preview smoke does not contain $required." } +} + +$workflow = Get-Content -Raw -LiteralPath ".github/workflows/preview-smoke.yml" +foreach ($required in @("pull_request", "github.event.pull_request.number", "smoke-preview.ps1", "K3D_VERSION", "helm")) { + if ($workflow -notmatch [regex]::Escape($required)) { throw "Preview workflow does not contain $required." } +} + +$taskfile = Get-Content -Raw -LiteralPath "Taskfile.yml" +if ($taskfile -notmatch '(?m)^ preview:smoke:') { throw "Taskfile does not contain preview:smoke." } + +Write-Host "Preview structure checks passed." diff --git a/scripts/run-test-lab.ps1 b/scripts/run-test-lab.ps1 index a53dcb0..94042b0 100644 --- a/scripts/run-test-lab.ps1 +++ b/scripts/run-test-lab.ps1 @@ -15,10 +15,12 @@ function Get-LocalSetting([string]$Name) { function Get-AppNetwork { $projectName = Get-LocalSetting "COMPOSE_PROJECT_NAME" - $containerIds = @(& docker ps ` - --filter "label=com.docker.compose.project=$projectName" ` - --filter "label=com.docker.compose.service=channel-gateway" ` - --format "{{.ID}}") | Where-Object { $_ } + $containerIds = @( + (& docker ps ` + --filter "label=com.docker.compose.project=$projectName" ` + --filter "label=com.docker.compose.service=channel-gateway" ` + --format "{{.ID}}") | Where-Object { $_ } + ) if ($LASTEXITCODE -ne 0 -or $containerIds.Count -ne 1) { throw "Expected one running Channel Gateway container in Compose project $projectName." } diff --git a/scripts/smoke-preview.ps1 b/scripts/smoke-preview.ps1 new file mode 100644 index 0000000..7125d9e --- /dev/null +++ b/scripts/smoke-preview.ps1 @@ -0,0 +1,78 @@ +param( + [Parameter(Mandatory)] + [ValidateRange(1, 999999999)] + [int]$PullRequestNumber, + [string]$ClusterName = "" +) + +$ErrorActionPreference = "Stop" +$created = $false +$namespaceCreated = $false +$namespace = "portable-agent-pr-$PullRequestNumber" +if (-not $ClusterName) { $ClusterName = "pa-preview-$PullRequestNumber" } +if ($ClusterName -notmatch '^[a-z0-9][a-z0-9-]{0,39}$') { + throw "ClusterName must contain lowercase letters, numbers and hyphens only." +} + +foreach ($tool in @("docker", "k3d", "kubectl", "helm")) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + throw "$tool is not installed." + } +} + +$oldContext = (& kubectl config current-context 2>$null) +if ($LASTEXITCODE -ne 0) { $oldContext = $null } + +try { + $oldCluster = & k3d cluster list --no-headers 2>$null | Select-String "^$([regex]::Escape($ClusterName))\s" + if ($oldCluster) { throw "Cluster $ClusterName already exists and will not be changed." } + + & k3d cluster create $ClusterName --agents 1 --wait --kubeconfig-switch-context=false + if ($LASTEXITCODE -ne 0) { throw "Cannot create k3d cluster." } + $created = $true + $context = "k3d-$ClusterName" + $expiresAt = (Get-Date).ToUniversalTime().AddHours(2).ToString("yyyy-MM-ddTHH:mm:ssZ") + + & helm upgrade --install "preview-$PullRequestNumber" charts/preview ` + --kube-context $context ` + --namespace kube-system ` + --set "namespace.name=$namespace" ` + --set-string "namespace.pullRequest=$PullRequestNumber" ` + --set-string "namespace.expiresAt=$expiresAt" ` + --wait + if ($LASTEXITCODE -ne 0) { throw "Cannot install preview guardrails." } + $namespaceCreated = $true + + $environment = & kubectl --context $context get namespace $namespace ` + -o 'jsonpath={.metadata.labels.portable-agent\.io/environment}' + $pullRequest = & kubectl --context $context get namespace $namespace ` + -o 'jsonpath={.metadata.labels.portable-agent\.io/pull-request}' + if ($environment -ne "preview" -or $pullRequest -ne "$PullRequestNumber") { + throw "Preview namespace labels are invalid." + } + + foreach ($resource in @("ResourceQuota/preview-budget", "LimitRange/preview-limits", "NetworkPolicy/preview-default-deny")) { + & kubectl --context $context --namespace $namespace get $resource | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Preview guardrail is missing: $resource" } + } + + & helm upgrade --install preview-service charts/service ` + --kube-context $context ` + --namespace $namespace ` + --values charts/service/tests/smoke-values.yaml ` + --wait + if ($LASTEXITCODE -ne 0) { throw "Cannot install service chart into preview namespace." } + + & kubectl --context $context --namespace $namespace ` + rollout status deployment/smoke-service --timeout=90s + if ($LASTEXITCODE -ne 0) { throw "Preview service did not become Ready." } + + Write-Host "Preview namespace $namespace passed guardrail and deployment checks." +} +finally { + if ($namespaceCreated -and $created) { + & kubectl --context "k3d-$ClusterName" delete namespace $namespace --wait --timeout=60s | Out-Null + } + if ($created) { & k3d cluster delete $ClusterName } + if ($oldContext) { & kubectl config use-context $oldContext | Out-Null } +}