diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c0f3cb..fa43a99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,7 @@ jobs: docs: uses: portable-agent/.github/.github/workflows/reusable-docs.yml@main + security: + uses: portable-agent/.github/.github/workflows/reusable-security.yml@main + with: + trivy-ignore-file: .trivyignore.yaml diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000..a072c62 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,11 @@ +misconfigurations: + - id: GIT-0001 + paths: + - modules/github-service/main.tf + statement: Репозитории Portable Agent публичные, потому что продукт разрабатывается как open source. + expired_at: 2027-08-31 + - id: GIT-0003 + paths: + - modules/github-service/main.tf + statement: Vulnerability alerts включены отдельным ресурсом github_repository_vulnerability_alerts. + expired_at: 2027-08-31 diff --git a/README.md b/README.md index b4376d9..c750ee9 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,20 @@ Репозиторий хранит OpenTofu modules и их тесты для инфраструктуры Portable Agent. Здесь нет бизнес-кода, секретов и настроек реального production. -## Текущий пакет +## Что готово -Первый пакет проверяет маленький модуль `name`. Он строит стабильное имя ресурса: +- `modules/name` строит стабильные имена; +- `modules/environment` задаёт namespace, domain и labels окружения; +- `modules/github-service` создаёт репу, доступ команды и ruleset; +- `examples/github-service` показывает запуск без токена в файлах. ```text portable-agent-local-network ``` -Это учебный и инженерный контракт. Модуль пока не создаёт облачные ресурсы. +GitHub token передаётся провайдеру только через `GITHUB_TOKEN`. State, tfvars с реальными +значениями и секреты не коммитятся. Облачные ресурсы и production пока не создаются. +Необязательный вход `template` наполняет новую репу из GitHub template repo; адрес не зашит в модуль. ## Требования diff --git a/docs/architecture.md b/docs/architecture.md index 1f39008..482b7b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,3 +8,13 @@ test -> OpenTofu module -> output выбора реального provider. `infra` создаёт базовые ресурсы. `deploy` устанавливает приложения в уже подготовленное окружение. +# Архитектура + +```text +описание сервиса -> github-service -> repo + team + quality rules +окружение -> environment -> namespace + domain + labels +``` + +Модули маленькие и тестируются через `tofu test`. GitHub provider в тестах заменён mock provider, +поэтому pull request не создаёт реальные репозитории. Реальное применение требует отдельного +ручного шага и токена из окружения. diff --git a/docs/decisions/0002-service-factory.md b/docs/decisions/0002-service-factory.md new file mode 100644 index 0000000..3fecbf3 --- /dev/null +++ b/docs/decisions/0002-service-factory.md @@ -0,0 +1,12 @@ +# ADR 0002: фабрика репозиториев сервисов + +Статус: принято. + +Новые микросервисы создаются OpenTofu-модулем `github-service`. Модуль сразу включает squash +merge, удаление ветки, code owner review и обязательные CI checks. Токен и state не хранятся в +Git. Шаблон кода и workflows подключаются из репозитория `.github` отдельным шагом. + +Названия обязательных checks передаются явно. GitHub строит их из имени вызывающего job и job +переиспользуемого workflow, поэтому короткие значения вроде `test` могут никогда не появиться и +навсегда заблокировать merge. Для стандартного Java-шаблона это `test / Java quality gate` и +`security / Security`; для другого стека берутся точные имена из первого успешного запуска CI. diff --git a/examples/github-service/example.tfvars b/examples/github-service/example.tfvars new file mode 100644 index 0000000..6d66b98 --- /dev/null +++ b/examples/github-service/example.tfvars @@ -0,0 +1,7 @@ +owner = "your-github-org" +name = "sample-service" +description = "Короткое описание назначения нового сервиса." +team_id = "replace-with-team-id" +required_checks = ["test / Java quality gate", "security / Security"] +# После создания общей template repo можно включить: +# template = { owner = "your-github-org", repository = "service-template" } diff --git a/examples/github-service/main.tf b/examples/github-service/main.tf new file mode 100644 index 0000000..aa169c2 --- /dev/null +++ b/examples/github-service/main.tf @@ -0,0 +1,21 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 6.13" + } + } +} + +provider "github" { + owner = var.owner +} + +module "service" { + source = "../../modules/github-service" + name = var.name + description = var.description + team_id = var.team_id + required_checks = var.required_checks + template = var.template +} diff --git a/examples/github-service/variables.tf b/examples/github-service/variables.tf new file mode 100644 index 0000000..6cd75a1 --- /dev/null +++ b/examples/github-service/variables.tf @@ -0,0 +1,15 @@ +variable "owner" { type = string } +variable "name" { type = string } +variable "description" { type = string } +variable "team_id" { type = string } +variable "required_checks" { + type = list(string) + description = "Точные названия checks, которые показывает GitHub после запуска CI." +} +variable "template" { + type = object({ + owner = string + repository = string + }) + default = null +} diff --git a/mkdocs.yml b/mkdocs.yml index ec25322..65e8307 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,3 +11,4 @@ nav: - Эксплуатация: runbook.md - Решения: - Отдельный infra-репозиторий: decisions/0001-infra-repository.md + - Фабрика сервисов: decisions/0002-service-factory.md diff --git a/modules/environment/main.tf b/modules/environment/main.tf new file mode 100644 index 0000000..293c730 --- /dev/null +++ b/modules/environment/main.tf @@ -0,0 +1,9 @@ +locals { + namespace = "${var.project}-${var.name}" + domain = "${var.name}.${var.base_domain}" + labels = { + "app.kubernetes.io/part-of" = var.project + "portable-agent.io/env" = var.name + } +} + diff --git a/modules/environment/outputs.tf b/modules/environment/outputs.tf new file mode 100644 index 0000000..04f1acd --- /dev/null +++ b/modules/environment/outputs.tf @@ -0,0 +1,13 @@ +output "namespace" { + value = local.namespace + description = "Namespace окружения." +} +output "domain" { + value = local.domain + description = "Домен окружения." +} +output "labels" { + value = local.labels + description = "Обязательные Kubernetes labels." +} + diff --git a/modules/environment/tests/environment.tftest.hcl b/modules/environment/tests/environment.tftest.hcl new file mode 100644 index 0000000..b2beb8b --- /dev/null +++ b/modules/environment/tests/environment.tftest.hcl @@ -0,0 +1,17 @@ +run "build_dev_environment" { + command = plan + variables { + project = "portable-agent" + name = "dev" + base_domain = "example.test" + } + assert { + condition = output.namespace == "portable-agent-dev" + error_message = "Namespace должен быть стабильным." + } + assert { + condition = output.domain == "dev.example.test" + error_message = "Домен должен включать окружение." + } +} + diff --git a/modules/environment/variables.tf b/modules/environment/variables.tf new file mode 100644 index 0000000..37a29bb --- /dev/null +++ b/modules/environment/variables.tf @@ -0,0 +1,27 @@ +variable "project" { + type = string + description = "Короткое имя проекта." + validation { + condition = can(regex("^[a-z][a-z0-9-]+$", var.project)) + error_message = "project должен быть DNS-именем." + } +} + +variable "name" { + type = string + description = "Имя окружения: local, dev или stage." + validation { + condition = contains(["local", "dev", "stage"], var.name) + error_message = "Разрешены только local, dev и stage." + } +} + +variable "base_domain" { + type = string + description = "Базовый домен без протокола. Может быть тестовым." + validation { + condition = can(regex("^[a-z0-9.-]+$", var.base_domain)) + error_message = "base_domain содержит недопустимые символы." + } +} + diff --git a/modules/github-service/.terraform.lock.hcl b/modules/github-service/.terraform.lock.hcl new file mode 100644 index 0000000..491bec9 --- /dev/null +++ b/modules/github-service/.terraform.lock.hcl @@ -0,0 +1,36 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/integrations/github" { + version = "6.13.0" + constraints = "~> 6.13" + hashes = [ + "h1:2kD+4leuV8tBBXv+EPeehmfW6cDhIzVki61OXsGCtRI=", + "h1:99s0C+KmzXIsUJY0tKlgfcFUIuuXjCK0TAeeZ8HaOZQ=", + "h1:Mug81HyUTKKMngXMOtBxuQ8ge3dVnzt9tGcF9SxLcVE=", + "h1:RhCWa2aaFVKF/HzeR0fkIxZmoJvkGrv07hE0z09aPQs=", + "h1:YS8951MRtP4YNs2CNsDfqE7Mr9tDz/Y7xDSo18zyCkQ=", + "h1:Z0dj6yhxjLxg44gGNkh3zdAPY9iNkkiC+n7mEJcvUHY=", + "h1:a9VUv7chtxc+vro0uZo12PhBGbyeq3uKslrKLDHbkeg=", + "h1:awjLJy4zAQRONIVuKbsFSOpEWYWREdeeAXQHkhDXMDI=", + "h1:gz9DIUPAPQf0wI9dcmcNMgPBY/AwUfzKZbVnUMbAd9I=", + "h1:jPHxtaeO8mgFGGWdEmATq/BNGo1LkE6FYFgMa6Dum08=", + "h1:jXEm7QnQCF2UG4KTgDMwT2cEqezPE8a+3V0iA8N1r7k=", + "h1:jjeEBnfOJI+bV/rf1721l4B3fNO7Yws4AKf4NDdhiho=", + "h1:y0Sujto8gttV86innNp/LTMzq7CqsFpBs7XKH8AlMl4=", + "zh:0ab29fc21699f34345cf0bbbe44745fd1b143b7c73b410c1dc4abe05ffad0a84", + "zh:1aed10d06755d420bb3a893bf548ab2932297a9d094c04c5a8501e949ca186ed", + "zh:2a6a11c21eae408055f45b9533c07afd2e845f6d496fd1b645aec2e873012103", + "zh:5dd05dee677f6ebdbed00cbb1b9be444ab2d1062d345cbc9ec50a47cb41b8622", + "zh:6b757d034831243d67ddda869eac4368cef539848bd97511f4d68f1aa38a9c88", + "zh:947c9b5b238f0364c57a705beabd24d3eea3159a6f3a24c07e3fbb13657ffae0", + "zh:a676549a98164b61630658cbeb6c17820331ca04a049dc9b5095996a0c31ffbe", + "zh:a8a81b7fe41dd61eb6a6fa5e08a4dd9ee070e862868252a7fd4cfce30364efee", + "zh:c26a9bca4865665084e7f59b1402d7aff34ee63a418d7401a0658fa280cad4d4", + "zh:c638d8d0762e62ea188f86302954ef4c92803f2160f0a45fca0cd13974bd3725", + "zh:e739a0b7e81ca816944a18a38e679f4015edf8be7ac319815cdea865ba7727d7", + "zh:ec099487ea3de8999c84b3b791e242d728461e51fe344832b37bd8d521201c77", + "zh:f016ff9e2daab5b88185cec0795213049d105439ffd585d3309a714514ccae13", + "zh:fbd1fee2c9df3aa19cf8851ce134dea6e45ea01cb85695c1726670c285797e25", + ] +} diff --git a/modules/github-service/main.tf b/modules/github-service/main.tf new file mode 100644 index 0000000..21e5fc5 --- /dev/null +++ b/modules/github-service/main.tf @@ -0,0 +1,74 @@ +resource "github_repository" "service" { + name = var.name + description = var.description + visibility = var.visibility + has_issues = true + has_projects = false + has_wiki = false + allow_auto_merge = true + allow_merge_commit = false + allow_rebase_merge = false + allow_squash_merge = true + delete_branch_on_merge = true + auto_init = var.template == null + topics = sort(distinct(var.topics)) + + dynamic "template" { + for_each = var.template == null ? [] : [var.template] + content { + owner = template.value.owner + repository = template.value.repository + include_all_branches = false + } + } +} + +resource "github_repository_vulnerability_alerts" "service" { + repository = github_repository.service.name +} + +resource "github_team_repository" "maintainers" { + team_id = var.team_id + repository = github_repository.service.name + permission = "maintain" +} + +resource "github_repository_ruleset" "main" { + name = "main-quality-gate" + repository = github_repository.service.name + target = "branch" + enforcement = "active" + + conditions { + ref_name { + exclude = [] + include = ["~DEFAULT_BRANCH"] + } + } + + rules { + creation = true + deletion = true + non_fast_forward = !var.allow_force_push + required_linear_history = true + required_signatures = false + + pull_request { + dismiss_stale_reviews_on_push = true + require_code_owner_review = true + require_last_push_approval = false + required_approving_review_count = 1 + required_review_thread_resolution = true + } + + required_status_checks { + strict_required_status_checks_policy = true + dynamic "required_check" { + for_each = toset(var.required_checks) + content { + context = required_check.value + } + } + } + } +} diff --git a/modules/github-service/outputs.tf b/modules/github-service/outputs.tf new file mode 100644 index 0000000..2a4526c --- /dev/null +++ b/modules/github-service/outputs.tf @@ -0,0 +1,9 @@ +output "name" { + value = github_repository.service.name + description = "Созданный репозиторий." +} +output "url" { + value = github_repository.service.html_url + description = "URL репозитория." +} + diff --git a/modules/github-service/tests/service.tftest.hcl b/modules/github-service/tests/service.tftest.hcl new file mode 100644 index 0000000..074af22 --- /dev/null +++ b/modules/github-service/tests/service.tftest.hcl @@ -0,0 +1,29 @@ +mock_provider "github" { + mock_resource "github_repository" { + defaults = { + html_url = "https://github.com/example/sample-service" + } + } +} + +run "build_public_service" { + command = plan + variables { + name = "sample-service" + description = "Тестовый сервис для проверки фабрики." + team_id = "123" + required_checks = ["test", "security"] + } + assert { + condition = github_repository.service.visibility == "public" + error_message = "Open source репозиторий должен быть public по умолчанию." + } + assert { + condition = github_repository.service.delete_branch_on_merge + error_message = "Ветки должны удаляться после merge." + } + assert { + condition = github_team_repository.maintainers.permission == "maintain" + error_message = "Команда должна получить maintain." + } +} diff --git a/modules/github-service/variables.tf b/modules/github-service/variables.tf new file mode 100644 index 0000000..ade9a04 --- /dev/null +++ b/modules/github-service/variables.tf @@ -0,0 +1,55 @@ +variable "name" { + type = string + description = "Имя репозитория сервиса." + validation { + condition = can(regex("^[a-z][a-z0-9-]{2,62}$", var.name)) + error_message = "name должен содержать маленькие латинские буквы, цифры и дефисы." + } +} +variable "description" { + type = string + description = "Короткое публичное описание назначения сервиса." + validation { + condition = length(trimspace(var.description)) >= 10 + error_message = "description должен объяснять назначение сервиса." + } +} +variable "team_id" { + type = string + description = "ID GitHub team, которая поддерживает сервис." +} +variable "visibility" { + type = string + default = "public" + description = "Видимость репозитория." + validation { + condition = contains(["public", "private", "internal"], var.visibility) + error_message = "visibility должна быть public, private или internal." + } +} +variable "topics" { + type = list(string) + default = ["portable-agent", "microservice"] + description = "GitHub topics." +} +variable "required_checks" { + type = list(string) + description = "Точные названия обязательных GitHub checks из первого успешного запуска CI." + validation { + condition = length(var.required_checks) > 0 + error_message = "Нужен хотя бы один обязательный check." + } +} +variable "allow_force_push" { + type = bool + default = false + description = "Разрешить force push в main. По умолчанию запрещён." +} +variable "template" { + type = object({ + owner = string + repository = string + }) + default = null + description = "Необязательная GitHub template repo. Если не задана, создаётся только README." +} diff --git a/modules/github-service/versions.tf b/modules/github-service/versions.tf new file mode 100644 index 0000000..4106d91 --- /dev/null +++ b/modules/github-service/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.10.0" + required_providers { + github = { + source = "integrations/github" + version = "~> 6.13" + } + } +} +