commit dd1007d24c21c33f3fbe2836cd41a9e510c445ea Author: “Naeel” Date: Wed Jul 1 17:37:08 2026 +0400 init: registry server + operator from VM diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..18abd2e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,10 @@ +# Правила + +> Подробные правила: [`.github/pravila.md`](pravila.md) + +1. Не трогать рабочий код без явного указания. +2. Файлы редактировать локально — `~/remote_dev/` = `~/terra/` на ВМ (sshfs), SCP не нужен. +3. Все команды — **только через SSH**, никогда локально: + ```bash + ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' + ``` diff --git a/.github/pravila.md b/.github/pravila.md new file mode 100644 index 0000000..be15fff --- /dev/null +++ b/.github/pravila.md @@ -0,0 +1,56 @@ +# Правила работы агента + +## Файловая система + +`~/remote_dev/` (локально) примонтирован через sshfs к `~/terra/` на ВМ — **одна ФС**. +Файлы, сохранённые локально, мгновенно видны на ВМ. SCP не нужен. + +Монтирование может слетать. Признак: файлы рассинхронизированы. + +```bash +# Размонтировать +fusermount -u ~/remote_dev +# Если завис: sudo umount -l /home/naeel/remote_dev + +# Примонтировать +sshfs naeel@5.172.178.213:/home/naeel/terra ~/remote_dev \ + -o cache=no -o no_readahead -o reconnect \ + -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \ + -o IdentityFile=~/.ssh/naeel_vm_id_ed25519 +``` + +## SSH + +Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы. + +```bash +ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' +``` + +Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта. + +## Документация + +- `doc/thinking/` — лог рассуждений агента (обязательно) +- `doc/progress.md` — трекер задач +- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой + +## Git + +Коммитить и пушить через SSH после каждого завершённого этапа. + +Версионирование тегами: `vMAJOR.MINOR.PATCH` +- Patch — любое изменение кода +- Minor — новая фича / компонент +- Major — breaking change + +```bash +git tag vX.Y.Z && git push origin vX.Y.Z +``` + +## Поведение агента + +- Не трогать рабочий код без явного указания +- Не делать ничего сверх того, о чём явно попросили +- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов +- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b02e2bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# ── Go ───────────────────────────────────────────────────────────────────────── +bin/ +dist/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ +go.sum.bak + +# Coverage / profiling +coverage.out +coverage.html +*.prof +*.pprof + +# ── Helm ─────────────────────────────────────────────────────────────────────── +charts/*.tgz +charts/Chart.lock +charts/charts/ + +# ── Kubernetes / secrets ─────────────────────────────────────────────────────── +*secret*.yaml +*secret*.yml +*secrets*.yaml +*secrets*.yml +kubeconfig +kubeconfig.* +*.kubeconfig +.kube/ + +# ── GPG / crypto keys ────────────────────────────────────────────────────────── +*.asc +*.gpg +*.pgp +*.pem +*.key +*.p12 +*.pfx +server/gpg/ +registrykeys/*.key +private_key* + +# ── Build artefacts ──────────────────────────────────────────────────────────── +artifacts/ +build/output/ +*.har +HAR/ + +# ── IDE ──────────────────────────────────────────────────────────────────────── +.idea/ +*.iml +.vscode/ +*.swp +*.swo +*~ + +# ── OS ───────────────────────────────────────────────────────────────────────── +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db +Desktop.ini + +# ── Misc ─────────────────────────────────────────────────────────────────────── +*.log +*.tmp +.env +.env.* +!.env.example diff --git a/.kube-iot-naeel b/.kube-iot-naeel new file mode 100644 index 0000000..cfe5e72 --- /dev/null +++ b/.kube-iot-naeel @@ -0,0 +1,18 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM2akNDQWRLZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJMk1EUXdOekE0TURRME0xb1hEVE0yTURRd05EQTRNRGswTTFvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTUZECm5ZQjRQaHBxQWZ2cU9uTlMzYXp2b1hzc29uRTlYTHQ0Z2xXM216RHpEWHU4NVZlRGp2a2phUHRnTFcvaXhnN2wKKzN3VDZ0YXpHUkxSNDNrcE00ZHA2czNXYkh5blVuZHRMVHhNaVBrbXZNMk0rVVdUaHpCTGc1VHRHUXhKaTRvVwpnZTNMTy9lS1RYNjhYTTl0Y3lsc0JoM2ZLTFVaL3c3OHNaelM0QVB4WWh4aVV4My9YcnM0YVNTdXk4aS9OWGVpCnIySlBWMnI5VzM4OEhPQzRnM0xWM2RLeEdKdW14dVdXcC9wRTUvWmo2Rk9XRExGTFBnV1Z2T3pTRFkvN2RtSHIKQjZ5REFzd1VpM01ob091ckVvSlNZYTNib3FJL0JGZklFSW9sNFVETlVqYVMvTnMrUks5MWpyU1dsOHVEMmhWSgp5WXFPc2dnZklKTi9xTWNsS0k4Q0F3RUFBYU5GTUVNd0RnWURWUjBQQVFIL0JBUURBZ0trTUJJR0ExVWRFd0VCCi93UUlNQVlCQWY4Q0FRQXdIUVlEVlIwT0JCWUVGSTZHUnBYTVkyMjJ6d1ltT1lNWXN5TmhLVm5ITUEwR0NTcUcKU0liM0RRRUJDd1VBQTRJQkFRQ1ZiK2kwa1hrRjBwSlBiREJCdXJHc0licFFyMFBuL2EvUWtKRVlmbGVqS0c2WAozL2d0d3dOL0lxY1NMRzMyU2JBbHZ6c2xrbmY1c1pBQmhibWJRWHIvQ2JybjRiZWVxTExyYUxQS3JuUVd2ZVZHCkN1a0ViV2dKU3EyS1BIaStKS0JPZ0RPT1FGa1ZZZlR3ek5JYjI5eTRqU1o1R3dRL0xiZU1NaEQ0ZUV6c1lYS0QKWWF6S0JmaGZsZWVKT0VDZWlWTXMrUDRPMC81TEV1ZVNWVkZwYndIeCtRSncyVDlZUGJ1dEkvMGoxQjNKT3lxbQptRG9JN1RIM3NWdVorNTRHT3kwRXlhQUdiQmVETmduQlRxM0djdUJBbjJSNDErMzJzdHk5V0lIb1RvNnhZZ0lsCjBUaVI5N3lDZ25rejZlNFE3UTFCT29Ib0JFNmVFNk1YSkJNUFJvTHEKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://185.247.187.149:6443 + name: iot-naeel +contexts: +- context: + cluster: iot-naeel + user: tazetdinovn@gmail.com + name: tazetdinovn@gmail.com@iot-naeel +current-context: tazetdinovn@gmail.com@iot-naeel +kind: Config +preferences: {} +users: +- name: tazetdinovn@gmail.com + user: + token: eyJhbGciOiJSUzI1NiIsImtpZCI6InNoIiwidHlwIjoiSldUIn0.eyJhdWQiOiJiYWNrZW5kIiwiZXhwIjoxNzc1OTAwNzI4LCJqdGkiOiIwOWMwZjJmOC02MDcyLTRkMTctOWEzNC1lZTQ4MzMzMjE3ODUiLCJpYXQiOjE3NzU4MTQzMjgsImlzcyI6Imh0dHBzOi8vYXV0aC5rOHMubmdjbG91ZC5ydSIsInN1YiI6InRhemV0ZGlub3ZuQGdtYWlsLmNvbSIsImNsYWltcyI6WyIiLCIiLCJ3ejAzNzA5LXNodHVydmFsLWFkbWluLWlvdC1uYWVlbCIsInd6MDM3MDktc2h0dXJ2YWwtYWRtaW4tbmFlZWwtdGVzdC0zIl0sIm5hbWUiOiLQndCw0LjQu9GMINCi0LDQt9C10YLQtNC40L3QvtCyIiwiZW1haWwiOiJ0YXpldGRpbm92bkBnbWFpbC5jb20iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0YXpldGRpbm92bkBnbWFpbC5jb20ifQ.v14knWeokzb7-MegHj2XY8X20Z9eEcEhQA2MBBZNTX_eHtctC3lOAk7RU8VgBkyr_yX-kpaw9gW1k0GPmDoOZg-sH8brmjfHTIahEUN0ui3cUOq-KN7yTAof3ZfZWVRaVvScDD_cDI90NFEeNhO5uPZs7zvVSpKflKQQJlZNbH69gjdRvScpDp0m0BTuhRxMoYyVhTxP2RfJdYcT6179xyfbevUQkC8bkX1fOXrnD7mkLXpXG0v0ClYvFaq8VNY9_MejhEoA4P01lEMbl8NkDw04JhhSjHJ2ZXViwswYsit5T8JYezhFrKQ1HeDnkRkrh5fwsoii_9Lm4PHUGPDYEw diff --git a/.kube-wheel b/.kube-wheel new file mode 100644 index 0000000..9038a11 --- /dev/null +++ b/.kube-wheel @@ -0,0 +1,18 @@ +apiVersion: v1 +clusters: +- cluster: + insecure-skip-tls-verify: true + server: https://5.172.178.181:6443 + name: wheel +contexts: +- context: + cluster: wheel + user: tazet@narod.ru + name: tazet@narod.ru@wheel +current-context: tazet@narod.ru@wheel +kind: Config +preferences: {} +users: +- name: tazet@narod.ru + user: + token: eyJhbGciOiJSUzI1NiIsImtpZCI6InNoIiwidHlwIjoiSldUIn0.eyJhdWQiOiJiYWNrZW5kIiwiZXhwIjoxNzc1ODk5ODc2LCJqdGkiOiI5NjE4Yzg4NC05NjQ5LTQ1NjQtYjNjMi03Njc0Zjg5ZDAzMDciLCJpYXQiOjE3NzU4MTM0NzYsImlzcyI6Imh0dHBzOi8vYXV0aC5rOHMubmdjbG91ZC5ydSIsInN1YiI6InRhemV0QG5hcm9kLnJ1IiwiY2xhaW1zIjpbIiIsIiIsIiIsIjU1YzQ5MTRlLTdiYzUtNGJjYy1iYzcxLTI0NDQwMjViYzZmY19BRE1JTiIsIldaMDEzMjUtc2h0dXJ2YWwtYWRtaW4tdGVzdGVyIiwiV1owMTMyNS1zaHR1cnZhbC1hZG1pbi13aGVlbCJdLCJuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIg0J3QsNC40LvRjCIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0YXpldEBuYXJvZC5ydSJ9.GiPjJonDXxxu4U6QE4LVPc7rMV8I_0Ubr8dugNo8g4HeCwh5Y0MtWYsPhQ4GOB2A2Fy_43ItVIlMypmG9ZwVzhaRxfmNIAuNlSV-N49x_C9zGqIOagL2Us6UVr30XCYdfgcSiN2fQGx5F-LYxCMOyePA_vxyAl8dYFKT2lZmH4poOxJjgkwclZ9tej7NKcfFy31PmJu26li6LE_8TzaNN130woO0Rat3bvHfHRLk_eKj-0iAlXD5a_rt3QEli2wyhxi7DZo5ohaJ0PjhNIrdqkghBLM0KjUERRHXVyhSBUEWjsz8OAXwItmaMb6iOH_Wv50vPPLmZ_KVm8FdkQD1Qg diff --git a/Dockerfile.registry-fix b/Dockerfile.registry-fix new file mode 100644 index 0000000..3867917 --- /dev/null +++ b/Dockerfile.registry-fix @@ -0,0 +1,14 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /app +COPY operator/go.mod operator/go.sum ./ +RUN go mod download +COPY operator/cmd/registry ./cmd/registry +COPY registrykeys ./registrykeys +RUN CGO_ENABLED=0 GOOS=linux go build -o /registry-server ./cmd/registry +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /registry-server /app/registry-server +RUN apk add --no-cache ca-certificates +USER 1000:1000 +EXPOSE 8080 +CMD ["/app/registry-server"] diff --git a/REGISTRY_OVERVIEW.md b/REGISTRY_OVERVIEW.md new file mode 100644 index 0000000..50a1ce1 --- /dev/null +++ b/REGISTRY_OVERVIEW.md @@ -0,0 +1,198 @@ +# Terraform Registry — Полное описание + +> Этот документ — точка входа для нового чата/агента. Содержит всё необходимое для понимания, сопровождения и доработки реестра провайдера. + +--- + +## Что это такое + +Кастомный Terraform Registry Server для провайдера `nubes` (облако Nubes / ngcloud.ru). + +Когда разработчик пишет в своём `.tf`: +```hcl +terraform { + required_providers { + nubes = { + source = "terra.k8c.ru/nubes/nubes" + version = "5.0.18" + } + } +} +``` +...и выполняет `terraform init`, Terraform идёт к **этому** серверу за бинарниками провайдера. + +--- + +## Структура папки + +``` +REGISTRY/ +├── server/ — Go-код HTTP-сервера реестра (АКТУАЛЬНЫЙ) +├── operator/ — Kubernetes Operator (CRD TerraformProviderRelease) + старая копия server-кода в cmd/registry/ +├── k8s/ — Kubernetes манифесты деплоя (namespace, ingress, deployment, service, TLS) +├── registrykeys/ — Go-модуль с публичным GPG-ключом (для верификации провайдера) +├── charts/ — Helm chart для деплоя registry-server +└── scripts/ + └── build-and-push-registry-server.sh — сборка Docker-образа и push в registry +``` + +**Важно:** `server/` и `operator/cmd/registry/` — дублирующийся код. Актуальный и полный — только `server/main.go`. В `operator/cmd/registry/main.go` нет health-эндпоинтов и не удалены darwin-платформы. При правках менять только `server/`. + +--- + +## Как работает registry-server + +### Реализованный протокол + +[Terraform Registry Protocol v1](https://developer.hashicorp.com/terraform/internals/provider-registry-protocol). + +| Эндпоинт | Назначение | +|---|---| +| `GET /.well-known/terraform.json` | Discovery — говорит Terraform, где API реестра | +| `GET /v1/providers/{ns}/{name}/versions` | Список доступных версий и платформ | +| `GET /v1/providers/{ns}/{name}/{version}/download/{os}/{arch}` | URL для скачивания бинарника | +| `GET /v1/proxy?url=...` | Проксирование файлов из S3 (бинарники, SHA, GPG-сигнатура) | +| `GET /docs/{ns}/{name}/{version}/...` | Документация провайдера (статический HTML из S3) | +| `GET /healthz` | Health probe для Kubernetes | +| `GET /readyz` | Readiness probe для Kubernetes | + +### Как определяются версии + +Сервер листит S3 bucket в поиске файлов вида: +``` +////terraform-provider-__linux_amd64.zip +``` +Из имён файлов парсит версию и платформы. Поддерживаемые платформы: `linux_amd64`, `linux_arm64`, `windows_amd64`. + +### Переменные окружения + +| Переменная | Default | Назначение | +|---|---|---| +| `REGISTRY_HOSTNAME` | `localhost:8080` | Домен реестра (используется в URL ответов) | +| `S3_ENDPOINT` | — | Хост S3 без схемы, например `s3.msk-1.ngcloud.ru` | +| `S3_ACCESS_KEY` | — | S3 access key | +| `S3_SECRET_KEY` | — | S3 secret key | +| `S3_BUCKET` | `terraform-registry` | Имя бакета | +| `S3_USE_SSL` | — | Если `true` — HTTPS (в коде Secure: true захардкожено) | + +### S3 структура бакета + +``` +terraform-registry/ ← бакет +└── terra.k8c.ru/ + └── nubes/ + └── nubes/ + └── 5.0.18/ + ├── terraform-provider-nubes_5.0.18_linux_amd64.zip + ├── terraform-provider-nubes_5.0.18_windows_amd64.zip + ├── terraform-provider-nubes_5.0.18_SHA256SUMS + └── terraform-provider-nubes_5.0.18_SHA256SUMS.sig +docs/ +└── nubes/ + └── nubes/ + └── 5.0.18/ + └── index.html (и другие файлы документации) +``` + +--- + +## GPG-ключ + +Terraform верифицирует подпись провайдера через GPG. + +- Публичный ключ **встроен** в `server/main.go` (ASCII Armor, константа в коде). +- Также хранится в `registrykeys/public_key.go` (отдельный go-модуль). +- Приватный ключ — `secrets/private_key.asc` (НЕ в репо, gitignore). + +**При ротации ключей:** +1. Сгенерировать новую пару `gpg --gen-key` +2. Обновить ASCII Armor в `server/main.go` +3. Пересобрать и передеплоить Docker-образ +4. Перезалить все артефакты провайдера, подписанные новым ключом + +--- + +## Деплой в Kubernetes + +### Кластер и namespace + +- Namespace: `terra` +- Домен: `terra.k8c.ru` (планируемая миграция → `registry.nubes.ru`, см. `../docs/ops/DOMAIN_MIGRATION.md`) +- TLS: cert-manager с `letsencrypt-prod` issuer — **НЕ ТРОГАТЬ** сертификат руками! + +### Что задеплоено + +| Ресурс | Kind | Образ | +|---|---|---| +| `registry-server` | Deployment | `naeel/terraform-registry-server:gpg-key-20260208` | +| `registry-server` | Service | ClusterIP :8080 | +| `registry-ingress` | Ingress | host: `terra.k8c.ru` | + +Credentials S3 — из секрета `s3-credentials` (ключи `access-key`, `secret-key`). + +### Применение манифестов (только при смене конфигурации кластера) + +```bash +kubectl apply -f k8s/operator/manifests/00-namespace.yaml +kubectl apply -f k8s/operator/manifests/00-rbac.yaml +kubectl apply -f k8s/operator/manifests/02-crd.yaml +kubectl apply -f k8s/operator/manifests/05-registry-server.yaml +``` + +### Диагностика (read-only) + +```bash +kubectl -n terra get pods,svc,ingress,deploy +kubectl -n terra logs -l app=registry-server --tail=50 +kubectl -n terra get certificate registry-tls -o yaml +``` + +--- + +## Сборка и публикация нового образа + +Скрипт: `scripts/build-and-push-registry-server.sh` + +```bash +cd REGISTRY +./scripts/build-and-push-registry-server.sh +``` + +Собирает Docker-образ из `server/` и пушит в Docker Hub (`naeel/terraform-registry-server:`). + +После push нужно обновить тег образа в `operator/manifests/05-registry-server.yaml` и применить `kubectl apply`. + +--- + +## Когда registry НЕ нужно трогать + +Registry — stateless сервис. При выпуске новой версии провайдера (`nubes`) registry не меняется: + +``` +Новая версия провайдера: + 1. go build → бинарники + 2. devops/03_build_and_upload_provider.sh → бинарники в S3 + 3. devops/04_build_and_publish_docs.sh → документация в S3 + ✅ Registry автоматически начинает отдавать новую версию + ❌ kubectl не нужен + ❌ код registry не меняется + ❌ Docker-образ не пересобирается +``` + +Registry пересобирается только если меняется **сам сервер**: новые эндпоинты, логика, ротация GPG-ключа. + +--- + +## Связь с основным репо провайдера + +Этот код был перемещён из основного репо `terraform/` в папку `REGISTRY/` для последующего выноса в отдельный репозиторий. Код провайдера и код реестра полностью независимы — нет общих go-модулей, нет импортов друг друга. + +--- + +## Ограничения и правила работы + +- **Ручные правки `operator/cmd/registry/`** — не делать, это устаревшая копия. Актуальный код — только `server/`. +- **Let's Encrypt cert-manager** (`letsencrypt-prod`) — не применять `kubectl apply` на Ingress-ресурсы без именённой необходимости (риск бана от CA). +- **docker build** — только с явного разрешения разработчика. +- **kubectl** — только read-only (`get`, `describe`, `logs`) без разрешения. +- **S3 credentials** — никогда не коммитить в репо. Только через секреты k8s или переменные окружения. diff --git a/charts/Chart.yaml b/charts/Chart.yaml new file mode 100644 index 0000000..c80a9e6 --- /dev/null +++ b/charts/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: terraform-registry +description: Helm chart for Terraform Provider Registry (registry-server + operator) +type: application +version: 0.1.0 +appVersion: "1.0.0" +keywords: + - terraform + - registry + - nubes +home: https://terra.k8c.ru +maintainers: + - name: Naeel diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl new file mode 100644 index 0000000..28c25bb --- /dev/null +++ b/charts/templates/_helpers.tpl @@ -0,0 +1,42 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "terraform-registry.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "terraform-registry.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Create chart label. +*/}} +{{- define "terraform-registry.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "terraform-registry.labels" -}} +helm.sh/chart: {{ include "terraform-registry.chart" . }} +{{ include "terraform-registry.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "terraform-registry.selectorLabels" -}} +app.kubernetes.io/name: {{ include "terraform-registry.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/charts/templates/build-script-configmap.yaml b/charts/templates/build-script-configmap.yaml new file mode 100644 index 0000000..2c2e982 --- /dev/null +++ b/charts/templates/build-script-configmap.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: builder-script + namespace: {{ .Values.global.namespace }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +data: + build.sh: | + #!/bin/sh + set -e + + echo ">>> Starting Build for $PROVIDER_NAME $VERSION" + + # 1. Setup Environment + # Inputs: GIT_REPO, GIT_REF, PROVIDER_NAME, VERSION + WORK_DIR="/workspace" + ARTIFACTS_DIR="/artifacts" + mkdir -p $WORK_DIR $ARTIFACTS_DIR + + # 2. Clone Repository + echo ">>> Cloning $GIT_REPO ($GIT_REF)..." + git clone $GIT_REPO $WORK_DIR/src + cd $WORK_DIR/src + git checkout $GIT_REF + + # 3. Build for Platforms + echo ">>> Building binaries..." + + # Linux amd64 + echo "--> linux/amd64" + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o terraform-provider-${PROVIDER_NAME}_v${VERSION} + zip ${ARTIFACTS_DIR}/terraform-provider-${PROVIDER_NAME}_${VERSION}_linux_amd64.zip terraform-provider-${PROVIDER_NAME}_v${VERSION} + rm terraform-provider-${PROVIDER_NAME}_v${VERSION} + + # Windows amd64 + echo "--> windows/amd64" + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o terraform-provider-${PROVIDER_NAME}_v${VERSION}.exe + zip ${ARTIFACTS_DIR}/terraform-provider-${PROVIDER_NAME}_${VERSION}_windows_amd64.zip terraform-provider-${PROVIDER_NAME}_v${VERSION}.exe + rm terraform-provider-${PROVIDER_NAME}_v${VERSION}.exe + + # 4. Checksums + cd $ARTIFACTS_DIR + sha256sum *.zip > terraform-provider-${PROVIDER_NAME}_${VERSION}_SHA256SUMS + + # 5. GPG Sign + touch terraform-provider-${PROVIDER_NAME}_${VERSION}_SHA256SUMS.sig + + # 6. Upload to S3 + if [ -z "$REGISTRY_HOSTNAME" ]; then + REGISTRY_HOSTNAME="{{ .Values.global.registryHostname }}" + fi + + TARGET_PATH="terraform-providers/$REGISTRY_HOSTNAME/${NAMESPACE}/${PROVIDER_NAME}/${VERSION}" + + echo ">>> Uploading to S3 path: $TARGET_PATH" + + mc alias set nubes_s3 https://$S3_ENDPOINT $S3_ACCESS_KEY $S3_SECRET_KEY + mc cp --recursive $ARTIFACTS_DIR/ nubes_s3/$TARGET_PATH/ + + echo ">>> Build Complete!" diff --git a/charts/templates/crd.yaml b/charts/templates/crd.yaml new file mode 100644 index 0000000..e3f473a --- /dev/null +++ b/charts/templates/crd.yaml @@ -0,0 +1,79 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: providerreleases.terra.core.nubes.ru + annotations: + app.kubernetes.io/managed-by: Helm +spec: + group: terra.core.nubes.ru + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: + - providerName + - version + - gitRepo + - gitRef + properties: + providerName: + type: string + description: "Name of the provider (e.g. 'mycloud')" + namespace: + type: string + default: "mycloud" + description: "Terraform registry namespace (e.g. 'nubes')" + version: + type: string + description: "Semantic version without v (e.g. '1.0.1')" + gitRepo: + type: string + description: "Git repository URL to clone" + gitRef: + type: string + description: "Git tag, commit SHA or branch" + platforms: + type: array + items: + type: string + default: ["linux_amd64", "windows_amd64"] + description: "Target platforms for build" + status: + type: object + properties: + phase: + type: string + description: "Current phase: Pending, Building, Publishing, Ready, Failed" + downloadUrl: + type: string + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + lastTransitionTime: + type: string + format: date-time + reason: + type: string + message: + type: string + scope: Namespaced + names: + plural: providerreleases + singular: providerrelease + kind: TerraformProviderRelease + shortNames: + - tpr diff --git a/charts/templates/docs-server-deployment.yaml b/charts/templates/docs-server-deployment.yaml new file mode 100644 index 0000000..68d3f67 --- /dev/null +++ b/charts/templates/docs-server-deployment.yaml @@ -0,0 +1,52 @@ +{{- if .Values.docs.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry-server-docs + namespace: {{ .Values.global.namespace }} + labels: + app: registry-server-docs + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: registry-server-docs + template: + metadata: + labels: + app: registry-server-docs + spec: + containers: + - name: registry-server + image: "{{ .Values.docs.image.repository }}:{{ .Values.docs.image.tag }}" + imagePullPolicy: {{ .Values.docs.image.pullPolicy }} + env: + - name: REGISTRY_HOSTNAME + value: {{ .Values.global.registryHostname | quote }} + - name: S3_ENDPOINT + value: {{ .Values.s3.endpoint | quote }} + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.s3.existingSecret }} + key: {{ .Values.s3.accessKeyField }} + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.s3.existingSecret }} + key: {{ .Values.s3.secretKeyField }} + - name: S3_BUCKET + value: {{ .Values.s3.bucket | quote }} + - name: S3_USE_SSL + value: {{ .Values.s3.useSSL | quote }} + ports: + - containerPort: 8080 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 128Mi +{{- end }} diff --git a/charts/templates/ingress.yaml b/charts/templates/ingress.yaml new file mode 100644 index 0000000..e6b48b0 --- /dev/null +++ b/charts/templates/ingress.yaml @@ -0,0 +1,37 @@ +{{- if .Values.ingress.enabled }} +# WARNING: Do NOT apply to existing clusters without confirming TLS issuer. +# LetsEncrypt rate limits apply. For testing, use a self-signed issuer. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: registry-ingress + namespace: {{ .Values.global.namespace }} + annotations: + {{- if .Values.ingress.tls.enabled }} + cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.issuer | quote }} + {{- end }} + {{- range $key, $value := .Values.ingress.annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + ingressClassName: {{ .Values.ingress.className | quote }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host | quote }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: registry-server + port: + number: {{ .Values.registry.service.port }} +{{- end }} diff --git a/charts/templates/namespace.yaml b/charts/templates/namespace.yaml new file mode 100644 index 0000000..3e84644 --- /dev/null +++ b/charts/templates/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.global.namespace }} + labels: + name: {{ .Values.global.namespace }} + {{- include "terraform-registry.labels" . | nindent 4 }} diff --git a/charts/templates/networkpolicy.yaml b/charts/templates/networkpolicy.yaml new file mode 100644 index 0000000..76d8967 --- /dev/null +++ b/charts/templates/networkpolicy.yaml @@ -0,0 +1,31 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "terraform-registry.fullname" . }}-netpol + namespace: {{ .Values.global.namespace }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ports: + - port: {{ .Values.registry.service.targetPort }} + protocol: TCP + egress: + - to: [] + ports: + - port: 443 # S3, Vault + protocol: TCP + - port: 53 # DNS + protocol: UDP + - port: 53 + protocol: TCP +{{- end }} diff --git a/charts/templates/operator-deployment.yaml b/charts/templates/operator-deployment.yaml new file mode 100644 index 0000000..4f7cd21 --- /dev/null +++ b/charts/templates/operator-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: terraform-operator + namespace: {{ .Values.global.namespace }} + labels: + app: terraform-operator + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.operator.replicas }} + selector: + matchLabels: + app: terraform-operator + template: + metadata: + labels: + app: terraform-operator + spec: + serviceAccountName: terraform-operator + containers: + - name: operator + image: "{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag }}" + imagePullPolicy: IfNotPresent + env: + - name: REGISTRY_HOSTNAME + value: {{ .Values.global.registryHostname | quote }} + resources: + requests: + cpu: {{ .Values.operator.resources.requests.cpu }} + memory: {{ .Values.operator.resources.requests.memory }} + limits: + cpu: {{ .Values.operator.resources.limits.cpu }} + memory: {{ .Values.operator.resources.limits.memory }} diff --git a/charts/templates/pdb.yaml b/charts/templates/pdb.yaml new file mode 100644 index 0000000..93a19f8 --- /dev/null +++ b/charts/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if .Values.pdb.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "terraform-registry.fullname" . }}-registry + namespace: {{ .Values.global.namespace }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.pdb.minAvailable }} + selector: + matchLabels: + app: registry-server +{{- end }} diff --git a/charts/templates/rbac.yaml b/charts/templates/rbac.yaml new file mode 100644 index 0000000..0c317b2 --- /dev/null +++ b/charts/templates/rbac.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: terraform-operator + namespace: {{ .Values.global.namespace }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: terraform-operator + namespace: {{ .Values.global.namespace }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +rules: +- apiGroups: ["terra.core.nubes.ru"] + resources: ["providerreleases", "providerreleases/status"] + verbs: ["get", "list", "watch", "update", "patch"] +- apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create", "get", "list", "watch", "delete"] +- apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: terraform-operator + namespace: {{ .Values.global.namespace }} + labels: + {{- include "terraform-registry.labels" . | nindent 4 }} +subjects: +- kind: ServiceAccount + name: terraform-operator + namespace: {{ .Values.global.namespace }} +roleRef: + kind: Role + name: terraform-operator + apiGroup: rbac.authorization.k8s.io diff --git a/charts/templates/registry-server-deployment.yaml b/charts/templates/registry-server-deployment.yaml new file mode 100644 index 0000000..8f5fcaf --- /dev/null +++ b/charts/templates/registry-server-deployment.yaml @@ -0,0 +1,64 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry-server + namespace: {{ .Values.global.namespace }} + labels: + app: registry-server + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.registry.replicas }} + selector: + matchLabels: + app: registry-server + template: + metadata: + labels: + app: registry-server + spec: + containers: + - name: registry-server + image: "{{ .Values.registry.image.repository }}:{{ .Values.registry.image.tag }}" + imagePullPolicy: {{ .Values.registry.image.pullPolicy }} + ports: + - containerPort: {{ .Values.registry.service.targetPort }} + env: + - name: REGISTRY_HOSTNAME + value: {{ .Values.global.registryHostname | quote }} + - name: S3_ENDPOINT + value: {{ .Values.s3.endpoint | quote }} + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.s3.existingSecret }} + key: {{ .Values.s3.accessKeyField }} + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.s3.existingSecret }} + key: {{ .Values.s3.secretKeyField }} + - name: S3_BUCKET + value: {{ .Values.s3.bucket | quote }} + - name: S3_USE_SSL + value: {{ .Values.s3.useSSL | quote }} + resources: + requests: + cpu: {{ .Values.registry.resources.requests.cpu }} + memory: {{ .Values.registry.resources.requests.memory }} + limits: + cpu: {{ .Values.registry.resources.limits.cpu }} + memory: {{ .Values.registry.resources.limits.memory }} + {{- if .Values.registry.healthcheck.enabled }} + livenessProbe: + httpGet: + path: {{ .Values.registry.healthcheck.path }} + port: {{ .Values.registry.healthcheck.port }} + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: {{ .Values.registry.healthcheck.path }} + port: {{ .Values.registry.healthcheck.port }} + initialDelaySeconds: 5 + periodSeconds: 10 + {{- end }} diff --git a/charts/templates/registry-server-service.yaml b/charts/templates/registry-server-service.yaml new file mode 100644 index 0000000..a8253a7 --- /dev/null +++ b/charts/templates/registry-server-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: registry-server + namespace: {{ .Values.global.namespace }} + labels: + app: registry-server + {{- include "terraform-registry.labels" . | nindent 4 }} +spec: + selector: + app: registry-server + ports: + - port: {{ .Values.registry.service.port }} + targetPort: {{ .Values.registry.service.targetPort }} diff --git a/charts/values-dev.yaml b/charts/values-dev.yaml new file mode 100644 index 0000000..81ba511 --- /dev/null +++ b/charts/values-dev.yaml @@ -0,0 +1,6 @@ +global: + registryHostname: "terra.k8c.ru" +registry: + replicas: 1 +pdb: + enabled: false diff --git a/charts/values-prod.yaml b/charts/values-prod.yaml new file mode 100644 index 0000000..c7e8062 --- /dev/null +++ b/charts/values-prod.yaml @@ -0,0 +1,14 @@ +global: + registryHostname: "registry.nubes.ru" # Целевой домен +registry: + image: + repository: "pearlharbor.registryk8s.services.ngcloud.ru/terraform/registry-server" + replicas: 2 +operator: + image: + repository: "pearlharbor.registryk8s.services.ngcloud.ru/terraform/registry-operator" +pdb: + enabled: true + minAvailable: 1 +networkPolicy: + enabled: true diff --git a/charts/values.yaml b/charts/values.yaml new file mode 100644 index 0000000..3892218 --- /dev/null +++ b/charts/values.yaml @@ -0,0 +1,71 @@ +global: + registryHostname: "terra.k8c.ru" # Переопределяется при миграции + namespace: "terraform-registry" + +registry: + image: + repository: "naeel/terraform-registry-server" # → Harbor при миграции + tag: "latest" + pullPolicy: IfNotPresent + replicas: 1 # → 2 в prod + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + service: + port: 80 + targetPort: 8080 + healthcheck: + enabled: true + path: /healthz + port: 8080 + +operator: + image: + repository: "naeel/terraform-registry-operator" + tag: "latest" + replicas: 1 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 128Mi + +docs: + enabled: false + image: + repository: "naeel/terraform-registry-server" + tag: "docs-dev-fixed" + pullPolicy: IfNotPresent + +ingress: + enabled: true + className: "nginx" + host: "terra.k8c.ru" # Переопределяется + tls: + enabled: true + issuer: "letsencrypt-prod" # НЕ ТРОГАТЬ LetsEncrypt issuer! + secretName: "registry-tls" + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "100m" + +s3: + endpoint: "s3.msk-1.ngcloud.ru" + bucket: "terraform-registry" + useSSL: true + # credentials через existingSecret + existingSecret: "s3-credentials" + accessKeyField: "access-key" + secretKeyField: "secret-key" + +pdb: + enabled: false # → true в prod + minAvailable: 1 + +networkPolicy: + enabled: false # → true при миграции diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..3e63ea1 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,30 @@ +# k8s 🔧 + +Кратко: Kubernetes‑манифесты для namespace `terra` — развёртывание `registry-server`, `terraform-operator`, Ingress, TLS и связанные ресурсы (S3, CRD, builder ConfigMap). + +## Важные файлы +- `00-namespace.yaml` — namespace `terra` +- `00-rbac.yaml` — RBAC для оператора +- `02-crd.yaml` — CRD для операторов (ProviderRelease) +- `03-build-script.yaml` — ConfigMap для сборщика Job'ов +- `04-operator-deployment.yaml` — Deployment для оператора +- `05-registry-server.yaml`, `06-registry-server-docs.yaml` — Deployment, Service и Ingress для `registry-server` + +## Как перенести на новый домен (коротко) ✅ +1. **Ingress**: в манифесте (обычно `05-*` или `registry-ingress`) заменить `host: terra.k8c.ru` на новый домен и убедиться в корректной аннотации `cert-manager.io/cluster-issuer` (или задать другой `secretName`). +2. **Certificate**: cert-manager автоматически выпустит/обновит TLS; проверьте `kubectl -n terra get certificate registry-tls -o yaml` и дождитесь `status.conditions[*].type == Ready`. +3. **Deployment env**: заменить `REGISTRY_HOSTNAME` в `04-operator-deployment.yaml` и в `registry-server` на новый домен. +4. **DNS**: добавить A/CNAME запись для нового домена, указывающую на IP балансировщика (`kubectl -n terra get ingress registry-ingress -o jsonpath='{.status.loadBalancer.ingress[0].ip}'`). +5. **Secrets & storage**: используйте `s3-credentials` (S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY) и убедитесь в доступности S3. +6. **Apply & Verify**: + - `kubectl apply -f manifests/` + - `kubectl -n terra describe ingress registry-ingress` + - `kubectl -n terra get certificate registry-tls` + - `curl -vk https:///docs////` + +## Проверки (read-only) +- `kubectl -n terra get ingress,svc,deploy,po,secret` +- `kubectl -n terra get certificate registry-tls -o yaml` +- `kubectl -n terra logs ` + +> Примечание: это инструкция для операции вручную/CI; я ничего не меняю в кластере автоматически. \ No newline at end of file diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml new file mode 100644 index 0000000..a51fc96 --- /dev/null +++ b/k8s/base/deployment.yaml @@ -0,0 +1,19 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deployment +spec: + replicas: 1 + selector: + matchLabels: + app: my-app + template: + metadata: + labels: + app: my-app + spec: + containers: + - name: my-container + image: my-image:latest + ports: + - containerPort: 80 diff --git a/k8s/base/ingress.yaml b/k8s/base/ingress.yaml new file mode 100644 index 0000000..a11eb92 --- /dev/null +++ b/k8s/base/ingress.yaml @@ -0,0 +1,16 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-ingress +spec: + rules: + - host: example.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-service + port: + number: 80 diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..94bdff3 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/k8s/base/service.yaml b/k8s/base/service.yaml new file mode 100644 index 0000000..979f93e --- /dev/null +++ b/k8s/base/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + selector: + app: my-app + ports: + - protocol: TCP + port: 80 + targetPort: 80 diff --git a/k8s/overlays/dev/clusterissuer-selfsigned-dev.yaml b/k8s/overlays/dev/clusterissuer-selfsigned-dev.yaml new file mode 100644 index 0000000..ed747d2 --- /dev/null +++ b/k8s/overlays/dev/clusterissuer-selfsigned-dev.yaml @@ -0,0 +1,6 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: selfsigned-dev +spec: + selfSigned: {} diff --git a/k8s/overlays/dev/clusterissuer-staging-dev.yaml b/k8s/overlays/dev/clusterissuer-staging-dev.yaml new file mode 100644 index 0000000..9dc1d03 --- /dev/null +++ b/k8s/overlays/dev/clusterissuer-staging-dev.yaml @@ -0,0 +1,14 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-staging-dev +spec: + acme: + server: https://acme-staging-v02.api.letsencrypt.org/directory + email: devops@example.com + privateKeySecretRef: + name: letsencrypt-staging-dev + solvers: + - http01: + ingress: + class: nginx diff --git a/k8s/overlays/dev/image-patch.yaml b/k8s/overlays/dev/image-patch.yaml new file mode 100644 index 0000000..92179bc --- /dev/null +++ b/k8s/overlays/dev/image-patch.yaml @@ -0,0 +1,10 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-deployment +spec: + template: + spec: + containers: + - name: my-container + image: nginx:stable diff --git a/k8s/overlays/dev/ingress-patch.yaml b/k8s/overlays/dev/ingress-patch.yaml new file mode 100644 index 0000000..35227da --- /dev/null +++ b/k8s/overlays/dev/ingress-patch.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-ingress + annotations: + cert-manager.io/cluster-issuer: selfsigned-dev + kubernetes.io/ingress.class: nginx +spec: + tls: + - hosts: + - terr.kube5s.ru + secretName: terr-kube5s-ru-tls + rules: + - host: terr.kube5s.ru + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-service + port: + number: 80 diff --git a/k8s/overlays/dev/kustomization.yaml b/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..e73bce9 --- /dev/null +++ b/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../base +namespace: terra-dev +patchesStrategicMerge: + - ingress-patch.yaml + - image-patch.yaml diff --git a/k8s/registry-deployment-new.yaml b/k8s/registry-deployment-new.yaml new file mode 100644 index 0000000..81d2b34 --- /dev/null +++ b/k8s/registry-deployment-new.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry-server + namespace: terra + labels: + app: registry-server +spec: + replicas: 1 + selector: + matchLabels: + app: registry-server + template: + metadata: + labels: + app: registry-server + spec: + containers: + - name: registry-server + image: naeel/terraform-registry-server:docs-dev + imagePullPolicy: Always + ports: + - containerPort: 8080 + env: + - name: REGISTRY_HOSTNAME + value: "terra.k8c.ru" + - name: S3_ENDPOINT + value: "s3.msk-1.ngcloud.ru" + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + name: s3-credentials + key: access-key + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + name: s3-credentials + key: secret-key + - name: S3_BUCKET + value: "terraform-registry" + - name: S3_USE_SSL + value: "true" diff --git a/k8s/registry-ingress-new.yaml b/k8s/registry-ingress-new.yaml new file mode 100644 index 0000000..0eb800e --- /dev/null +++ b/k8s/registry-ingress-new.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: registry-ingress + namespace: terra + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/proxy-body-size: "100m" + nginx.ingress.kubernetes.io/rewrite-target: "/$2" +spec: + ingressClassName: nginx + tls: + - hosts: + - terra.k8c.ru + secretName: registry-tls + rules: + - host: terra.k8c.ru + http: + paths: + - path: "/()(.*)" + pathType: ImplementationSpecific + backend: + service: + name: registry-server + port: + number: 80 diff --git a/operator/.dockerignore b/operator/.dockerignore new file mode 100644 index 0000000..8654f06 --- /dev/null +++ b/operator/.dockerignore @@ -0,0 +1,47 @@ +# Exclude files not needed in Docker build context for operator +.git +.gitignore +.dockerignore + +# Docs / markdown +*.md +docs/ + +# Other services / infra +server/ +scripts/ +charts/ +k8s/ + +# Secrets & keys +secrets/ +*.pem +*.key +*.asc +*.gpg +*.pgp + +# Artefacts / temp +artifacts/ +*.har +HAR/ +registry-server-build/ +universal_rebuild/ +tools/ +devops/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Go test/coverage +*.test +*.out +coverage.out + +# Logs +*.log diff --git a/operator/Makefile b/operator/Makefile new file mode 100644 index 0000000..dd2e6ab --- /dev/null +++ b/operator/Makefile @@ -0,0 +1,23 @@ +IMAGE_REPO ?= naeel +TAG ?= latest +PLATFORM ?= linux/amd64 + +.PHONY: build-all push-all build-operator push-operator build-registry push-registry tidy + +tidy: + go mod tidy + +build-all: build-operator build-registry +push-all: push-operator push-registry + +build-operator: + docker build --platform $(PLATFORM) -t $(IMAGE_REPO)/terraform-registry-operator:$(TAG) -f build/Dockerfile.operator . + +push-operator: + docker push $(IMAGE_REPO)/terraform-registry-operator:$(TAG) + +build-registry: + docker build --platform $(PLATFORM) -t $(IMAGE_REPO)/terraform-registry-server:$(TAG) -f build/Dockerfile.registry . + +push-registry: + docker push $(IMAGE_REPO)/terraform-registry-server:$(TAG) diff --git a/operator/README.md b/operator/README.md new file mode 100644 index 0000000..2f0db79 --- /dev/null +++ b/operator/README.md @@ -0,0 +1,44 @@ +# Terra Operator Manifests + +## Minimal Setup for Namespace `terra` + +### 1. Structure +- **Namespace**: `terra` +- **Storage**: внешний S3 (Nubes Cloud). +- **CRD**: `TerraformProviderRelease` definitions. + +### 2. How to apply +```bash +kubectl apply -f operator/manifests/00-namespace.yaml +kubectl apply -f operator/manifests/02-crd.yaml +kubectl apply -f operator/manifests/04-operator-deployment.yaml +kubectl apply -f operator/manifests/05-registry-server.yaml +``` + +### 3. Usage +Create a release request: +```yaml +apiVersion: terra.core.nubes.ru/v1alpha1 +kind: TerraformProviderRelease +metadata: + name: mycloud-v0-1-0 + namespace: terra +spec: + providerName: "mycloud" + version: "0.1.0" + gitRepo: "https://github.com/Start-Ops/terraform-provider-mycloud.git" + gitRef: "v0.1.0" +``` + +--- + +## Простыми словами: что это и зачем + +Это набор манифестов и небольшой сервис, который автоматизирует сборку Terraform‑провайдеров. +Идея простая: я описываю в CRD, какой провайдер и какую версию собрать, а оператор сам запускает Job, +собирает бинарники и складывает их в хранилище. Дальше registry‑server отдает эти артефакты Terraform‑клиентам. + +Зачем это нужно: +- чтобы не собирать провайдеры руками; +- чтобы хранить артефакты в одном месте; +- чтобы Terraform мог скачивать провайдеры по обычному источнику. diff --git a/operator/build/Dockerfile.operator b/operator/build/Dockerfile.operator new file mode 100644 index 0000000..adb710d --- /dev/null +++ b/operator/build/Dockerfile.operator @@ -0,0 +1,18 @@ +# Build Stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd ./cmd +# Build statically +RUN CGO_ENABLED=0 GOOS=linux go build -o /operator cmd/main.go + +# Run Stage +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /operator /app/operator +USER 1000:1000 + +CMD ["/app/operator"] diff --git a/operator/build/Dockerfile.registry b/operator/build/Dockerfile.registry new file mode 100644 index 0000000..28eb052 --- /dev/null +++ b/operator/build/Dockerfile.registry @@ -0,0 +1,20 @@ +# Build Stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +COPY registrykeys ./registrykeys +RUN go mod download +COPY cmd/registry ./cmd/registry +RUN CGO_ENABLED=0 GOOS=linux go build -o /registry-server ./cmd/registry + +# Run Stage +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /registry-server /app/registry-server +RUN apk add --no-cache ca-certificates + +USER 1000:1000 +EXPOSE 8080 + +CMD ["/app/registry-server"] diff --git a/operator/build/Dockerfile.registry.bak b/operator/build/Dockerfile.registry.bak new file mode 100644 index 0000000..07f00b1 --- /dev/null +++ b/operator/build/Dockerfile.registry.bak @@ -0,0 +1,23 @@ +# Build Stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +# Only copy what's needed for the registry server +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd/registry ./cmd/registry +# Build statically +RUN CGO_ENABLED=0 GOOS=linux go build -o /registry-server ./cmd/registry + +# Run Stage +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /registry-server /app/registry-server +# Registry server uses HTTPS for S3, so CA certs are good to have +RUN apk add --no-cache ca-certificates + +USER 1000:1000 +EXPOSE 8080 + +CMD ["/app/registry-server"] diff --git a/operator/cmd/registry/docs.go b/operator/cmd/registry/docs.go new file mode 100644 index 0000000..ee111b4 --- /dev/null +++ b/operator/cmd/registry/docs.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "mime" + "net/http" + "path/filepath" + "strings" + + s3 "github.com/minio/minio-go/v7" +) + +// parseDocsRequestPath parses paths like: +// /docs////... (rest may be empty) +func parseDocsRequestPath(p string) (namespace, name, version, rest string, err error) { +p = strings.TrimPrefix(p, "/") +p = strings.TrimPrefix(p, "docs/") +parts := strings.SplitN(p, "/", 4) +if len(parts) < 3 { +err = fmt.Errorf("invalid docs path: %s", p) +return +} +namespace = parts[0] +name = parts[1] +version = parts[2] +if len(parts) == 3 { +rest = "" +} else { +rest = parts[3] +} +return +} + +// docsObjectKey builds the S3 key for a docs object given parsed parts. +func docsObjectKey(namespace, name, version, pathPart string) string { +clean := strings.TrimPrefix(pathPart, "/") +if clean == "" { +return fmt.Sprintf("docs/%s/%s/%s/index.html", namespace, name, version) +} +return fmt.Sprintf("docs/%s/%s/%s/%s", namespace, name, version, clean) +} + +// tryCandidateKeys returns a list of keys to attempt for a given request path. +// Order matters: exact path first, then /index.html, then top-level index. +func tryCandidateKeys(namespace, name, version, rest string) []string { +keys := []string{} +if rest == "" { +keys = append(keys, docsObjectKey(namespace, name, version, "index.html")) +return keys +} +// exact +keys = append(keys, docsObjectKey(namespace, name, version, rest)) +// if it looks like a directory or has no extension, try index under it +if strings.HasSuffix(rest, "/") || filepath.Ext(rest) == "" { +keys = append(keys, docsObjectKey(namespace, name, version, strings.TrimSuffix(rest, "/")+"/index.html")) +} +// finally, try root index +keys = append(keys, docsObjectKey(namespace, name, version, "index.html")) +return keys +} + +// docsHandler serves static documentation files from S3 (public-facing via Ingress). +func docsHandler(w http.ResponseWriter, r *http.Request) { +ns, name, ver, rest, err := parseDocsRequestPath(r.URL.Path) +if err != nil { +http.Error(w, "Bad docs path", http.StatusBadRequest) +return +} + +ctx := context.Background() +candidates := tryCandidateKeys(ns, name, ver, rest) +log.Printf("Docs candidates (host=%s): %v", hostname, candidates) + +var lastErr error +for _, key := range candidates { + obj, err := s3Client.GetObject(ctx, bucketName, key, s3.GetObjectOptions{}) +if err != nil { +lastErr = err +continue +} +stat, err := obj.Stat() +if err != nil { +lastErr = err +_ = obj.Close() +continue +} + +// Determine content-type +ext := filepath.Ext(key) +ctype := mime.TypeByExtension(ext) +if ctype == "" { +// fallback for HTML +if ext == ".html" || strings.HasSuffix(key, "index.html") { +ctype = "text/html; charset=utf-8" +} else { +ctype = "application/octet-stream" +} +} + +w.Header().Set("Content-Type", ctype) +w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) +w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) + +if _, err := io.Copy(w, obj); err != nil { +log.Printf("Error streaming object %s: %v", key, err) +} +_ = obj.Close() +return +} + +log.Printf("Docs not found in candidates: %v, lastErr: %v", candidates, lastErr) +http.Error(w, "Documentation not found", http.StatusNotFound) +} diff --git a/operator/cmd/registry/gpg_key.go b/operator/cmd/registry/gpg_key.go new file mode 100644 index 0000000..246892a --- /dev/null +++ b/operator/cmd/registry/gpg_key.go @@ -0,0 +1,8 @@ +package main + +import registrykeys "terraform-registry-keys" + +var gpgPublicKey = GPGPublicKey{ + KeyID: registrykeys.KeyID, + ASCIIArmor: registrykeys.ASCIIArmor, +} diff --git a/operator/cmd/registry/main.go b/operator/cmd/registry/main.go new file mode 100644 index 0000000..042ff3d --- /dev/null +++ b/operator/cmd/registry/main.go @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + + s3 "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +var ( + s3Client *s3.Client + bucketName = "terraform-registry" // Default + hostname = os.Getenv("REGISTRY_HOSTNAME") +) + +// Terraform Registry Protocol Structs +type Discovery struct { + ProvidersV1 string `json:"providers.v1"` +} + +type VersionList struct { + ID string `json:"id"` + Versions []Version `json:"versions"` + Warnings []string `json:"warnings"` +} + +type Version struct { + Version string `json:"version"` + Protocols []string `json:"protocols"` + Platforms []Platform `json:"platforms"` +} + +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +type DownloadResponse struct { + Protocols []string `json:"protocols"` + OS string `json:"os"` + Arch string `json:"arch"` + Filename string `json:"filename"` + DownloadURL string `json:"download_url"` + ShasumsURL string `json:"shasums_url"` + ShasumsSignatureURL string `json:"shasums_signature_url"` + Shasum string `json:"shasum"` + SigningKeys SigningKeys `json:"signing_keys"` +} + +type SigningKeys struct { + GPGPublicKeys []GPGPublicKey `json:"gpg_public_keys"` +} + +type GPGPublicKey struct { + KeyID string `json:"key_id"` + ASCIIArmor string `json:"ascii_armor"` +} + +func main() { + // 1. Init S3 Connection + if hostname == "" { + hostname = "localhost:8080" + } + + endpoint := os.Getenv("S3_ENDPOINT") + accessKeyID := os.Getenv("S3_ACCESS_KEY") + secretAccessKey := os.Getenv("S3_SECRET_KEY") + + if os.Getenv("S3_BUCKET") != "" { + bucketName = os.Getenv("S3_BUCKET") + } + + var err error + s3Client, err = s3.New(endpoint, &s3.Options{ + Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Secure: true, // Force secure for cloud S3 + }) + if err != nil { + log.Fatalln(err) + } + + // 2. HTTP Handlers + http.HandleFunc("/.well-known/terraform.json", discoveryHandler) + http.HandleFunc("/v1/providers/", router) + http.HandleFunc("/v1/proxy", proxyHandler) + http.HandleFunc("/docs/", docsHandler) + http.Handle("/", http.HandlerFunc(rootHandler)) + + log.Printf("Starting Registry Service on :8080 (Bucket: %s, Endpoint: %s)\n", bucketName, endpoint) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ` + + +Terra Registry + +

