From 8a8cd66f594c87a07ba1392f00b2172d117f9cb0 Mon Sep 17 00:00:00 2001 From: emelinda Date: Mon, 3 Aug 2026 14:49:22 +0300 Subject: [PATCH] feat(ansible): add GitOps foundation (k3s, gitea, vault, and FluxCD) with multi-wave deployment support --- .env.example | 18 +- .gitignore | 15 + aero/README.md | 97 +++++++ aero/pyproject.toml | 12 + aero/roles/sarex_stack/defaults/main.yml | 46 ++++ aero/roles/sarex_stack/tasks/main.yml | 30 ++ aero/roles/sarex_stack/tasks/platform.yml | 83 ++++++ docker-compose.yaml | 318 ++++++++++++++++++++-- k3s/manifests/flux/bridge-gitea.yaml | 39 +++ vault/config/vault.hcl | 22 ++ 10 files changed, 655 insertions(+), 25 deletions(-) create mode 100644 aero/roles/sarex_stack/tasks/platform.yml create mode 100644 k3s/manifests/flux/bridge-gitea.yaml create mode 100644 vault/config/vault.hcl diff --git a/.env.example b/.env.example index cc9a813..120ba5b 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -K3S_TOKEN=gVadzB0OtWGDAM9rmlZvHiwfITsP8bxo +K3S_TOKEN=changeme # Внешний адрес (IP или домен), по которому к kube-apiserver обращаются извне. # Попадёт в TLS-сертификат (--tls-san). Без него внешний kubectl упадёт на x509. @@ -48,9 +48,25 @@ PLATFORM_DOMAIN=sarex.local # основной домен платфо ADMIN_DOMAIN=admin.sarex.local # RabbitMQ management UI MINIO_DOMAIN=minio.sarex.local # MinIO: S3 API (/) + консоль (/console/) +# --- GitOps-подложка: gitea (источник правды) + Vault + FluxCD в k3s --- +# Админ gitea заводится контейнером gitea-init; этими же кредами flux-bootstrap +# авторизуется при push'е манифестов. Пароль генерирует роль sarex_stack. +GITEA_ADMIN_USER=sarex +GITEA_ADMIN_PASSWORD=changeme +GITEA_ADMIN_EMAIL=sarex@sarex.local +# Организация и репозиторий, которые gitea-init создаёт, а Flux реконсилирует. +GITEA_ORG=infra +GITEA_REPO=iac +# Ветка и путь внутри репозитория, за которыми следит Flux. +AERO_GIT_BRANCH=master +AERO_FLUX_PATH=clusters/aero + # --- Docker-образы инфраструктуры (тег/реестр можно переопределить) --- K3S_IMAGE=rancher/k3s:v1.36.2-k3s1 GITEA_IMAGE=gitea/gitea:1.22 +VAULT_IMAGE=hashicorp/vault:1.18 +# Версия CLI должна совпадать с версией контроллеров Flux в остальных кластерах. +FLUX_CLI_IMAGE=ghcr.io/fluxcd/flux-cli:v2.8.5 POSTGRES_IMAGE=postgres:12-alpine NGINX_IMAGE=nginx:1.27-alpine SAREX_REDIS_IMAGE=cr.yandex/crp3ccidau046kdj8g9q/redis:7.0.10 diff --git a/.gitignore b/.gitignore index 3f8bd36..04344df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,22 @@ .claude CLAUDE.md .env +.env.* +!.env.example tmp .tmp/ snapshots/* + +# --- Рантайм-артефакты контура aero (создаются docker-compose на хосте) --- +gitea-data/ # данные gitea: репозитории, sqlite +k3s-storage/ # PVC-данные local-path-provisioner +k3s/kubeconfig.yaml # kubeconfig, который пишет k3s-server +nginx/certs/ # самоподписанные TLS-сертификаты + +# --- Секреты и ключи: не коммитить никогда --- +*.pem +*.key +*.p12 +kubeconfig* +*service-account*.json diff --git a/aero/README.md b/aero/README.md index 1cb32e2..0d631b7 100644 --- a/aero/README.md +++ b/aero/README.md @@ -63,10 +63,13 @@ uv run poe install | `uv run poe ping` | проверка SSH + sudo | | `uv run poe provision` | базовые пакеты + docker/containerd + запуск службы | | `uv run poe deploy` | деплой конфигурации: файлы, `.env`, сертификаты, `docker login` | +| `uv run poe platform` | деплой + поднять GitOps-подложку (k3s + gitea + vault + Flux) | | `uv run poe stack` | деплой + поднять стек (`docker compose up -d`) | | `uv run poe gen-env` | только сгенерировать пароли и `.env` | | `uv run poe login` | только `docker login` в реестр по ключу | | `uv run poe superuser` | сгенерировать логин+пароль и создать Django-суперпользователя | +| `uv run poe flux-status` | состояние FluxCD в k3s | +| `uv run poe vault-status` | состояние Vault (инициализирован / распечатан) | | `uv run poe check` / `deploy-check` | dry-run (`--check --diff`) | Поднимаемые сервисы задаются в `sarex_services` (роль `sarex_stack`) — @@ -77,6 +80,100 @@ uv run poe install > (через `/mnt/c`) права `0777`, и ansible игнорирует `ansible.cfg` в > world-writable каталоге, поэтому путь к конфигу передаётся явно. +### GitOps-подложка контура (k3s + gitea + vault + FluxCD) + +Целевая схема контура — не «всё в compose», а **compose как подложка, k3s как +среда выполнения**. Источником правды становится gitea внутри контура, а +раскаткой занимается FluxCD. + +``` +docker compose k3s (4 ноды в контейнерах) +├── k3s-server ──► нода ┐ +├── k3s-worker-1..3 ──► ноды ├─► flux-system (controllers) +├── gitea (172.28.0.12) ◄────── ┘ и всё, что опишем в clusters/aero +│ └── infra/iac.git +├── vault (+init) +├── flux-k8s-init ──► namespace flux-system + Service-мост до gitea +└── flux-bootstrap ──► ставит Flux в k3s и привязывает к gitea +``` + +**Мост до gitea.** Контроллеры Flux работают внутри k3s, где резолвит CoreDNS, +а имён compose-сети там нет. Поэтому `flux-k8s-init` заводит в namespace +`flux-system` Service без селектора + Endpoints на статический IP gitea — тем же +приёмом, что `bridge-postgres`/`bridge-minio` для processing. Источник в +`GitRepository` указан именем `gitea.flux-system.svc.cluster.local`, а адрес +задан ровно в одном месте — переменной `GITEA_BRIDGE_IP` (она же `ipv4_address` +сервиса `gitea`). Контейнер `flux-bootstrap` резолвит то же имя через +`extra_hosts` — иначе flux CLI не смог бы запушить манифесты. + +**Имена нод закреплены** через `hostname:` в compose. Это не косметика: k3s +берёт имя ноды из hostname, иначе им становится ID контейнера. Local-path +привязывает PV к ноде через `nodeAffinity` по имени, и после пересоздания +контейнера тома «повисли» бы. + +### Хранилище PVC + +`StorageClass local-path` (встроенный в k3s, он же default). Дефолтный путь +провижинера `/var/lib/rancher/k3s/storage` перекрыт bind-mount'ом на хост — +поэтому настраивать ConfigMap `local-path-config` не нужно: + +``` +{{ deploy_dir }}/k3s-storage/ +├── server/ ← PVC, севшие на k3s-server +├── worker-1/ +├── worker-2/ +└── worker-3/ +``` + +Файлы лежат на хосте обычными каталогами: переживают пересоздание контейнера +k3s и `docker compose down -v`, бэкапятся штатными средствами. + +> **Следствие для stateful-сервисов.** Хранилище **node-local**: PVC привязан к +> той ноде, где под запустился первым. При переезде postgres/redis/rabbitmq/minio +> в k3s их нужно явно прибивать к конкретной ноде (`nodeSelector`), иначе данные +> размажутся по четырём каталогам, а потеря ноды сделает том недоступным. +> Бэкап должен покрывать все каталоги `k3s-storage/*`. + +```bash +uv run poe platform # поднять подложку +uv run poe flux-status # убедиться, что Flux реконсилирует +``` + +Что происходит по шагам: + +1. **Волна 1** — стартуют `k3s-server`, три воркера и `gitea`. Роль ждёт, пока + k3s запишет `./k3s/kubeconfig.yaml` (и что файл непустой) — иначе + `flux-bootstrap` смонтировал бы каталог вместо файла. +2. **`gitea-init`** — одноразовый: заводит администратора (`gitea admin user + create`), организацию и пустой репозиторий через API. Идемпотентен. +3. **`flux-k8s-init`** — одноразовый: namespace `flux-system` и Service-мост до + gitea (см. выше). Обязан отработать до bootstrap. +4. **`vault` + `vault-init`** — Vault на file-хранилище. `vault-init` живёт + постоянно: инициализирует Vault одним ключом, распечатывает его и включает + `kv-v2` на пути `secrets/` (тот же путь, что в k8s-контурах). После ребута + хоста Vault поднимается запечатанным — цикл распечатывает его сам. +5. **`flux-bootstrap`** — одноразовый: `flux bootstrap git` ставит контроллеры в + k3s и пушит их манифесты в gitea в `clusters/aero/flux-system`. + +> **FluxCD не работает в docker-compose** — это контроллеры Kubernetes. В compose +> живёт только установщик; после его успеха Flux работает внутри k3s. + +Секреты подложки (`GITEA_ADMIN_PASSWORD`, `K3S_TOKEN`) генерируются так же, как +остальные — в `aero/.secrets//`. Unseal-ключ и root-токен Vault лежат на +отдельном docker-томе `sarex-vault-init` с правами `600` и **в git не попадают**. + +#### Границы текущего этапа + +Сделана только подложка. Осознанно **не** сделано: + +- Репозиторий в gitea содержит лишь `clusters/aero/flux-system` — то, что запушил + bootstrap. Наполнение (`apps/`, `infrastructure/`) и перенос прикладных сервисов + из compose в k3s — следующий этап. +- Vault поднят и распечатан, но **kubernetes auth не настроен**: для него нужен + Vault Agent Injector внутри k3s, а он приедет уже через Flux. Пока в Vault + можно только класть секреты, поды их ещё не читают. +- Прикладной стек (`sarex_services`) не тронут и продолжает работать в compose. + ### Развёртывание sarex-стека (роль `sarex_stack`) Копирует `docker-compose.yaml`, `nginx/templates` и `backend/uwsgi.ini` в diff --git a/aero/pyproject.toml b/aero/pyproject.toml index cb6b1d4..ef3a360 100644 --- a/aero/pyproject.toml +++ b/aero/pyproject.toml @@ -36,6 +36,10 @@ deploy = "ansible-playbook deploy.yml" deploy-check = "ansible-playbook deploy.yml --check --diff" # Деплой + поднять стек приложения (docker compose up -d) stack = "ansible-playbook deploy.yml -e sarex_compose_up=true" +# Деплой + поднять GitOps-подложку: k3s + gitea + vault + bootstrap FluxCD. +# Без --tags намеренно: подложке нужны .env, docker-compose.yaml и vault/config, +# которые кладут нетегированные задачи роли (при --tags они были бы пропущены). +platform = "ansible-playbook deploy.yml -e aero_platform_up=true" # Только генерация паролей и рендер .env на хосте (без копирования/сертификатов) gen-env = "ansible-playbook deploy.yml --tags env" # docker login в приватный реестр по ключу json_key @@ -53,6 +57,14 @@ up = "ansible-playbook deploy.yml -e sarex_compose_up=true --tags up" cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose ps chdir=/root/sarex'" help = "Статус стека на хосте (docker compose ps)" +[tool.poe.tasks.flux-status] +cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose exec -T k3s-server kubectl -n flux-system get kustomizations,gitrepositories,pods chdir=/root/sarex'" +help = "Состояние FluxCD в k3s (kustomizations, источники, поды)" + +[tool.poe.tasks.vault-status] +cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose exec -T vault-init vault status chdir=/root/sarex'" +help = "Состояние Vault (инициализирован / распечатан)" + [tool.poe.tasks.pull] cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose pull ${service} chdir=/root/sarex'" help = "Стянуть образы: uv run poe pull [service]" diff --git a/aero/roles/sarex_stack/defaults/main.yml b/aero/roles/sarex_stack/defaults/main.yml index 3aa0fb7..96a4283 100644 --- a/aero/roles/sarex_stack/defaults/main.yml +++ b/aero/roles/sarex_stack/defaults/main.yml @@ -17,6 +17,7 @@ cert_dir: "{{ deploy_dir }}/nginx/certs" # Переменные-пароли: им генерируются стойкие значения взамен слабых дефолтов. sarex_secret_vars: - K3S_TOKEN + - GITEA_ADMIN_PASSWORD - SAREX_POSTGRES_PASSWORD - SAREX_DJANGO_DB_PASSWORD - SAREX_PROCESSING_DB_PASSWORD @@ -46,6 +47,51 @@ registry_key_src: /mnt/c/Users/user/.ssh/local/authorized_key.json sarex_compose_up: false compose_cmd: "docker compose" +# --- GitOps-подложка контура (k3s + gitea + vault + flux) ------------------- +# Параметры gitea и Flux. Попадают в .env (их читает docker-compose) — менять +# нужно здесь, а не в .env.example, иначе перезапишется при следующем деплое. +gitea_admin_user: sarex +gitea_admin_email: "{{ gitea_admin_user }}@{{ platform_domain }}" +gitea_org: infra +gitea_repo: iac +# Ветка и путь, за которыми следит Flux внутри репозитория в gitea. +aero_git_branch: master +aero_flux_path: clusters/aero + +# Поднимается отдельно от прикладного стека: образы публичные, docker login в +# cr.yandex не нужен. По умолчанию выключено, включает `uv run poe platform`. +aero_platform_up: false + +# Волна 1: долгоживущие сервисы, которым нужно успеть стать healthy. +# k3s-server при первом старте создаёт ./k3s/kubeconfig.yaml — его ждёт волна 2. +aero_platform_services_wave1: + - k3s-server + - k3s-worker-1 + - k3s-worker-2 + - k3s-worker-3 + - gitea + +# Волна 2: провижининг и bootstrap. gitea-init, flux-k8s-init и flux-bootstrap +# одноразовые (restart: "no"), vault-init живёт постоянно и распечатывает Vault +# после ребута хоста. +aero_platform_services_wave2: + - gitea-init + - flux-k8s-init + - vault + - vault-init + - flux-bootstrap + +# Ноды k3s. Каждой нужен свой каталог PVC-хранилища на хосте: провижинер +# local-path node-local, и PV навсегда привязывается к ноде через nodeAffinity. +aero_k3s_nodes: + - server + - worker-1 + - worker-2 + - worker-3 + +# Сколько ждать появления kubeconfig от k3s-server, секунд. +aero_kubeconfig_timeout: 180 + # Сервисы приложения, поднимаемые при sarex_compose_up (depends_on тянет # зависимости и порядок). k3s/gitea намеренно не поднимаем. sarex_services: diff --git a/aero/roles/sarex_stack/tasks/main.yml b/aero/roles/sarex_stack/tasks/main.yml index a7a2f96..4765e31 100644 --- a/aero/roles/sarex_stack/tasks/main.yml +++ b/aero/roles/sarex_stack/tasks/main.yml @@ -50,6 +50,22 @@ dest: "{{ deploy_dir }}/engine/" mode: "0644" +# vault монтирует ./vault/config как :ro — без конфига сервер не стартует. +- name: Скопировать конфигурацию Vault + ansible.builtin.copy: + src: "{{ deploy_src_root }}/vault/config/" + dest: "{{ deploy_dir }}/vault/config/" + mode: "0644" + +# Каталоги PVC-данных k3s — по одному на ноду. Создаём заранее: иначе docker +# создаст их от root с umask демона, а local-path пишет туда от имени подов. +- name: Создать каталоги PVC-хранилища k3s (по ноде) + ansible.builtin.file: + path: "{{ deploy_dir }}/k3s-storage/{{ item }}" + state: directory + mode: "0755" + loop: "{{ aero_k3s_nodes }}" + # --- Секреты: генерация с персистом на control-node (идемпотентно) -------- - name: Сгенерировать/загрузить пароли (persist в secrets_store, gitignored) ansible.builtin.set_fact: @@ -88,6 +104,12 @@ 'ADMIN_DOMAIN': admin_domain, 'MINIO_DOMAIN': minio_domain, 'SAREX_MINIO_CONSOLE_URL': 'https://' ~ minio_domain, + 'GITEA_ADMIN_USER': gitea_admin_user, + 'GITEA_ADMIN_EMAIL': gitea_admin_email, + 'GITEA_ORG': gitea_org, + 'GITEA_REPO': gitea_repo, + 'AERO_GIT_BRANCH': aero_git_branch, + 'AERO_FLUX_PATH': aero_flux_path, 'SAREX_JWT_PRIVATE_KEY': "'" ~ (lookup('file', jwt_private_key_path) | replace('\n', '\\n')) ~ "'", 'SAREX_JWT_PUBLIC_KEY': "'" ~ (lookup('file', jwt_public_key_path) | replace('\n', '\\n')) ~ "'", }) }} @@ -157,6 +179,14 @@ state: absent tags: [login] +# --- GitOps-подложка: k3s + gitea + vault + Flux -------------------------- +# Отдельный тег и отдельный флаг: подложка поднимается публичными образами и +# не зависит от docker login, поэтому её можно накатывать независимо от стека. +- name: Поднять GitOps-подложку контура + ansible.builtin.include_tasks: platform.yml + when: aero_platform_up | bool + tags: [platform] + # --- Поднять сервисы приложения ------------------------------------------ - name: Поднять сервисы приложения (docker compose up -d) ansible.builtin.command: diff --git a/aero/roles/sarex_stack/tasks/platform.yml b/aero/roles/sarex_stack/tasks/platform.yml new file mode 100644 index 0000000..84b4b88 --- /dev/null +++ b/aero/roles/sarex_stack/tasks/platform.yml @@ -0,0 +1,83 @@ +--- +# Подъём GitOps-подложки контура: k3s + gitea + vault + bootstrap FluxCD. +# Отделено от прикладного стека (sarex_services) намеренно: здесь только +# публичные образы, docker login в приватный реестр не требуется. +# +# Порядок важен и поэтому разбит на две волны: flux-bootstrap монтирует +# ./k3s/kubeconfig.yaml, который k3s-server создаёт только при первом старте. +# Если поднять всё одной командой, docker может создать на месте отсутствующего +# файла директорию, и bootstrap упадёт с невнятной ошибкой. + +- name: Волна 1 — поднять k3s-server и gitea + ansible.builtin.command: + cmd: "{{ compose_cmd }} up -d {{ aero_platform_services_wave1 | join(' ') }}" + chdir: "{{ deploy_dir }}" + changed_when: true + +- name: Дождаться kubeconfig от k3s-server + ansible.builtin.wait_for: + path: "{{ deploy_dir }}/k3s/kubeconfig.yaml" + state: present + timeout: "{{ aero_kubeconfig_timeout }}" + +# Пустой файл означает, что k3s-server ещё дописывает kubeconfig. Без этой +# проверки flux-bootstrap может стартовать с нулевым конфигом. +- name: Убедиться, что kubeconfig не пустой + ansible.builtin.wait_for: + path: "{{ deploy_dir }}/k3s/kubeconfig.yaml" + search_regex: "clusters:" + timeout: 60 + +- name: Волна 2 — gitea-init, vault(+init) и bootstrap Flux + ansible.builtin.command: + cmd: "{{ compose_cmd }} up -d {{ aero_platform_services_wave2 | join(' ') }}" + chdir: "{{ deploy_dir }}" + changed_when: true + +# flux-bootstrap одноразовый: успех = код 0. Забираем его явно, иначе ошибка +# bootstrap'а останется незамеченной — `compose up -d` о ней не сообщает. +# +# `docker compose wait` здесь не годится: для уже завершившегося контейнера он +# возвращает rc=1 и "no containers for project". Так как `up -d` не дожидается +# одноразового сервиса, состояние гоночное — поэтому опрашиваем `ps -a`, +# который корректно отвечает и для running, и для exited. +# +# Формат обёрнут в {% raw %}: это Go-шаблон docker'а, и без raw Jinja попыталась +# бы раскрыть {{.State.Status}} как свою переменную. Аргументы передаём списком — +# у `command` нет шелла, и строка со скобками разбилась бы по пробелу. +- name: Дождаться завершения flux-bootstrap + ansible.builtin.command: + argv: + - "{{ compose_cmd.split()[0] }}" + - inspect + - "-f" + - "{% raw %}{{.State.Status}} {{.State.ExitCode}}{% endraw %}" + - flux-bootstrap + register: aero_flux_bootstrap + changed_when: false + retries: 30 + delay: 10 + until: aero_flux_bootstrap.stdout | trim is not match('^running') + +- name: Показать логи flux-bootstrap при ошибке + ansible.builtin.command: + cmd: "{{ compose_cmd }} logs --no-color --tail 50 flux-bootstrap" + chdir: "{{ deploy_dir }}" + register: aero_flux_logs + changed_when: false + when: aero_flux_bootstrap.stdout.split() | last != "0" + +- name: Прервать деплой, если bootstrap не удался + ansible.builtin.fail: + msg: | + flux bootstrap завершился с кодом {{ aero_flux_bootstrap.stdout.split() | last }}. + Логи контейнера: + {{ aero_flux_logs.stdout | default('(недоступны)') }} + when: aero_flux_bootstrap.stdout.split() | last != "0" + +- name: Итог + ansible.builtin.debug: + msg: + - "gitea: http://{{ ansible_host }}:3000 (пользователь {{ gitea_admin_user }}, пароль в aero/.secrets/{{ inventory_hostname }}/GITEA_ADMIN_PASSWORD)" + - "Flux реконсилирует {{ gitea_org }}/{{ gitea_repo }} → {{ aero_flux_path }}" + - "Проверка: docker compose exec k3s-server kubectl -n flux-system get kustomizations" diff --git a/docker-compose.yaml b/docker-compose.yaml index 111b268..32246b7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,33 @@ +# Общая конфигурация воркер-нод k3s — см. сервисы k3s-worker-1..3. +# У каждой ноды СВОЙ том состояния и СВОЙ каталог PVC-хранилища: провижинер +# local-path кладёт данные на ту ноду, где под запустился первым, и через +# nodeAffinity навсегда привязывает к ней PV. +x-k3s-worker: &k3s-worker + image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1} + command: ["agent"] + privileged: true + cgroup: host # kubelet в cgroup v2 (Ubuntu 22.04+/RedOS) + restart: unless-stopped + depends_on: + k3s-server: + condition: service_healthy + environment: + K3S_URL: https://k3s-server:6443 + K3S_TOKEN: ${K3S_TOKEN:-supersecrettoken} + tmpfs: + - /run + - /var/run + ulimits: + nproc: 65535 + nofile: + soft: 65535 + hard: 65535 + services: k3s-server: image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1} container_name: k3s-server + hostname: k3s-server # имя ноды в кластере — см. комментарий у воркеров command: - server - --tls-san=127.0.0.1 @@ -22,6 +48,15 @@ services: volumes: - k3s-server-data:/var/lib/rancher/k3s # etcd/state, образы, манифесты — только named volume - ./k3s:/output:z # отсюда забираем контекст k8s (:z — SELinux label для RedOS) + # PVC-данные local-path-provisioner (StorageClass local-path, он же default + # в k3s). Перекрываем ровно дефолтный путь провижинера, поэтому настраивать + # ConfigMap local-path-config не нужно. Bind-mount на хост означает, что + # файлы БД/очередей/объектов переживают пересоздание контейнера k3s и + # доступны для бэкапа обычными средствами хоста. + # Каталог намеренно не вложен в ./k3s: тот смонтирован ещё и как /output, + # и данные PVC засветились бы там же. У каждой ноды свой подкаталог, иначе + # сервер видел бы каталоги воркеров как посторонние PV в своём storage. + - ./k3s-storage/server:/var/lib/rancher/k3s/storage:z tmpfs: - /run - /var/run @@ -37,30 +72,32 @@ services: retries: 30 start_period: 30s - k3s-worker: - image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1} - container_name: k3s-worker - command: - - agent - privileged: true - cgroup: host # kubelet в cgroup v2 (Ubuntu 22.04+/RedOS) - restart: unless-stopped - depends_on: - k3s-server: - condition: service_healthy - environment: - K3S_URL: https://k3s-server:6443 - K3S_TOKEN: ${K3S_TOKEN:-supersecrettoken} + # hostname задаёт ИМЯ НОДЫ в кластере (k3s берёт его из hostname). Без него + # нодой становится ID контейнера, а он меняется при пересоздании — и тогда + # local-path PV, привязанные к старому имени через nodeAffinity, «повисают». + k3s-worker-1: + <<: *k3s-worker + container_name: k3s-worker-1 + hostname: k3s-worker-1 volumes: - - k3s-worker-data:/var/lib/rancher/k3s - tmpfs: - - /run - - /var/run - ulimits: - nproc: 65535 - nofile: - soft: 65535 - hard: 65535 + - k3s-worker-1-data:/var/lib/rancher/k3s + - ./k3s-storage/worker-1:/var/lib/rancher/k3s/storage:z + + k3s-worker-2: + <<: *k3s-worker + container_name: k3s-worker-2 + hostname: k3s-worker-2 + volumes: + - k3s-worker-2-data:/var/lib/rancher/k3s + - ./k3s-storage/worker-2:/var/lib/rancher/k3s/storage:z + + k3s-worker-3: + <<: *k3s-worker + container_name: k3s-worker-3 + hostname: k3s-worker-3 + volumes: + - k3s-worker-3-data:/var/lib/rancher/k3s + - ./k3s-storage/worker-3:/var/lib/rancher/k3s/storage:z # Одноразовый провижининг k3s: namespace processing + DNS-мост postgres/minio. # Образ k3s содержит kubectl; server/tls перекрываем на k3s-server:6443 @@ -82,6 +119,11 @@ services: - ./k3s/kubeconfig.yaml:/kube/config:ro,z - ./k3s/manifests/processing:/manifests:ro,z + # --------------------------------------------------------------------------- + # GitOps-подложка контура: gitea (источник) + vault (секреты) + flux (в k3s). + # Порядок: gitea → gitea-init (админ/орг/репо) → flux-bootstrap (ставит Flux + # в k3s и привязывает его к репозиторию в gitea). + # --------------------------------------------------------------------------- gitea: image: ${GITEA_IMAGE:-gitea/gitea:1.22} container_name: gitea @@ -91,6 +133,11 @@ services: USER_GID: 1000 GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3000/} GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222} + # Пропускаем web-визард установки: конфигурация задаётся здесь, админа + # заводит gitea-init. Без INSTALL_LOCK первый запрос уводит на /install. + GITEA__security__INSTALL_LOCK: "true" + GITEA__database__DB_TYPE: sqlite3 + GITEA__service__DISABLE_REGISTRATION: "true" ports: - "3000:3000" # web UI (80/443 заняты k3s) - "${GITEA_SSH_PORT:-2222}:22" # git over ssh @@ -102,6 +149,225 @@ services: timeout: 5s retries: 12 start_period: 20s + # Статический IP по образцу postgres/minio. Он обязателен: source-controller + # Flux работает ВНУТРИ k3s, где своя DNS (CoreDNS), и имя compose-сети + # `gitea` там не резолвится. Поды k3s дотягиваются до 172.28.0.0/16 через + # SNAT на ноде, поэтому в URL источника используется именно этот адрес. + networks: + default: + ipv4_address: 172.28.0.12 + + # Одноразовый провижининг gitea: администратор + организация + пустой репозиторий. + # Делит /data с gitea (CLI пишет в ту же sqlite), поэтому запускается тем же uid. + # Все шаги идемпотентны: повторный прогон на готовой инсталляции — no-op. + gitea-init: + image: ${GITEA_IMAGE:-gitea/gitea:1.22} + container_name: gitea-init + restart: "no" + user: "1000:1000" + depends_on: + gitea: + condition: service_healthy + environment: + GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-sarex} + GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:?GITEA_ADMIN_PASSWORD is required} + GITEA_ADMIN_EMAIL: ${GITEA_ADMIN_EMAIL:-sarex@sarex.local} + GITEA_ORG: ${GITEA_ORG:-infra} + GITEA_REPO: ${GITEA_REPO:-iac} + volumes: + - ./gitea-data:/data:z + entrypoint: ["/bin/sh", "-ec"] + command: + - | + export GITEA_WORK_DIR=/data/gitea + CONF=/data/gitea/conf/app.ini + + # 1. Администратор. Существующий пользователь → ненулевой код, это норма. + gitea --config "$$CONF" admin user create \ + --admin --username "$$GITEA_ADMIN_USER" --password "$$GITEA_ADMIN_PASSWORD" \ + --email "$$GITEA_ADMIN_EMAIL" --must-change-password=false 2>&1 \ + | grep -v 'already exists' || true + + API=http://gitea:3000/api/v1 + AUTH="$$GITEA_ADMIN_USER:$$GITEA_ADMIN_PASSWORD" + + # 2. Организация. 422 = уже существует. + curl -fsS -u "$$AUTH" -X POST "$$API/orgs" \ + -H 'Content-Type: application/json' \ + -d "{\"username\":\"$$GITEA_ORG\"}" >/dev/null 2>&1 || true + + # 3. Репозиторий. auto_init=false — первый коммит сделает flux bootstrap. + curl -fsS -u "$$AUTH" -X POST "$$API/orgs/$$GITEA_ORG/repos" \ + -H 'Content-Type: application/json' \ + -d "{\"name\":\"$$GITEA_REPO\",\"private\":true,\"auto_init\":false}" \ + >/dev/null 2>&1 || true + + # Проверяем, что репозиторий действительно доступен — иначе flux bootstrap + # упадёт позже и менее внятно. + curl -fsS -u "$$AUTH" "$$API/repos/$$GITEA_ORG/$$GITEA_REPO" >/dev/null + echo "gitea-init: $$GITEA_ORG/$$GITEA_REPO готов" + + vault: + image: ${VAULT_IMAGE:-hashicorp/vault:1.18} + container_name: vault + restart: unless-stopped + # Только "server", без -config: entrypoint образа сам добавляет + # -config=/vault/config. Явный путь к файлу внутри этого же каталога + # приводит к двойной загрузке конфига и падению на + # "listen tcp4 0.0.0.0:8200: bind: address already in use". + command: ["server"] + cap_add: + - IPC_LOCK + environment: + VAULT_ADDR: http://127.0.0.1:8200 + volumes: + # :ro осознанно — entrypoint пишет в лог безвредные "chown: Read-only + # file system", но конфиг остаётся неизменяемым изнутри контейнера. + - ./vault/config:/vault/config:ro,z + - sarex-vault-data:/vault/file + - sarex-vault-init:/vault/init + healthcheck: + # /v1/sys/health отдаёт 200 только когда Vault инициализирован и распечатан + # (501 — не инициализирован, 503 — запечатан). Значит healthy здесь + # означает «готов принимать секреты», и на это можно вешать depends_on. + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8200/v1/sys/health"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 10s + + # Инициализация и авто-распечатывание Vault. Живёт постоянно: после рестарта + # хоста Vault поднимается запечатанным, и цикл распечатывает его сам. + # Ключ и root-токен лежат на отдельном volume (sarex-vault-init), не в git. + vault-init: + image: ${VAULT_IMAGE:-hashicorp/vault:1.18} + container_name: vault-init + restart: unless-stopped + depends_on: + vault: + condition: service_started + environment: + VAULT_ADDR: http://vault:8200 + volumes: + - sarex-vault-init:/vault/init + entrypoint: ["/bin/sh", "-ec"] + command: + - | + UNSEAL=/vault/init/unseal.key + ROOT=/vault/init/root.token + + while :; do + # `vault operator init -status`: 0 — инициализирован, 2 — нет, + # прочее — Vault ещё не отвечает. Под `set -e` ненулевой код нужно + # перехватить явно, иначе скрипт завершится. + rc=0 + vault operator init -status >/dev/null 2>&1 || rc=$$? + + if [ "$$rc" -eq 0 ]; then + # Инициализирован. `vault status`: 0 — распечатан, 2 — запечатан. + if ! vault status >/dev/null 2>&1; then + if [ -s "$$UNSEAL" ]; then + vault operator unseal "$$(cat $$UNSEAL)" >/dev/null \ + && echo "vault-init: распечатан" + else + echo "vault-init: Vault запечатан, но $$UNSEAL отсутствует —" \ + "распечатать нечем (том sarex-vault-init потерян?)" >&2 + fi + fi + + elif [ "$$rc" -eq 2 ]; then + # Отвечает, но не инициализирован — инициализируем одним ключом. + # Текстовый вывод парсим awk'ом: jq в образе нет. + if vault operator init -key-shares=1 -key-threshold=1 > /vault/init/init.txt 2>/dev/null; then + awk '/^Unseal Key 1:/{print $$4}' /vault/init/init.txt > "$$UNSEAL" + awk '/^Initial Root Token:/{print $$4}' /vault/init/init.txt > "$$ROOT" + chmod 600 "$$UNSEAL" "$$ROOT" /vault/init/init.txt + vault operator unseal "$$(cat $$UNSEAL)" >/dev/null + echo "vault-init: инициализирован и распечатан" + + # kv-v2 на пути secrets/ — так же, как в k8s-контурах + # (манифесты ссылаются на secrets/data/...). + VAULT_TOKEN="$$(cat $$ROOT)" vault secrets enable -path=secrets kv-v2 \ + >/dev/null 2>&1 && echo "vault-init: включён kv-v2 на secrets/" + fi + fi + + sleep 10 + done + + # Одноразовый провижининг namespace flux-system и DNS-моста до gitea. + # Должен отработать ДО flux-bootstrap: тот дожидается готовности GitRepository, + # а она невозможна, пока source-controller не может отрезолвить источник. + flux-k8s-init: + image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1} + container_name: flux-k8s-init + restart: "no" + depends_on: + k3s-server: + condition: service_healthy + environment: + GITEA_BRIDGE_IP: ${GITEA_BRIDGE_IP:-172.28.0.12} + volumes: + - ./k3s/kubeconfig.yaml:/kube/config:ro,z + - ./k3s/manifests/flux:/manifests:ro,z + entrypoint: ["/bin/sh", "-ec"] + command: + - | + sed "s|__GITEA_BRIDGE_IP__|$$GITEA_BRIDGE_IP|g" /manifests/bridge-gitea.yaml \ + | kubectl --kubeconfig /kube/config \ + --server https://k3s-server:6443 --insecure-skip-tls-verify \ + apply -f - + + # Одноразовый bootstrap FluxCD внутрь k3s с привязкой к репозиторию в gitea. + # Flux — это контроллеры Kubernetes, в compose живёт только этот установщик. + # После успешного прогона источником правды становится gitea, а не compose. + flux-bootstrap: + image: ${FLUX_CLI_IMAGE:-ghcr.io/fluxcd/flux-cli:v2.8.5} + container_name: flux-bootstrap + restart: "no" + depends_on: + k3s-server: + condition: service_healthy + gitea-init: + condition: service_completed_successfully + flux-k8s-init: + condition: service_completed_successfully + # Один и тот же URL резолвится в двух разных контекстах: внутри k3s — через + # Service флакс-моста, здесь, в compose-сети, — через эту запись в /etc/hosts. + # Без неё flux CLI не смог бы запушить манифесты в репозиторий. + extra_hosts: + - "gitea.flux-system.svc.cluster.local:${GITEA_BRIDGE_IP:-172.28.0.12}" + environment: + GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-sarex} + GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:?GITEA_ADMIN_PASSWORD is required} + GITEA_ORG: ${GITEA_ORG:-infra} + GITEA_REPO: ${GITEA_REPO:-iac} + AERO_GIT_BRANCH: ${AERO_GIT_BRANCH:-master} + AERO_FLUX_PATH: ${AERO_FLUX_PATH:-clusters/aero} + # Должен совпадать с ipv4_address сервиса gitea. + GITEA_BRIDGE_IP: ${GITEA_BRIDGE_IP:-172.28.0.12} + volumes: + - ./k3s/kubeconfig.yaml:/kube/config:ro,z + entrypoint: ["/bin/sh", "-ec"] + command: + - | + # k3s пишет kubeconfig с server: https://127.0.0.1:6443 — изнутри другого + # контейнера это неверный адрес. Подменяем на имя сервиса; сертификат + # apiserver уже содержит --tls-san=k3s-server, поэтому TLS проверяется + # штатно, без --insecure. + sed 's#https://127.0.0.1:6443#https://k3s-server:6443#' /kube/config > /tmp/kubeconfig + export KUBECONFIG=/tmp/kubeconfig + + # Адрес источника — имя Service'а моста (flux-k8s-init), а не IP: так + # адрес gitea задан ровно в одном месте — переменной GITEA_BRIDGE_IP. + flux bootstrap git \ + --url="http://gitea.flux-system.svc.cluster.local:3000/$$GITEA_ORG/$$GITEA_REPO.git" \ + --branch="$$AERO_GIT_BRANCH" \ + --path="$$AERO_FLUX_PATH" \ + --username="$$GITEA_ADMIN_USER" \ + --password="$$GITEA_ADMIN_PASSWORD" \ + --token-auth \ + --allow-insecure-http # --------------------------------------------------------------------------- # Инфраструктурные зависимости apps/django (sarex-backend). @@ -593,8 +859,12 @@ services: volumes: k3s-server-data: - k3s-worker-data: + k3s-worker-1-data: + k3s-worker-2-data: + k3s-worker-3-data: gitea-data: + sarex-vault-data: # file-хранилище Vault + sarex-vault-init: # unseal-ключ и root-токен (только для vault-init) sarex-postgres-data: sarex-redis-data: sarex-rabbitmq-data: diff --git a/k3s/manifests/flux/bridge-gitea.yaml b/k3s/manifests/flux/bridge-gitea.yaml new file mode 100644 index 0000000..31b262d --- /dev/null +++ b/k3s/manifests/flux/bridge-gitea.yaml @@ -0,0 +1,39 @@ +# DNS-мост: имя `gitea` в namespace flux-system → compose-контейнер gitea. +# Service без селектора + ручной Endpoints, по образцу bridge-postgres/bridge-minio. +# +# Зачем: source-controller Flux работает ВНУТРИ k3s, где резолвит CoreDNS, а имя +# сервиса compose-сети там не существует. Без моста GitRepository падает с +# "lookup gitea on 10.43.0.10:53: no such host". +# +# __GITEA_BRIDGE_IP__ подставляет контейнер flux-k8s-init из переменной +# GITEA_BRIDGE_IP — она же задаёт ipv4_address сервиса gitea в docker-compose. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: flux-system +--- +apiVersion: v1 +kind: Service +metadata: + name: gitea + namespace: flux-system +spec: + ports: + - name: http + port: 3000 + targetPort: 3000 + protocol: TCP +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: gitea + namespace: flux-system +subsets: + - addresses: + - ip: __GITEA_BRIDGE_IP__ + ports: + - name: http + port: 3000 + protocol: TCP diff --git a/vault/config/vault.hcl b/vault/config/vault.hcl new file mode 100644 index 0000000..6b45c49 --- /dev/null +++ b/vault/config/vault.hcl @@ -0,0 +1,22 @@ +// Vault контура aero: file-хранилище на named volume, TLS терминируется выше +// (верхний nginx). Внутри compose-сети слушаем открытым HTTP. +// Инициализацию/распечатывание делает сервис vault-init (см. docker-compose.yaml). + +ui = true + +storage "file" { + path = "/vault/file" +} + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = true +} + +// Адрес, который Vault сообщает клиентам (и себе при редиректах внутри сети). +api_addr = "http://vault:8200" + +// mlock требует IPC_LOCK; в compose capability выдана, но в некоторых окружениях +// (RedOS в контейнере без CAP_IPC_LOCK) Vault упадёт на старте. Отключаем — +// swap на хосте контура и так выключен. +disable_mlock = true