Terra Registry & Documentation Server

+

Status: ONLINE

+
+

Powered by Nubes Cloud S3 Storage

+ + +`) +} + +func proxyHandler(w http.ResponseWriter, r *http.Request) { + bucket := r.URL.Query().Get("bucket") + key := r.URL.Query().Get("key") + + if bucket == "" || key == "" { + http.Error(w, "Missing bucket or key params", http.StatusBadRequest) + return + } + + obj, err := s3Client.GetObject(context.Background(), bucket, key, s3.GetObjectOptions{}) + if err != nil { + log.Printf("Error getting object %s/%s: %v", bucket, key, err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer obj.Close() + + stat, err := obj.Stat() + if err != nil { + log.Printf("Error stating object %s/%s: %v", bucket, key, err) + http.Error(w, "File not found or not accessible", http.StatusNotFound) + return + } + + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) + + if _, err := io.Copy(w, obj); err != nil { + log.Printf("Error streaming object: %v", err) + } +} + +func discoveryHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Discovery{ProvidersV1: "/v1/providers/"}) +} + +func router(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1/providers/") + parts := strings.Split(path, "/") + + if len(parts) == 3 && parts[2] == "versions" { + listVersions(w, r, parts[0], parts[1]) + return + } + + if len(parts) == 6 && parts[3] == "download" { + downloadVersion(w, r, parts[0], parts[1], parts[2], parts[4], parts[5]) + return + } + + http.Error(w, "Not Found", http.StatusNotFound) +} + +func listVersions(w http.ResponseWriter, r *http.Request, namespace, pType string) { + prefix := fmt.Sprintf("%s/%s/%s/", hostname, namespace, pType) + ctx := context.Background() + versions := []Version{} + seenVersions := map[string]*Version{} + + objectCh := s3Client.ListObjects(ctx, bucketName, s3.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + }) + + for object := range objectCh { + if object.Err != nil { + continue + } + parts := strings.Split(object.Key, "/") + if len(parts) < 5 { + continue + } + verStr := parts[3] + fileName := parts[4] + + if _, ok := seenVersions[verStr]; !ok { + seenVersions[verStr] = &Version{ + Version: verStr, + Protocols: []string{"5.0"}, + Platforms: []Platform{}, + } + } + + if strings.Contains(fileName, "_linux_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "linux", Arch: "amd64"}) + } + if strings.Contains(fileName, "_windows_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "windows", Arch: "amd64"}) + } + if strings.Contains(fileName, "_darwin_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "darwin", Arch: "amd64"}) + } + if strings.Contains(fileName, "_darwin_arm64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "darwin", Arch: "arm64"}) + } + } + + for _, v := range seenVersions { + versions = append(versions, *v) + } + + resp := VersionList{ + ID: fmt.Sprintf("%s/%s/%s", hostname, namespace, pType), + Versions: versions, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func downloadVersion(w http.ResponseWriter, r *http.Request, namespace, pType, version, osType, arch string) { + basePath := fmt.Sprintf("%s/%s/%s/%s", hostname, namespace, pType, version) + filename := fmt.Sprintf("terraform-provider-%s_%s_%s_%s.zip", pType, version, osType, arch) + fullKey := fmt.Sprintf("%s/%s", basePath, filename) + shasumsKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS", basePath, pType, version) + sigKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS.sig", basePath, pType, version) + + var shasumValue string + shasumsObj, err := s3Client.GetObject(context.Background(), bucketName, shasumsKey, s3.GetObjectOptions{}) + if err == nil { + defer shasumsObj.Close() + scanner := bufio.NewScanner(shasumsObj) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, filename) { + fields := strings.Fields(line) + if len(fields) >= 1 { + shasumValue = fields[0] + } + break + } + } + } + + baseURL := "https://" + hostname + downloadLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(fullKey)) + shasumsLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(shasumsKey)) + sigLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(sigKey)) + + gpgKey := gpgPublicKey + + resp := DownloadResponse{ + Protocols: []string{"5.0"}, + OS: osType, + Arch: arch, + Filename: filename, + DownloadURL: downloadLink, + ShasumsURL: shasumsLink, + ShasumsSignatureURL: sigLink, + Shasum: shasumValue, + SigningKeys: SigningKeys{ + GPGPublicKeys: []GPGPublicKey{gpgKey}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/operator/cmd/registry/main.go.bak b/operator/cmd/registry/main.go.bak new file mode 100644 index 0000000..f8b3b82 --- /dev/null +++ b/operator/cmd/registry/main.go.bak @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + + s3 "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +var ( + s3Client *s3.Client + bucketName = "terraform-registry" // Default + hostname = os.Getenv("REGISTRY_HOSTNAME") +) + +// Terraform Registry Protocol Structs +type Discovery struct { + ProvidersV1 string `json:"providers.v1"` +} + +type VersionList struct { + ID string `json:"id"` + Versions []Version `json:"versions"` + Warnings []string `json:"warnings"` +} + +type Version struct { + Version string `json:"version"` + Protocols []string `json:"protocols"` + Platforms []Platform `json:"platforms"` +} + +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +type DownloadResponse struct { + Protocols []string `json:"protocols"` + OS string `json:"os"` + Arch string `json:"arch"` + Filename string `json:"filename"` + DownloadURL string `json:"download_url"` + ShasumsURL string `json:"shasums_url"` + ShasumsSignatureURL string `json:"shasums_signature_url"` + Shasum string `json:"shasum"` + SigningKeys SigningKeys `json:"signing_keys"` +} + +type SigningKeys struct { + GPGPublicKeys []GPGPublicKey `json:"gpg_public_keys"` +} + +type GPGPublicKey struct { + KeyID string `json:"key_id"` + ASCIIArmor string `json:"ascii_armor"` +} + +func main() { + // 1. Init S3 Connection + if hostname == "" { + hostname = "localhost:8080" + } + + endpoint := os.Getenv("S3_ENDPOINT") + accessKeyID := os.Getenv("S3_ACCESS_KEY") + secretAccessKey := os.Getenv("S3_SECRET_KEY") + + if os.Getenv("S3_BUCKET") != "" { + bucketName = os.Getenv("S3_BUCKET") + } + + var err error + s3Client, err = s3.New(endpoint, &s3.Options{ + Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Secure: true, // Force secure for cloud S3 + }) + if err != nil { + log.Fatalln(err) + } + + // 2. HTTP Handlers + http.HandleFunc("/.well-known/terraform.json", discoveryHandler) + http.HandleFunc("/v1/providers/", router) + http.HandleFunc("/v1/proxy", proxyHandler) + http.HandleFunc("/docs/", docsHandler) + http.Handle("/", http.HandlerFunc(rootHandler)) + + log.Printf("Starting Registry Service on :8080 (Bucket: %s, Endpoint: %s)\n", bucketName, endpoint) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ` + + +Terra Registry + +

Terra Registry & Documentation Server

+

Status: ONLINE

+
+

Powered by Nubes Cloud S3 Storage

+ + +`) +} + +func proxyHandler(w http.ResponseWriter, r *http.Request) { + bucket := r.URL.Query().Get("bucket") + key := r.URL.Query().Get("key") + + if bucket == "" || key == "" { + http.Error(w, "Missing bucket or key params", http.StatusBadRequest) + return + } + + obj, err := s3Client.GetObject(context.Background(), bucket, key, s3.GetObjectOptions{}) + if err != nil { + log.Printf("Error getting object %s/%s: %v", bucket, key, err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer obj.Close() + + stat, err := obj.Stat() + if err != nil { + log.Printf("Error stating object %s/%s: %v", bucket, key, err) + http.Error(w, "File not found or not accessible", http.StatusNotFound) + return + } + + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) + + if _, err := io.Copy(w, obj); err != nil { + log.Printf("Error streaming object: %v", err) + } +} + +func discoveryHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Discovery{ProvidersV1: "/v1/providers/"}) +} + +func router(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1/providers/") + parts := strings.Split(path, "/") + + if len(parts) == 3 && parts[2] == "versions" { + listVersions(w, r, parts[0], parts[1]) + return + } + + if len(parts) == 6 && parts[3] == "download" { + downloadVersion(w, r, parts[0], parts[1], parts[2], parts[4], parts[5]) + return + } + + http.Error(w, "Not Found", http.StatusNotFound) +} + +func listVersions(w http.ResponseWriter, r *http.Request, namespace, pType string) { + prefix := fmt.Sprintf("%s/%s/%s/", hostname, namespace, pType) + ctx := context.Background() + versions := []Version{} + seenVersions := map[string]*Version{} + + objectCh := s3Client.ListObjects(ctx, bucketName, s3.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + }) + + for object := range objectCh { + if object.Err != nil { + continue + } + parts := strings.Split(object.Key, "/") + if len(parts) < 5 { + continue + } + verStr := parts[3] + fileName := parts[4] + + if _, ok := seenVersions[verStr]; !ok { + seenVersions[verStr] = &Version{ + Version: verStr, + Protocols: []string{"5.0"}, + Platforms: []Platform{}, + } + } + + if strings.Contains(fileName, "_linux_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "linux", Arch: "amd64"}) + } + if strings.Contains(fileName, "_windows_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "windows", Arch: "amd64"}) + } + if strings.Contains(fileName, "_darwin_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "darwin", Arch: "amd64"}) + } + if strings.Contains(fileName, "_darwin_arm64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "darwin", Arch: "arm64"}) + } + } + + for _, v := range seenVersions { + versions = append(versions, *v) + } + + resp := VersionList{ + ID: fmt.Sprintf("%s/%s", namespace, pType), + Versions: versions, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func downloadVersion(w http.ResponseWriter, r *http.Request, namespace, pType, version, osType, arch string) { + basePath := fmt.Sprintf("%s/%s/%s/%s", hostname, namespace, pType, version) + filename := fmt.Sprintf("terraform-provider-%s_%s_%s_%s.zip", pType, version, osType, arch) + fullKey := fmt.Sprintf("%s/%s", basePath, filename) + shasumsKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS", basePath, pType, version) + sigKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS.sig", basePath, pType, version) + + var shasumValue string + shasumsObj, err := s3Client.GetObject(context.Background(), bucketName, shasumsKey, s3.GetObjectOptions{}) + if err == nil { + defer shasumsObj.Close() + scanner := bufio.NewScanner(shasumsObj) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, filename) { + fields := strings.Fields(line) + if len(fields) >= 1 { + shasumValue = fields[0] + } + break + } + } + } + + baseURL := "https://" + hostname + downloadLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(fullKey)) + shasumsLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(shasumsKey)) + sigLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(sigKey)) + + gpgKey := gpgPublicKey + + resp := DownloadResponse{ + Protocols: []string{"5.0"}, + OS: osType, + Arch: arch, + Filename: filename, + DownloadURL: downloadLink, + ShasumsURL: shasumsLink, + ShasumsSignatureURL: sigLink, + Shasum: shasumValue, + SigningKeys: SigningKeys{ + GPGPublicKeys: []GPGPublicKey{gpgKey}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/operator/example-release.yaml b/operator/example-release.yaml new file mode 100644 index 0000000..a2858ca --- /dev/null +++ b/operator/example-release.yaml @@ -0,0 +1,11 @@ +apiVersion: terra.core.nubes.ru/v1alpha1 +kind: TerraformProviderRelease +metadata: + name: scaffolding-v0-0-1 + namespace: terra +spec: + providerName: "scaffolding" + namespace: "hashicorp" + version: "0.0.1" + gitRepo: "https://github.com/hashicorp/terraform-provider-scaffolding.git" + gitRef: "main" diff --git a/operator/go.mod b/operator/go.mod new file mode 100644 index 0000000..bd795c6 --- /dev/null +++ b/operator/go.mod @@ -0,0 +1,37 @@ +module terraform-provider-operator + +go 1.24.0 + +toolchain go1.24.12 + +require ( + github.com/minio/minio-go/v7 v7.0.98 + terraform-registry-keys v0.0.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace terraform-registry-keys => ./registrykeys diff --git a/operator/go.sum b/operator/go.sum new file mode 100644 index 0000000..8542c10 --- /dev/null +++ b/operator/go.sum @@ -0,0 +1,58 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/operator/manifests/00-namespace.yaml b/operator/manifests/00-namespace.yaml new file mode 100644 index 0000000..5d0c7df --- /dev/null +++ b/operator/manifests/00-namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: terra + labels: + name: terra diff --git a/operator/manifests/00-rbac.yaml b/operator/manifests/00-rbac.yaml new file mode 100644 index 0000000..8a806c8 --- /dev/null +++ b/operator/manifests/00-rbac.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: terraform-operator + namespace: terra +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: terraform-operator + namespace: terra +rules: +- apiGroups: ["terra.core.nubes.ru"] + resources: ["providerreleases", "providerreleases/status"] + verbs: ["get", "list", "watch", "update", "patch"] +- apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["create", "get", "list", "watch", "delete"] +- apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: terraform-operator + namespace: terra +subjects: +- kind: ServiceAccount + name: terraform-operator + namespace: terra +roleRef: + kind: Role + name: terraform-operator + apiGroup: rbac.authorization.k8s.io diff --git a/operator/manifests/02-crd.yaml b/operator/manifests/02-crd.yaml new file mode 100644 index 0000000..295f21f --- /dev/null +++ b/operator/manifests/02-crd.yaml @@ -0,0 +1,77 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: providerreleases.terra.core.nubes.ru +spec: + group: terra.core.nubes.ru + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: + - providerName + - version + - gitRepo + - gitRef + properties: + providerName: + type: string + description: "Name of the provider (e.g. 'mycloud')" + namespace: + type: string + default: "mycloud" + description: "Terraform registry namespace (e.g. 'nubes')" + version: + type: string + description: "Semantic version without v (e.g. '1.0.1')" + gitRepo: + type: string + description: "Git repository URL to clone" + gitRef: + type: string + description: "Git tag, commit SHA or branch" + platforms: + type: array + items: + type: string + default: ["linux_amd64", "windows_amd64"] + description: "Target platforms for build" + status: + type: object + properties: + phase: + type: string + description: "Current phase: Pending, Building, Publishing, Ready, Failed" + downloadUrl: + type: string + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + lastTransitionTime: + type: string + format: date-time + reason: + type: string + message: + type: string + scope: Namespaced + names: + plural: providerreleases + singular: providerrelease + kind: TerraformProviderRelease + shortNames: + - tpr diff --git a/operator/manifests/03-build-script.yaml b/operator/manifests/03-build-script.yaml new file mode 100644 index 0000000..a10e8af --- /dev/null +++ b/operator/manifests/03-build-script.yaml @@ -0,0 +1,75 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: builder-script + namespace: terra +data: + build.sh: | + #!/bin/sh + set -e + + echo ">>> Starting Build for $PROVIDER_NAME $VERSION" + + # 1. Setup Environment + # Inputs: GIT_REPO, GIT_REF, PROVIDER_NAME, VERSION + WORK_DIR="/workspace" + ARTIFACTS_DIR="/artifacts" + mkdir -p $WORK_DIR $ARTIFACTS_DIR + + # 2. Clone Repository + echo ">>> Cloning $GIT_REPO ($GIT_REF)..." + git clone $GIT_REPO $WORK_DIR/src + cd $WORK_DIR/src + git checkout $GIT_REF + + # 3. Build for Platforms + # Hardcoded platforms for MVP: linux/amd64, windows/amd64 + # Naming convention: terraform-provider-NAME_vVERSION_OS_ARCH.zip + + echo ">>> Building binaries..." + + # Linux amd64 + echo "--> linux/amd64" + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o terraform-provider-${PROVIDER_NAME}_v${VERSION} + zip ${ARTIFACTS_DIR}/terraform-provider-${PROVIDER_NAME}_${VERSION}_linux_amd64.zip terraform-provider-${PROVIDER_NAME}_v${VERSION} + rm terraform-provider-${PROVIDER_NAME}_v${VERSION} + + # Windows amd64 + echo "--> windows/amd64" + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o terraform-provider-${PROVIDER_NAME}_v${VERSION}.exe + zip ${ARTIFACTS_DIR}/terraform-provider-${PROVIDER_NAME}_${VERSION}_windows_amd64.zip terraform-provider-${PROVIDER_NAME}_v${VERSION}.exe + rm terraform-provider-${PROVIDER_NAME}_v${VERSION}.exe + + # 4. Checksums + cd $ARTIFACTS_DIR + sha256sum *.zip > terraform-provider-${PROVIDER_NAME}_${VERSION}_SHA256SUMS + + # 5. GPG Sign (Mock for MVP - empty sig or skip if client allows, but Terraform strict. + # For now we will generate a dummy signature to satisfy file existence, real signing requires GPG private key in env) + # Ideally: gpg --detach-sign --armor terraform-provider-..._SHA256SUMS + touch terraform-provider-${PROVIDER_NAME}_${VERSION}_SHA256SUMS.sig + + # 6. Upload to S3 + # Layout: /terraform-providers/hostname/namespace/name/version/ + # We will use 'REGISTRY_HOSTNAME' env var if set, else default. + + if [ -z "$REGISTRY_HOSTNAME" ]; then + REGISTRY_HOSTNAME="terra.k8c.ru" + fi + + TARGET_PATH="terraform-providers/$REGISTRY_HOSTNAME/${NAMESPACE}/${PROVIDER_NAME}/${VERSION}" + + echo ">>> Uploading to S3 path: $TARGET_PATH" + + # Configure mc using S3 credentials (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY) + mc alias set nubes_s3 https://$S3_ENDPOINT $S3_ACCESS_KEY $S3_SECRET_KEY + + # Upload files + mc cp --recursive $ARTIFACTS_DIR/ nubes_s3/$TARGET_PATH/ + + # 7. Generate shasums file for download (Terraform download protocol requires a checksums.txt usually or just the sums file) + # We are following the provider registry protocol loosely. + # The most important part for discovery is the 'index.json' or 'versions' endpoint, but we are static hosting. + # We rely on the directory structure. + + echo ">>> Build Complete!" diff --git a/operator/manifests/04-operator-deployment.yaml b/operator/manifests/04-operator-deployment.yaml new file mode 100644 index 0000000..23a0872 --- /dev/null +++ b/operator/manifests/04-operator-deployment.yaml @@ -0,0 +1,27 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: terraform-operator + namespace: terra +spec: + replicas: 1 + selector: + matchLabels: + app: terraform-operator + template: + metadata: + labels: + app: terraform-operator + spec: + serviceAccountName: terraform-operator + containers: + - name: operator + image: naeel/terraform-registry-operator:latest + imagePullPolicy: Always + env: + - name: REGISTRY_HOSTNAME + value: "terra.k8c.ru" + resources: + limits: + memory: "128Mi" + cpu: "500m" diff --git a/operator/manifests/05-registry-server.yaml b/operator/manifests/05-registry-server.yaml new file mode 100644 index 0000000..b1f36f5 --- /dev/null +++ b/operator/manifests/05-registry-server.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry-server + namespace: terra + labels: + app: registry-server +spec: + replicas: 1 + selector: + matchLabels: + app: registry-server + template: + metadata: + labels: + app: registry-server + spec: + containers: + - name: registry-server + image: naeel/terraform-registry-server:gpg-key-20260208 + imagePullPolicy: Always + env: + - name: REGISTRY_HOSTNAME + value: "terra.k8c.ru" + - name: S3_ENDPOINT + value: "s3.msk-1.ngcloud.ru" + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + key: access-key + name: s3-credentials + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + key: secret-key + name: s3-credentials + - name: S3_BUCKET + value: "terraform-registry" + - name: S3_USE_SSL + value: "true" + ports: + - containerPort: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: registry-server + namespace: terra +spec: + selector: + app: registry-server + ports: + - port: 80 + targetPort: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: registry-ingress + namespace: terra + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - terra.k8c.ru + secretName: registry-tls + rules: + - host: terra.k8c.ru + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: registry-server + port: + number: 80 diff --git a/operator/manifests/06-registry-server-docs.yaml b/operator/manifests/06-registry-server-docs.yaml new file mode 100644 index 0000000..52de092 --- /dev/null +++ b/operator/manifests/06-registry-server-docs.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry-server + namespace: terra + labels: + app: registry-server +spec: + replicas: 1 + selector: + matchLabels: + app: registry-server + template: + metadata: + labels: + app: registry-server + spec: + containers: + - name: registry-server + image: naeel/terraform-registry-server:docs-dev-fixed + imagePullPolicy: Always + env: + - name: REGISTRY_HOSTNAME + value: "terra.k8c.ru" + - name: S3_ENDPOINT + value: "s3.msk-1.ngcloud.ru" + - name: S3_ACCESS_KEY + valueFrom: + secretKeyRef: + key: access-key + name: s3-credentials + - name: S3_SECRET_KEY + valueFrom: + secretKeyRef: + key: secret-key + name: s3-credentials + - name: S3_BUCKET + value: "terraform-registry" + - name: S3_USE_SSL + value: "true" + ports: + - containerPort: 8080 diff --git a/operator/registrykeys/go.mod b/operator/registrykeys/go.mod new file mode 100644 index 0000000..f29132e --- /dev/null +++ b/operator/registrykeys/go.mod @@ -0,0 +1,5 @@ +module terraform-registry-keys + +go 1.24.0 + +toolchain go1.24.12 diff --git a/operator/registrykeys/public_key.go b/operator/registrykeys/public_key.go new file mode 100644 index 0000000..2718aff --- /dev/null +++ b/operator/registrykeys/public_key.go @@ -0,0 +1,59 @@ +package registrykeys + +const KeyID = "3EC4673EB798238A" + +const ASCIIArmor = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +6cZ0NQ== +=3Ea2 +-----END PGP PUBLIC KEY BLOCK----- +` diff --git a/registrykeys/go.mod b/registrykeys/go.mod new file mode 100644 index 0000000..f29132e --- /dev/null +++ b/registrykeys/go.mod @@ -0,0 +1,5 @@ +module terraform-registry-keys + +go 1.24.0 + +toolchain go1.24.12 diff --git a/registrykeys/public_key.go b/registrykeys/public_key.go new file mode 100644 index 0000000..c4ca056 --- /dev/null +++ b/registrykeys/public_key.go @@ -0,0 +1,67 @@ +package registrykeys + +const KeyID = "CB3A0DF161ECC416" + +const ASCIIArmor = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=q8Tw +-----END PGP PUBLIC KEY BLOCK-----` diff --git a/scripts/build-and-push-registry-server.sh b/scripts/build-and-push-registry-server.sh new file mode 100755 index 0000000..74c7e3e --- /dev/null +++ b/scripts/build-and-push-registry-server.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build and optionally push the registry-server image with docs handler +# Usage: ./scripts/build-and-push-registry-server.sh +IMAGE_REPO=${1:-naeel} +TAG=${2:-docs-dev} + +cd operator +make build-registry IMAGE_REPO=${IMAGE_REPO} TAG=${TAG} + +echo "Built image: ${IMAGE_REPO}/terraform-registry-server:${TAG}" + +echo "To push: docker push ${IMAGE_REPO}/terraform-registry-server:${TAG}" + +echo "To deploy to cluster (example):" +echo " kubectl apply -f operator/manifests/06-registry-server-docs.yaml" + +echo "and edit the file to replace IMAGE_REPO/TAG with your registry and tag before applying." diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..c18d38d --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,47 @@ +# Exclude files not needed in Docker build context for registry-server +.git +.gitignore +.dockerignore + +# Docs / markdown +*.md +docs/ + +# Other services / infra +operator/ +registrykeys/ +scripts/ +charts/ +k8s/ + +# Secrets & keys +secrets/ +*.pem +*.key +*.asc +*.gpg +*.pgp + +# Artefacts / temp +artifacts/ +*.har +HAR/ +universal_rebuild/ +tools/ +devops/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Go test/coverage +*.test +*.out +coverage.out + +# Logs +*.log diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..25b53dc --- /dev/null +++ b/server/README.md @@ -0,0 +1,56 @@ +# Registry Server Build + +This folder holds the standalone registry server build and helper scripts. + +## Provider build and upload + +Script: `build-provider.sh` + +What it does: +- Builds the provider from `universal_rebuild` for 3 platforms: linux_amd64, windows_amd64, darwin_amd64 +- Creates ZIPs and SHA256SUMS +- Signs SHA256SUMS with a local GPG key file +- Uploads artifacts to S3 for the registry server to serve + +### Requirements +- Go 1.22+ +- `python3` (for zipfile) +- `gpg` +- `mc` (MinIO/S3 client) + +### GPG key file +Store the private key at: +- `secrets/private_key.asc` (ignored by git) + +The public key is at: +- `secrets/public_key.asc` + +The registry server embeds the public key it returns to Terraform. After regenerating keys: +- Update the embedded ASCII Armor in `registry-server-build/main.go` and `operator/cmd/registry/main.go`. +- Rebuild and redeploy the registry server. +- Re-upload provider artifacts signed with the new private key. + +### Environment +Set these variables before running: +- `S3_ENDPOINT` (example: `s3.msk-1.ngcloud.ru`) +- `S3_ACCESS_KEY` +- `S3_SECRET_KEY` + +Optional overrides: +- `REGISTRY_HOSTNAME` (default: `terra.k8c.ru`) +- `NAMESPACE` (default: `nubes`) +- `NAME` (default: `nubes`) +- `S3_BUCKET` (default: `terraform-registry`) + +### Usage + +```bash +export S3_ENDPOINT="s3.msk-1.ngcloud.ru" +export S3_ACCESS_KEY="..." +export S3_SECRET_KEY="..." + +./registry-server-build/build-provider.sh 2.0.2 +``` + +Artifacts are uploaded to: +`registry//////` diff --git a/server/build-provider.sh b/server/build-provider.sh new file mode 100755 index 0000000..f8d2a5e --- /dev/null +++ b/server/build-provider.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Build and upload the Terraform provider to the registry S3 bucket. +# +# GPG signing key requirements: +# - A private key is required for signing SHA256SUMS. +# - The private key must be available at: +# ${ROOT_DIR}/secrets/private_key.asc +# - The private key is intentionally ignored by git. +# +# If the key is missing, generate it with a no-passphrase batch run, then +# export it to the expected location. Example (email only, no passphrase): +# GPG_DIR=${ROOT_DIR}/secrets +# GNUPGHOME=$(mktemp -d) +# cat > /tmp/gpg_batch <<'EOF' +# %no-protection +# Key-Type: RSA +# Key-Length: 3072 +# Subkey-Type: RSA +# Subkey-Length: 3072 +# Name-Real: tazet@narod.ru +# Name-Email: tazet@narod.ru +# Expire-Date: 0 +# EOF +# gpg --batch --homedir "$GNUPGHOME" --gen-key /tmp/gpg_batch +# gpg --batch --homedir "$GNUPGHOME" --armor --export-secret-keys > "$GPG_DIR/private_key.asc" +# gpg --batch --homedir "$GNUPGHOME" --armor --export > "$GPG_DIR/public_key.asc" +# rm -rf "$GNUPGHOME" /tmp/gpg_batch +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="${ROOT_DIR:-$(cd "${SCRIPT_DIR}/.." && pwd)}" +PROVIDER_DIR="${PROVIDER_DIR:-${ROOT_DIR}/universal_rebuild}" +BUILD_DIR="${BUILD_DIR:-${ROOT_DIR}/artifacts/provider_build}" +GPG_KEY_FILE="${GPG_KEY_FILE:-${ROOT_DIR}/secrets/private_key.asc}" + +VERSION="${1:-2.0.2}" +REGISTRY_HOSTNAME="${REGISTRY_HOSTNAME:-terra.k8c.ru}" +NAMESPACE="${NAMESPACE:-nubes}" +PROVIDER_NAME="${PROVIDER_NAME:-nubes}" +S3_BUCKET="${S3_BUCKET:-terraform-registry}" + +S3_ENDPOINT="${S3_ENDPOINT:-}" +S3_ACCESS_KEY="${S3_ACCESS_KEY:-}" +S3_SECRET_KEY="${S3_SECRET_KEY:-}" + +if [[ -z "$S3_ENDPOINT" || -z "$S3_ACCESS_KEY" || -z "$S3_SECRET_KEY" ]]; then + echo "Error: S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY must be set" >&2 + echo "Hint: see ${ROOT_DIR}/secrets/.s3cfg_registry" >&2 + exit 2 +fi + +if [[ ! -f "$GPG_KEY_FILE" ]]; then + echo "Error: GPG key file not found at $GPG_KEY_FILE" >&2 + exit 2 +fi + +if [[ ! -d "$PROVIDER_DIR" ]]; then + echo "Error: provider source dir not found: $PROVIDER_DIR" >&2 + exit 2 +fi + +s3_endpoint_url="$S3_ENDPOINT" +if [[ "$s3_endpoint_url" != http* ]]; then + s3_endpoint_url="https://${s3_endpoint_url}" +fi + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +build_and_zip() { + local os="$1" + local arch="$2" + local binary="terraform-provider-nubes_v${VERSION}" + + if [[ "$os" == "windows" ]]; then + binary+=".exe" + fi + + echo "Building for ${os}/${arch}..." + (cd "$PROVIDER_DIR" && CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -o "${BUILD_DIR}/${binary}" .) + + (cd "$BUILD_DIR" && python3 -m zipfile -c "terraform-provider-nubes_${VERSION}_${os}_${arch}.zip" "$binary") + rm -f "${BUILD_DIR:?}/${binary}" +} + +build_and_zip linux amd64 +build_and_zip windows amd64 +build_and_zip darwin amd64 + +( cd "$BUILD_DIR" && sha256sum *.zip > "terraform-provider-nubes_${VERSION}_SHA256SUMS" ) + +GNUPGHOME="$(mktemp -d)" +export GNUPGHOME +trap 'rm -rf "$GNUPGHOME"' EXIT + +gpg --batch --import "$GPG_KEY_FILE" >/dev/null +key_id=$(gpg --list-keys --with-colons | awk -F: '/^pub/{print $5; exit}') +if [[ -z "$key_id" ]]; then + echo "Error: failed to read key id from $GPG_KEY_FILE" >&2 + exit 2 +fi + +gpg --batch --yes --detach-sign --default-key "$key_id" \ + --output "${BUILD_DIR}/terraform-provider-nubes_${VERSION}_SHA256SUMS.sig" \ + "${BUILD_DIR}/terraform-provider-nubes_${VERSION}_SHA256SUMS" + +mc alias set registry "$s3_endpoint_url" "$S3_ACCESS_KEY" "$S3_SECRET_KEY" --api S3v4 +S3_PATH="registry/${S3_BUCKET}/${REGISTRY_HOSTNAME}/${NAMESPACE}/${PROVIDER_NAME}/${VERSION}/" + +echo "Uploading to: ${S3_PATH}" +mc cp "${BUILD_DIR}"/*.zip "$S3_PATH" +mc cp "${BUILD_DIR}/terraform-provider-nubes_${VERSION}_SHA256SUMS" "$S3_PATH" +mc cp "${BUILD_DIR}/terraform-provider-nubes_${VERSION}_SHA256SUMS.sig" "$S3_PATH" + +echo "Done. Version ${VERSION} uploaded." diff --git a/server/docs.go b/server/docs.go new file mode 100644 index 0000000..ee111b4 --- /dev/null +++ b/server/docs.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "mime" + "net/http" + "path/filepath" + "strings" + + s3 "github.com/minio/minio-go/v7" +) + +// parseDocsRequestPath parses paths like: +// /docs////... (rest may be empty) +func parseDocsRequestPath(p string) (namespace, name, version, rest string, err error) { +p = strings.TrimPrefix(p, "/") +p = strings.TrimPrefix(p, "docs/") +parts := strings.SplitN(p, "/", 4) +if len(parts) < 3 { +err = fmt.Errorf("invalid docs path: %s", p) +return +} +namespace = parts[0] +name = parts[1] +version = parts[2] +if len(parts) == 3 { +rest = "" +} else { +rest = parts[3] +} +return +} + +// docsObjectKey builds the S3 key for a docs object given parsed parts. +func docsObjectKey(namespace, name, version, pathPart string) string { +clean := strings.TrimPrefix(pathPart, "/") +if clean == "" { +return fmt.Sprintf("docs/%s/%s/%s/index.html", namespace, name, version) +} +return fmt.Sprintf("docs/%s/%s/%s/%s", namespace, name, version, clean) +} + +// tryCandidateKeys returns a list of keys to attempt for a given request path. +// Order matters: exact path first, then /index.html, then top-level index. +func tryCandidateKeys(namespace, name, version, rest string) []string { +keys := []string{} +if rest == "" { +keys = append(keys, docsObjectKey(namespace, name, version, "index.html")) +return keys +} +// exact +keys = append(keys, docsObjectKey(namespace, name, version, rest)) +// if it looks like a directory or has no extension, try index under it +if strings.HasSuffix(rest, "/") || filepath.Ext(rest) == "" { +keys = append(keys, docsObjectKey(namespace, name, version, strings.TrimSuffix(rest, "/")+"/index.html")) +} +// finally, try root index +keys = append(keys, docsObjectKey(namespace, name, version, "index.html")) +return keys +} + +// docsHandler serves static documentation files from S3 (public-facing via Ingress). +func docsHandler(w http.ResponseWriter, r *http.Request) { +ns, name, ver, rest, err := parseDocsRequestPath(r.URL.Path) +if err != nil { +http.Error(w, "Bad docs path", http.StatusBadRequest) +return +} + +ctx := context.Background() +candidates := tryCandidateKeys(ns, name, ver, rest) +log.Printf("Docs candidates (host=%s): %v", hostname, candidates) + +var lastErr error +for _, key := range candidates { + obj, err := s3Client.GetObject(ctx, bucketName, key, s3.GetObjectOptions{}) +if err != nil { +lastErr = err +continue +} +stat, err := obj.Stat() +if err != nil { +lastErr = err +_ = obj.Close() +continue +} + +// Determine content-type +ext := filepath.Ext(key) +ctype := mime.TypeByExtension(ext) +if ctype == "" { +// fallback for HTML +if ext == ".html" || strings.HasSuffix(key, "index.html") { +ctype = "text/html; charset=utf-8" +} else { +ctype = "application/octet-stream" +} +} + +w.Header().Set("Content-Type", ctype) +w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) +w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) + +if _, err := io.Copy(w, obj); err != nil { +log.Printf("Error streaming object %s: %v", key, err) +} +_ = obj.Close() +return +} + +log.Printf("Docs not found in candidates: %v, lastErr: %v", candidates, lastErr) +http.Error(w, "Documentation not found", http.StatusNotFound) +} diff --git a/server/gpg_key.go b/server/gpg_key.go new file mode 100644 index 0000000..246892a --- /dev/null +++ b/server/gpg_key.go @@ -0,0 +1,8 @@ +package main + +import registrykeys "terraform-registry-keys" + +var gpgPublicKey = GPGPublicKey{ + KeyID: registrykeys.KeyID, + ASCIIArmor: registrykeys.ASCIIArmor, +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..042efb7 --- /dev/null +++ b/server/main.go @@ -0,0 +1,288 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + + s3 "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +var ( + s3Client *s3.Client + bucketName = "terraform-registry" // Default + hostname = os.Getenv("REGISTRY_HOSTNAME") +) + +// Terraform Registry Protocol Structs +type Discovery struct { + ProvidersV1 string `json:"providers.v1"` +} + +type VersionList struct { + ID string `json:"id"` + Versions []Version `json:"versions"` + Warnings []string `json:"warnings"` +} + +type Version struct { + Version string `json:"version"` + Protocols []string `json:"protocols"` + Platforms []Platform `json:"platforms"` +} + +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +type DownloadResponse struct { + Protocols []string `json:"protocols"` + OS string `json:"os"` + Arch string `json:"arch"` + Filename string `json:"filename"` + DownloadURL string `json:"download_url"` + ShasumsURL string `json:"shasums_url"` + ShasumsSignatureURL string `json:"shasums_signature_url"` + Shasum string `json:"shasum"` + SigningKeys SigningKeys `json:"signing_keys"` +} + +type SigningKeys struct { + GPGPublicKeys []GPGPublicKey `json:"gpg_public_keys"` +} + +type GPGPublicKey struct { + KeyID string `json:"key_id"` + ASCIIArmor string `json:"ascii_armor"` +} + +func main() { + // 1. Init S3 Connection + if hostname == "" { + hostname = "localhost:8080" + } + + endpoint := os.Getenv("S3_ENDPOINT") + accessKeyID := os.Getenv("S3_ACCESS_KEY") + secretAccessKey := os.Getenv("S3_SECRET_KEY") + + if os.Getenv("S3_BUCKET") != "" { + bucketName = os.Getenv("S3_BUCKET") + } + + var err error + s3Client, err = s3.New(endpoint, &s3.Options{ + Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Secure: true, // Force secure for cloud S3 + }) + if err != nil { + log.Fatalln(err) + } + + // 2. HTTP Handlers + http.HandleFunc("/.well-known/terraform.json", discoveryHandler) + http.HandleFunc("/v1/providers/", router) + http.HandleFunc("/v1/proxy", proxyHandler) + http.HandleFunc("/docs/", docsHandler) + http.Handle("/", http.HandlerFunc(rootHandler)) + http.HandleFunc("/healthz", healthzHandler) + http.HandleFunc("/readyz", readyzHandler) + + log.Printf("Starting Registry Service on :8080 (Bucket: %s, Endpoint: %s)\n", bucketName, endpoint) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ` + + +Terra Registry + +

Terra Registry & Documentation Server

+

Status: ONLINE

+
+

Powered by Nubes Cloud S3 Storage

+ + +`) +} + +func proxyHandler(w http.ResponseWriter, r *http.Request) { + bucket := r.URL.Query().Get("bucket") + key := r.URL.Query().Get("key") + + if bucket == "" || key == "" { + http.Error(w, "Missing bucket or key params", http.StatusBadRequest) + return + } + + obj, err := s3Client.GetObject(context.Background(), bucket, key, s3.GetObjectOptions{}) + if err != nil { + log.Printf("Error getting object %s/%s: %v", bucket, key, err) + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer obj.Close() + + stat, err := obj.Stat() + if err != nil { + log.Printf("Error stating object %s/%s: %v", bucket, key, err) + http.Error(w, "File not found or not accessible", http.StatusNotFound) + return + } + + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Last-Modified", stat.LastModified.Format(http.TimeFormat)) + + if _, err := io.Copy(w, obj); err != nil { + log.Printf("Error streaming object: %v", err) + } +} + +func discoveryHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Discovery{ProvidersV1: "/v1/providers/"}) +} + +func router(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1/providers/") + parts := strings.Split(path, "/") + + if len(parts) == 3 && parts[2] == "versions" { + listVersions(w, r, parts[0], parts[1]) + return + } + + if len(parts) == 6 && parts[3] == "download" { + downloadVersion(w, r, parts[0], parts[1], parts[2], parts[4], parts[5]) + return + } + + http.Error(w, "Not Found", http.StatusNotFound) +} + +func listVersions(w http.ResponseWriter, r *http.Request, namespace, pType string) { + prefix := fmt.Sprintf("%s/%s/%s/", hostname, namespace, pType) + ctx := context.Background() + versions := []Version{} + seenVersions := map[string]*Version{} + + objectCh := s3Client.ListObjects(ctx, bucketName, s3.ListObjectsOptions{ + Prefix: prefix, + Recursive: true, + }) + + for object := range objectCh { + if object.Err != nil { + continue + } + parts := strings.Split(object.Key, "/") + if len(parts) < 5 { + continue + } + verStr := parts[3] + fileName := parts[4] + + if _, ok := seenVersions[verStr]; !ok { + seenVersions[verStr] = &Version{ + Version: verStr, + Protocols: []string{"5.0"}, + Platforms: []Platform{}, + } + } + + if strings.Contains(fileName, "_linux_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "linux", Arch: "amd64"}) + } + if strings.Contains(fileName, "_windows_amd64.zip") { + seenVersions[verStr].Platforms = append(seenVersions[verStr].Platforms, Platform{OS: "windows", Arch: "amd64"}) + } + } + + for _, v := range seenVersions { + versions = append(versions, *v) + } + + resp := VersionList{ + ID: fmt.Sprintf("%s/%s", namespace, pType), + Versions: versions, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func downloadVersion(w http.ResponseWriter, r *http.Request, namespace, pType, version, osType, arch string) { + basePath := fmt.Sprintf("%s/%s/%s/%s", hostname, namespace, pType, version) + filename := fmt.Sprintf("terraform-provider-%s_%s_%s_%s.zip", pType, version, osType, arch) + fullKey := fmt.Sprintf("%s/%s", basePath, filename) + shasumsKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS", basePath, pType, version) + sigKey := fmt.Sprintf("%s/terraform-provider-%s_%s_SHA256SUMS.sig", basePath, pType, version) + + var shasumValue string + shasumsObj, err := s3Client.GetObject(context.Background(), bucketName, shasumsKey, s3.GetObjectOptions{}) + if err == nil { + defer shasumsObj.Close() + scanner := bufio.NewScanner(shasumsObj) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, filename) { + fields := strings.Fields(line) + if len(fields) >= 1 { + shasumValue = fields[0] + } + break + } + } + } + + baseURL := "https://" + hostname + downloadLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(fullKey)) + shasumsLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(shasumsKey)) + sigLink := fmt.Sprintf("%s/v1/proxy?bucket=%s&key=%s", baseURL, bucketName, url.QueryEscape(sigKey)) + + gpgKey := gpgPublicKey + + resp := DownloadResponse{ + Protocols: []string{"5.0"}, + OS: osType, + Arch: arch, + Filename: filename, + DownloadURL: downloadLink, + ShasumsURL: shasumsLink, + ShasumsSignatureURL: sigLink, + Shasum: shasumValue, + SigningKeys: SigningKeys{ + GPGPublicKeys: []GPGPublicKey{gpgKey}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func healthzHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +func readyzHandler(w http.ResponseWriter, r *http.Request) { + // Проверить доступность S3 + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/wheel-config.yaml b/wheel-config.yaml new file mode 100644 index 0000000..20f2efa --- /dev/null +++ b/wheel-config.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM2akNDQWRLZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJMU1UQXlNREV5TVRVMU5Wb1hEVE0xTVRBeE9ERXlNakExTlZvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBUGsvClp2SXFlTWY5cEM0V0YvZW4yVVpNVWV6bHdWYTB5Wm9XMlFWWXdQM1dubHZDNDB4Vld2aXlNSEFtWmxKZzdkdzgKK1ZUbEZSRVA5YVRZS3czSk10QnZlOVM1RXU1MnZ3ZW1FSGxCZDFOWFlVNHB3U08xNDhieCtzWnQrNWlkNk9BbAp6ck9UaUhKQXJzcTFvc0sxTEFmTXh6K0lvR015SG5WUjNlaXI5SnQxSWdPYTNWemJvN01qWGFhOHg0bG1MRlllCnpiN0R6dHBqdTdyYzlKZVdMRVdkbkNWL2NjVzVmTzBXU3RKZzlTVUVDZHc3MUs4VVBYQ2FxUmRKMTB4SDJoVGwKjalVFVDlJYjk0emgvL0dkMUp3RFlsNFJkWUZKR2FSYW5mQlFVRzhBck1iUmVDWmdrYTJIY0ZtUDJtdnBidGYxUQo3ZEtQdElsb3ZqSVRWblBGNURzQ0F3RUFBYU5GTUVNd0RnWURWUjBQQVFIL0JBUURBZ0trTUJJR0ExVWRFd0VCCi93UUlNQVlCQWY4Q0FRQXdIUVlEVlIwT0JCWUVGTWdTamRrd3lMOFhNVkdvbU1FV1ZOT2hEYzBCTUEwR0NTcUcKU0liM0RRRUJDd1VBQTRJQkFRQnkvTDZPTTFZK0JUWUJGRVY3TDRLeHNWaHFzZVk3UGZidnhiQWhObVFleEJlSQpYMmRxVDVyQlFaa1NXS3E3cEk3d1Q0Ny81QkN0UUtZQnpNSzRzR2NKQXVza2NHM09UejFRSzIxdEczMXp4N2lMCngrT0dDYzV0ZTR4Vydila3VCczdnN0RkNmlLRFFrbjZia1FTKzhvcFJ6SC9FU0pHUm5adXFlYTBGRW8wc1BuaW0KSGZlV3JTRm5OUk1YQ1J6dkRFY2V5cThjZ21WVEhHRGhOd2tJSTRrQ0F3ZWRnVi9vYlhjSEluZVl6SGhZVVpmbgpkbWhVSjNNVDNHZVErYkhsZFZoNjQyTXB6R2VQRTdPVzNvdjN5aFV4WUpaZ3BWZUU4a2VLTGc3YTcvd2FmeG5jCngxRWY2ekRNdlgrV2gwUjIyM3hPTldWUElPYUNyWUxoM0lHYkxCMTUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://5.172.178.181:6443 + name: wheel +contexts: +- context: + cluster: wheel + user: tazet@narod.ru + name: tazet@narod.ru@wheel +current-context: tazet@narod.ru@wheel +kind: Config +preferences: {} +users: +- name: tazet@narod.ru + user: + token: eyJhbGciOiJSUzI1NiIsImtpZCI6InNoIiwidHlwIjoiSldUIn0.eyJhdWQiOiJiYWNrZW5kIiwiZXhwIjoxNzc1ODk5ODc2LCJqdGkiOiI5NjE4Yzg4NC05NjQ5LTQ1NjQtYjNjMi03Njc0Zjg5ZDAzMDciLCJpYXQiOjE3NzU4MTM0NzYsImlzcyI6Imh0dHBzOi8vYXV0aC5rOHMubmdjbG91ZC5ydSIsInN1YiI6InRhemV0QG5hcm9kLnJ1IiwiY2xhaW1zIjpbIiIsIiIsIiIsIjU1YzQ5MTRlLTdiYzUtNGJjYy1iYzcxLTI0NDQwMjViYzZmY19BRE1JTiIsIldaMDEzMjUtc2h0dXJ2YWwtYWRtaW4tdGVzdGVyIiwiV1owMTMyNS1zaHR1cnZhbC1hZG1pbi13aGVlbCJdLCJuYW1lIjoi0KLQsNC30LXRgtC00LjQvdC-0LIg0J3QsNC40LvRjCIsImVtYWlsIjoidGF6ZXRAbmFyb2QucnUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0YXpldEBuYXJvZC5ydSJ9.GiPjJonDXxxu4U6QE4LVPc7rMV8I_0Ubr8dugNo8g4HeCwh5Y0MtWYsPhQ4GOB2A2Fy_43ItVIlMypmG9ZwVzhaRxfmNIAuNlSV-N49x_C9zGqIOagL2Us6UVr30XCYdfgcSiN2fQGx5F-LYxCMOyePA_vxyAl8dYFKT2lZmH4poOxJjgkwclZ9tej7NKcfFy31PmJu26li6LE_8TzaNN130woO0Rat3bvHfHRLk_eKj-0iAlXD5a_rt3QEli2wyhxi7DZo5ohaJ0PjhNIrdqkghBLM0KjUERRHXVyhSBUEWjsz8OAXwItmaMb6iOH_Wv50vPPLmZ_KVm8FdkQD1Qg