From 565faa0e171d73bbfb6b3661f6c8e6ef81954a19 Mon Sep 17 00:00:00 2001 From: emelinda Date: Sat, 8 Aug 2026 09:36:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(aero):=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0=20=E2=80=94=20Vault=E2=86=94k3s,=20Git?= =?UTF-8?q?Ops-=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=80=D1=83=D0=B6=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Топология k3s: 1 мастер + 2 воркера (убран k3s-worker-3 с томом). Vault ↔ k3s. Vault живёт в compose, вне кластера, поэтому получает статический IP 172.28.0.13, DNS-мост в namespace vault и token reviewer (SA vault-auth + system:auth-delegator). Ansible включает auth/kubernetes, передавая адрес apiserver, CA и JWT ревьюера явно, и заводит политику с ролями. Секрет token reviewer'а читается целиком в JSON: вариант -o jsonpath={.data.ca\.crt} НЕ работает — модуль command разбирает строку через shlex, съедает обратный слэш, kubectl возвращает пустую строку с кодом 0, и отказ остаётся незамеченным до падения vault write. Синхронизация репозитория в gitea. Нужное подмножество путей (замыкание ссылок clusters/aero) едет архивом на хост и коммитится там: gitea не публикуется дальше самого хоста, контур остаётся замкнутым. flux-bootstrap вынесен в отдельную волну — до неё Vault получает auth/kubernetes, а репозиторий наполняется, иначе первая реконсиляция падает на пустом репо. Инфраструктура: vault-agent-injector (чарт vault-contour в режиме внешнего Vault), local-path-provisioner взамен встроенного в k3s (--disable= local-storage; путь данных прежний), rabbitmq и minio с кредами из Vault. Выход наружу: порты 80/443 k3s-server опубликованы (istio ingressgateway занимает эти hostPort), общий contour-gateway на wildcard-хост, сертификат Let's Encrypt через http01. Имена в сертификате перечислены явно — http01 не выдаёт wildcard, для них нужен dns01. Разорваны два дедлока Flux: infra-configs больше не зависит от infra-controllers (издатели не должны зависеть от здоровья чартов, которые их используют), istio-config получил disableWait. Модули ядра iptable_nat и смежные грузятся на хосте: ноды k3s — контейнеры, istio-init правит iptables через ядро хоста, а на RED OS 8 (nf_tables) legacy-модули не загружены, из-за чего любой под с istio-injection навсегда вставал в Init:Error. Собственные Gateway/VirtualService/Certificate чартов rabbitmq и dashboard отключены — маршрутизация описана централизованно. У rabbitmq это сделано postRenderers, а не values: kustomize нормализует null в {}, а пустую карту Helm сливает с дефолтами, возвращая их целиком. --- aero/roles/sarex_stack/defaults/main.yml | 130 +++++++++++++- aero/roles/sarex_stack/tasks/gitea-sync.yml | 140 +++++++++++++++ aero/roles/sarex_stack/tasks/main.yml | 13 ++ aero/roles/sarex_stack/tasks/platform.yml | 40 ++++- aero/roles/sarex_stack/tasks/vault-k8s.yml | 169 ++++++++++++++++++ clusters/aero/apps.yaml | 24 +++ clusters/aero/apps/kustomization.yaml | 15 ++ clusters/aero/controllers/kustomization.yaml | 16 +- clusters/aero/infrastructure.yaml | 30 +++- clusters/aero/kustomization.yaml | 2 + docker-compose.yaml | 39 ++-- .../configs/clusterissuer-letsencrypt.yaml | 47 +++++ .../aero/configs/kustomization.yaml | 3 + infrastructure/dashboard/aero/dashboard.yaml | 38 ++++ .../dashboard/aero/kustomization.yaml | 5 + .../istio-config/aero/istio-config.yaml | 115 +++++++++++- .../aero/kustomization.yaml | 10 ++ .../aero/local-path-provisioner.yaml | 62 +++++++ infrastructure/minio/aero/kustomization.yaml | 10 ++ infrastructure/minio/aero/minio.yaml | 40 +++++ .../rabbitmq/aero/kustomization.yaml | 14 ++ infrastructure/rabbitmq/aero/rabbitmq.yaml | 105 +++++++++++ .../vault-injector/aero/kustomization.yaml | 5 + .../vault-injector/base/helmrelease.yaml | 49 +++++ .../vault-injector/base/kustomization.yaml | 11 ++ k3s/manifests/vault/bridge-vault.yaml | 41 +++++ k3s/manifests/vault/vault-auth.yaml | 44 +++++ 27 files changed, 1184 insertions(+), 33 deletions(-) create mode 100644 aero/roles/sarex_stack/tasks/gitea-sync.yml create mode 100644 aero/roles/sarex_stack/tasks/vault-k8s.yml create mode 100644 clusters/aero/apps.yaml create mode 100644 clusters/aero/apps/kustomization.yaml create mode 100644 infrastructure/cert-manager/aero/configs/clusterissuer-letsencrypt.yaml create mode 100644 infrastructure/dashboard/aero/dashboard.yaml create mode 100644 infrastructure/local-path-provisioner/aero/kustomization.yaml create mode 100644 infrastructure/local-path-provisioner/aero/local-path-provisioner.yaml create mode 100644 infrastructure/minio/aero/kustomization.yaml create mode 100644 infrastructure/minio/aero/minio.yaml create mode 100644 infrastructure/rabbitmq/aero/kustomization.yaml create mode 100644 infrastructure/rabbitmq/aero/rabbitmq.yaml create mode 100644 infrastructure/vault-injector/aero/kustomization.yaml create mode 100644 infrastructure/vault-injector/base/helmrelease.yaml create mode 100644 infrastructure/vault-injector/base/kustomization.yaml create mode 100644 k3s/manifests/vault/bridge-vault.yaml create mode 100644 k3s/manifests/vault/vault-auth.yaml diff --git a/aero/roles/sarex_stack/defaults/main.yml b/aero/roles/sarex_stack/defaults/main.yml index 1b004c4..92c3c73 100644 --- a/aero/roles/sarex_stack/defaults/main.yml +++ b/aero/roles/sarex_stack/defaults/main.yml @@ -2,6 +2,25 @@ # Каталог развёртывания на хосте (домашка root) deploy_dir: /root/sarex +# --- Модули ядра для istio-init -------------------------------------------- +# Ноды k3s — это docker-контейнеры, своего ядра у них нет: любые операции с +# iptables внутри подов идут через ядро ХОСТА. istio-init настраивает перехват +# трафика и создаёт legacy-таблицу nat, поэтому на хосте должен быть загружен +# iptable_nat. +# +# На RED OS 8 (ядро 6.6, iptables в режиме nf_tables) он по умолчанию НЕ +# загружен: присутствуют только nft-варианты (nft_chain_nat, xt_nat). Без этих +# модулей istio-init падает с +# xtables parameter problem: iptables-restore: unable to initialize table 'nat' +# и КАЖДЫЙ под в namespace с istio-injection=enabled навсегда остаётся в +# Init:Error — именно так встал kubernetes-dashboard. +aero_kernel_modules: + - iptable_nat + - iptable_mangle + - iptable_raw + - xt_REDIRECT + - xt_owner + # Источник файлов — корень репозитория iac (playbook лежит в aero/) deploy_src_root: "{{ playbook_dir }}/.." @@ -58,6 +77,11 @@ registry_secrets: - {name: regcred, namespace: cert-manager} - {name: regcred, namespace: istio-system} - {name: regcred, namespace: kubernetes-dashboard} + # Образы vault-k8s и vault-agent для injector'а тоже лежат в cr.yandex. + - {name: regcred, namespace: vault} + - {name: regcred, namespace: local-path-provisioner} + - {name: regcred, namespace: rabbitmq} + - {name: regcred, namespace: minio} # Поднимать стек (docker compose up -d). Требует запущенной службы docker и # docker login в cr.yandex — по умолчанию выключено. @@ -75,6 +99,50 @@ gitea_repo: iac aero_git_branch: master aero_flux_path: clusters/aero +# --- Синхронизация репозитория в gitea -------------------------------------- +# Содержимое едет с control-node на хост архивом и коммитится в gitea оттуда: +# наружу gitea не публикуется дальше самого хоста, а git на control-node может +# быть не настроен на работу с контуром. +# +# Список — это ЗАМЫКАНИЕ ссылок clusters/aero, а не весь репозиторий: +# clusters/aero/controllers ссылается на ../../../infrastructure/<компонент>/aero, +# каждый такой overlay тянет только ../base. Каталоги ansible-роли, snapshots и +# docs кластеру не нужны и не синхронизируются. +# +# ВАЖНО: добавил компонент в clusters/aero/controllers — добавь его сюда, иначе +# kustomize в кластере упадёт на отсутствующем каталоге. +gitea_sync_paths: + - clusters/aero + - infrastructure/cert-manager + - infrastructure/dashboard + - infrastructure/istio-base + - infrastructure/istio-config + - infrastructure/istio-gateway + - infrastructure/istio-pilot + - infrastructure/vault-injector + - infrastructure/local-path-provisioner + - infrastructure/rabbitmq + - infrastructure/minio + +# Рабочая копия репозитория gitea на хосте (клон, живёт между прогонами). +gitea_sync_workdir: "{{ deploy_dir }}/gitea-sync" +gitea_sync_archive_remote: "{{ deploy_dir }}/gitea-sync.tar.gz" + +# Адрес gitea с точки зрения ХОСТА: контейнер публикует 3000 наружу. +gitea_http_addr: "http://127.0.0.1:3000" + +# URL с кредами для push'а. sarex_secrets заполняется в tasks/main.yml, поэтому +# выражение раскрывается лениво — к моменту использования пароль уже есть. +gitea_sync_url: >- + http://{{ gitea_admin_user }}:{{ sarex_secrets.GITEA_ADMIN_PASSWORD | urlencode }}@127.0.0.1:3000/{{ gitea_org }}/{{ gitea_repo }}.git + +# Временный архив на control-node (удаляется после синхронизации). +gitea_sync_archive_local: /tmp/aero-gitea-sync.tar.gz + +# Автор коммитов синхронизации. +gitea_commit_name: aero-sync +gitea_commit_email: "aero-sync@{{ platform_domain }}" + # Поднимается отдельно от прикладного стека: образы публичные, docker login в # cr.yandex не нужен. По умолчанию выключено, включает `uv run poe platform`. aero_platform_up: false @@ -85,17 +153,23 @@ 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 -# после ребута хоста. +# Волна 2: провижининг. gitea-init и flux-k8s-init одноразовые (restart: "no"), +# vault-init живёт постоянно и распечатывает Vault после ребута хоста. +# +# flux-bootstrap ВЫНЕСЕН в волну 3 намеренно: между волнами Vault получает +# auth/kubernetes, а репозиторий контура синхронизируется в gitea. Запусти +# bootstrap раньше — Flux привязался бы к репозиторию, в котором ещё нет ни +# clusters/aero, ни infrastructure, и первая же реконсиляция упала бы. aero_platform_services_wave2: - gitea-init - flux-k8s-init - vault - vault-init + +# Волна 3: bootstrap FluxCD. Запускается после наполнения gitea. +aero_platform_services_wave3: - flux-bootstrap # Ноды k3s. Каждой нужен свой каталог PVC-хранилища на хосте: провижинер @@ -104,11 +178,57 @@ aero_k3s_nodes: - server - worker-1 - worker-2 - - worker-3 # Сколько ждать появления kubeconfig от k3s-server, секунд. aero_kubeconfig_timeout: 180 +# --- Интеграция Vault ↔ k3s (метод auth/kubernetes) ------------------------- +# Статический IP контейнера vault в compose-сети. ДОЛЖЕН совпадать с +# ipv4_address сервиса vault в docker-compose.yaml: отсюда значение +# подставляется в Endpoints DNS-моста (k3s/manifests/vault/bridge-vault.yaml). +vault_bridge_ip: 172.28.0.13 + +# Адрес apiserver, по которому Vault валидирует токены подов. Vault живёт в +# compose, поэтому имя резолвится compose-DNS, а не CoreDNS кластера. +vault_k8s_host: "https://k3s-server:6443" + +# Политика чтения kv-v2 на пути secrets/ — том же, что включает vault-init. +vault_apps_policy: apps-read + +# TTL выдаваемого агенту токена по умолчанию. +vault_role_ttl: 24h + +# Роли auth/kubernetes. Имя роли — то, что приложение указывает в аннотации +# vault.hashicorp.com/role, namespace — где живут его поды. Пополняется по мере +# переезда приложений из compose в k3s. +vault_k8s_roles: + - {name: rabbitmq, namespaces: [rabbitmq]} + - {name: minio, namespaces: [minio]} + - {name: django, namespaces: [django]} + - {name: processing, namespaces: [processing]} + +# Боевые креды инфраструктурных сервисов. Пути и имена ключей заданы чартами +# (см. vaultRoot у minio и auth.vault у rabbitmq в infrastructure/*/aero) — +# менять их в отрыве от values нельзя. +# +# Пароли не хранятся в репозитории: берутся из sarex_secrets, который +# генерирует и персистит в aero/.secrets задача "Сгенерировать/загрузить пароли". +vault_app_secrets: + - path: secrets/rabbitmq/auth + data: + username: "{{ sarex_rabbitmq_k8s_user }}" + password: "{{ sarex_secrets.SAREX_RABBITMQ_PASSWORD }}" + - path: secrets/minio/admin + data: + rootUser: "{{ sarex_minio_k8s_user }}" + rootPassword: "{{ sarex_secrets.SAREX_MINIO_ROOT_PASSWORD }}" + +# Логины сервисов в k3s. Совпадают со значениями в .env.example для +# одноимённых сервисов compose; пароли к ним — в sarex_secrets (те же +# переменные: контур переезжает в k3s, плодить вторую пару кредов незачем). +sarex_rabbitmq_k8s_user: rabbit +sarex_minio_k8s_user: minioadmin + # Сервисы приложения, поднимаемые при sarex_compose_up (depends_on тянет # зависимости и порядок). k3s/gitea намеренно не поднимаем. sarex_services: diff --git a/aero/roles/sarex_stack/tasks/gitea-sync.yml b/aero/roles/sarex_stack/tasks/gitea-sync.yml new file mode 100644 index 0000000..6eb4eaa --- /dev/null +++ b/aero/roles/sarex_stack/tasks/gitea-sync.yml @@ -0,0 +1,140 @@ +--- +# Синхронизация репозитория контура в gitea. +# +# Flux читает манифесты ИЗ gitea, а не из этого репозитория, поэтому содержимое +# нужно туда доставить. Схема: control-node пакует нужное подмножество путей в +# архив → ansible кладёт его на хост → на хосте архив распаковывается в рабочую +# копию репозитория gitea и коммитится. +# +# Почему коммит делается на хосте, а не push'ем с control-node: gitea публикует +# порт только на самом хосте, и контур остаётся замкнутым — наружу за git никто +# не ходит. +# +# Синхронизируется ЗАМЫКАНИЕ ссылок clusters/aero (gitea_sync_paths), а не весь +# репозиторий: ansible-роль, snapshots и docs кластеру не нужны. +# +# Идемпотентно: если содержимое не изменилось, коммита не будет. + +- name: Установить git на хосте (нужен для коммита в gitea) + ansible.builtin.package: + name: git + state: present + +- name: Проверить, что синхронизируемые пути существуют + ansible.builtin.stat: + path: "{{ deploy_src_root }}/{{ item }}" + loop: "{{ gitea_sync_paths }}" + loop_control: + label: "{{ item }}" + delegate_to: localhost + become: false + register: gitea_sync_stat + +- name: Прервать деплой, если путь из gitea_sync_paths отсутствует + ansible.builtin.fail: + msg: >- + Путь {{ item.item }} указан в gitea_sync_paths, но отсутствует в + репозитории. Список должен совпадать с тем, на что ссылается + clusters/aero — иначе kustomize в кластере упадёт. + loop: "{{ gitea_sync_stat.results }}" + loop_control: + label: "{{ item.item }}" + when: not item.stat.exists + +# Архив собирается из РАБОЧЕГО ДЕРЕВА, а не из HEAD: синхронизировать нужно то, +# что лежит на диске, включая ещё не закоммиченные локально правки. +- name: Собрать архив синхронизируемых путей на control-node + ansible.builtin.command: + cmd: "tar -czf {{ gitea_sync_archive_local }} {{ gitea_sync_paths | join(' ') }}" + chdir: "{{ deploy_src_root }}" + delegate_to: localhost + become: false + changed_when: true + +- name: Запомнить исходную ревизию (для сообщения коммита) + ansible.builtin.command: + cmd: git rev-parse --short HEAD + chdir: "{{ deploy_src_root }}" + delegate_to: localhost + become: false + register: gitea_sync_rev + changed_when: false + failed_when: false + +- name: Скопировать архив на хост + ansible.builtin.copy: + src: "{{ gitea_sync_archive_local }}" + dest: "{{ gitea_sync_archive_remote }}" + mode: "0600" + +- name: Синхронизировать и закоммитить содержимое в gitea + ansible.builtin.shell: + cmd: | + set -eu + WORKDIR={{ gitea_sync_workdir | quote }} + BRANCH={{ aero_git_branch | quote }} + URL={{ gitea_sync_url | quote }} + + mkdir -p "$WORKDIR" + cd "$WORKDIR" + + # Клон может не существовать, а репозиторий в gitea — быть пустым (до + # первого коммита у него нет ни одной ветки). init+fetch переживает оба + # случая, в отличие от `git clone`, который на пустом репозитории + # оставляет рабочую копию без ветки. + [ -d .git ] || git init -q + git config user.name {{ gitea_commit_name | quote }} + git config user.email {{ gitea_commit_email | quote }} + git remote remove origin 2>/dev/null || true + git remote add origin "$URL" + + if git fetch -q origin "$BRANCH" 2>/dev/null; then + git checkout -q -B "$BRANCH" FETCH_HEAD + else + git checkout -q -B "$BRANCH" + fi + + # Управляемые пути удаляются перед распаковкой, иначе файлы, удалённые в + # исходном репозитории, остались бы в gitea навсегда: `git add -A` увидит + # удаление только если файла действительно нет на диске. + for p in {{ gitea_sync_paths | join(' ') }}; do + rm -rf "$p" + done + + tar -xzf {{ gitea_sync_archive_remote | quote }} + + git add -A + if git diff --cached --quiet; then + echo "SYNC_RESULT=nochange" + else + git commit -q -m "sync from control-node ({{ gitea_sync_rev.stdout | default('unknown') }})" + echo "SYNC_RESULT=committed" + fi + + git push -q origin "$BRANCH" + echo "SYNC_DONE" + chdir: "{{ deploy_dir }}" + register: gitea_sync_result + changed_when: "'SYNC_RESULT=committed' in gitea_sync_result.stdout" + no_log: true + +- name: Удалить архив с хоста + ansible.builtin.file: + path: "{{ gitea_sync_archive_remote }}" + state: absent + +- name: Удалить архив с control-node + ansible.builtin.file: + path: "{{ gitea_sync_archive_local }}" + state: absent + delegate_to: localhost + become: false + +- name: Итог синхронизации + ansible.builtin.debug: + msg: >- + gitea {{ gitea_org }}/{{ gitea_repo }}@{{ aero_git_branch }}: + {{ 'закоммичены изменения' if 'SYNC_RESULT=committed' in gitea_sync_result.stdout + else 'изменений нет' }} + (источник {{ gitea_sync_rev.stdout | default('unknown') }}, + путей: {{ gitea_sync_paths | length }}) diff --git a/aero/roles/sarex_stack/tasks/main.yml b/aero/roles/sarex_stack/tasks/main.yml index 30428d2..6fc9c8c 100644 --- a/aero/roles/sarex_stack/tasks/main.yml +++ b/aero/roles/sarex_stack/tasks/main.yml @@ -1,4 +1,17 @@ --- +# --- Предпосылки на хосте ------------------------------------------------- +# Загружаем ДО подъёма k3s: istio-init внутри подов правит iptables через ядро +# хоста, и без этих модулей поды в namespace с istio-injection не стартуют. +# persistent: present закрепляет загрузку в /etc/modules-load.d, иначе после +# перезагрузки хоста контур снова развалился бы. +- name: Загрузить модули ядра, нужные istio-init + community.general.modprobe: + name: "{{ item }}" + state: present + persistent: present + loop: "{{ aero_kernel_modules }}" + tags: [kernel] + # --- Каталоги и файлы ---------------------------------------------------- - name: Создать каталог развёртывания ansible.builtin.file: diff --git a/aero/roles/sarex_stack/tasks/platform.yml b/aero/roles/sarex_stack/tasks/platform.yml index 687bad3..42677f0 100644 --- a/aero/roles/sarex_stack/tasks/platform.yml +++ b/aero/roles/sarex_stack/tasks/platform.yml @@ -33,12 +33,44 @@ - name: Создать секреты доступа к реестру в кластере ansible.builtin.include_tasks: registry-secrets.yml -- name: Волна 2 — gitea-init, vault(+init) и bootstrap Flux +- name: Волна 2 — gitea-init, flux-k8s-init и vault(+init) ansible.builtin.command: cmd: "{{ compose_cmd }} up -d {{ aero_platform_services_wave2 | join(' ') }}" chdir: "{{ deploy_dir }}" changed_when: true +# vault-init инициализирует и распечатывает Vault в цикле с шагом 10 секунд, +# поэтому сразу после `up -d` root-токена ещё нет. Он нужен следующему шагу, +# который настраивает auth/kubernetes. +- name: Дождаться инициализации Vault (появления root-токена) + ansible.builtin.command: + cmd: "{{ compose_cmd }} exec -T vault-init test -s /vault/init/root.token" + chdir: "{{ deploy_dir }}" + register: aero_vault_ready + until: aero_vault_ready.rc == 0 + retries: 30 + delay: 5 + changed_when: false + +# Метод auth/kubernetes: Vault учится валидировать токены подов k3s, а поды +# получают секреты файлами через vault-agent-injector (ставится Flux'ом). +- name: Настроить интеграцию Vault с k3s + ansible.builtin.include_tasks: vault-k8s.yml + tags: [vault] + +# Наполнение gitea — ДО bootstrap: Flux начинает реконсиляцию сразу после +# привязки, и репозиторий к этому моменту должен содержать clusters/aero +# вместе со всем, на что тот ссылается. +- name: Синхронизировать репозиторий контура в gitea + ansible.builtin.include_tasks: gitea-sync.yml + tags: [sync] + +- name: Волна 3 — bootstrap Flux + ansible.builtin.command: + cmd: "{{ compose_cmd }} up -d {{ aero_platform_services_wave3 | join(' ') }}" + chdir: "{{ deploy_dir }}" + changed_when: true + # flux-bootstrap одноразовый: успех = код 0. Забираем его явно, иначе ошибка # bootstrap'а останется незамеченной — `compose up -d` о ней не сообщает. # @@ -85,4 +117,8 @@ 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" + - "Слои: infra-controllers (istio, cert-manager, vault-injector, local-path, rabbitmq, minio) → infra-configs → apps" + - "Состояние Flux: docker compose exec k3s-server kubectl -n flux-system get kustomizations,helmreleases -A" + - "Dashboard: https://dashboard.{{ platform_domain }}" + - "RabbitMQ: https://rabbitmq.{{ platform_domain }} (пользователь {{ sarex_rabbitmq_k8s_user }}, пароль в aero/.secrets/{{ inventory_hostname }}/SAREX_RABBITMQ_PASSWORD)" + - "MinIO UI: https://minio.{{ platform_domain }}/console/ (пользователь {{ sarex_minio_k8s_user }}, пароль в aero/.secrets/{{ inventory_hostname }}/SAREX_MINIO_ROOT_PASSWORD)" diff --git a/aero/roles/sarex_stack/tasks/vault-k8s.yml b/aero/roles/sarex_stack/tasks/vault-k8s.yml new file mode 100644 index 0000000..372ea07 --- /dev/null +++ b/aero/roles/sarex_stack/tasks/vault-k8s.yml @@ -0,0 +1,169 @@ +--- +# Интеграция Vault ↔ k3s: метод auth/kubernetes. +# +# Vault контура живёт в docker-compose, ВНЕ кластера, поэтому не может +# воспользоваться ни собственным ServiceAccount-токеном, ни CA из +# /var/run/secrets — всё это ему нужно передать явно: +# kubernetes_host — адрес apiserver, резолвится по compose-DNS; +# kubernetes_ca_cert — CA кластера (из секрета token reviewer'а); +# token_reviewer_jwt — долгоживущий токен SA vault-auth. +# +# После этого под с аннотациями vault.hashicorp.com/* получает от injector'а +# init-контейнер vault-agent, тот логинится в Vault токеном ServiceAccount'а +# пода, Vault валидирует его через TokenReview и отдаёт секреты файлами +# в /vault/secrets/. +# +# Все шаги идемпотентны: повторный прогон на настроенном Vault — no-op. +# +# ВНИМАНИЕ по стилю: команды здесь задаются ЛИТЕРАЛЬНЫМ блоком (|), а не +# складывающим (>-). В складывающем скаляре строка с бОльшим отступом считается +# "more-indented", и YAML СОХРАНЯЕТ перед ней перевод строки — многострочная +# команда разрывается посреди аргументов. Именно на этом падал первый вариант: +# `vault write auth/kubernetes/config` отрывался от своих параметров. + +- name: Применить манифесты vault в k3s (DNS-мост + token reviewer) + ansible.builtin.shell: + cmd: | + {{ compose_cmd }} exec -T k3s-server sh -ec ' + sed "s|__VAULT_BRIDGE_IP__|{{ vault_bridge_ip }}|g" /output/manifests/vault/bridge-vault.yaml | + kubectl apply -f - + kubectl apply -f /output/manifests/vault/vault-auth.yaml + ' + chdir: "{{ deploy_dir }}" + changed_when: true + +# Секрет читается ЦЕЛИКОМ в JSON, а поля достаются фильтром from_json. +# +# Так сделано не из вкуса: вариант `-o jsonpath={.data.ca\.crt}` здесь НЕ +# работает. Модуль command разбирает строку через shlex с обработкой escape- +# последовательностей и съедает обратный слэш, поэтому kubectl получает +# {.data.ca.crt} — ищет вложенное поле crt внутри ca, не находит и возвращает +# ПУСТУЮ строку с кодом 0. Отказ тихий: задача чтения проходит успешно, а +# падает уже `vault write` с пустым CA. Ключ token при этом читался верно — +# в нём нет точки, — что делало симптом ещё запутаннее. +# +# token-controller заполняет секрет асинхронно, поэтому ждём оба поля. +- name: Прочитать секрет token reviewer'а (дождавшись заполнения) + ansible.builtin.command: + cmd: >- + {{ compose_cmd }} exec -T k3s-server + kubectl -n vault get secret vault-auth-token -o json + chdir: "{{ deploy_dir }}" + register: vault_auth_secret + until: >- + vault_auth_secret.rc == 0 + and '"ca.crt"' in vault_auth_secret.stdout + and '"token"' in vault_auth_secret.stdout + retries: 30 + delay: 2 + changed_when: false + no_log: true + +- name: Выделить CA и JWT из секрета + ansible.builtin.set_fact: + vault_k8s_ca_b64: "{{ (vault_auth_secret.stdout | from_json).data['ca.crt'] }}" + vault_sa_token_b64: "{{ (vault_auth_secret.stdout | from_json).data['token'] }}" + no_log: true + +- name: Проверить, что CA и JWT непустые + ansible.builtin.assert: + that: + - vault_k8s_ca_b64 | length > 0 + - vault_sa_token_b64 | length > 0 + fail_msg: >- + Секрет vault-auth-token прочитан, но CA или JWT пусты — Vault нельзя + настроить на валидацию токенов подов. + success_msg: "CA и JWT получены" + +# Повторный вызов на уже включённом пути отдаёт "path is already in use" +# и ненулевой код — это штатно, глушим. +- name: Включить метод auth/kubernetes в Vault + ansible.builtin.shell: + cmd: | + {{ compose_cmd }} exec -T vault-init sh -ec ' + export VAULT_TOKEN=$(cat /vault/init/root.token) + vault auth enable kubernetes 2>&1 | grep -v "path is already in use" || true + ' + chdir: "{{ deploy_dir }}" + changed_when: true + +# CA и JWT передаём внутрь контейнера base64-строкой и раскодируем во временные +# файлы: vault CLI умеет читать значение параметра из файла синтаксисом @путь, +# и так не приходится экранировать многострочный PEM в аргументах. +- name: Настроить auth/kubernetes (адрес apiserver, CA, token reviewer) + ansible.builtin.shell: + cmd: | + {{ compose_cmd }} exec -T vault-init sh -ec ' + export VAULT_TOKEN=$(cat /vault/init/root.token) + umask 077 + echo "{{ vault_k8s_ca_b64 }}" | base64 -d > /tmp/k8s-ca.crt + echo "{{ vault_sa_token_b64 }}" | base64 -d > /tmp/k8s-jwt + vault write auth/kubernetes/config kubernetes_host="{{ vault_k8s_host }}" kubernetes_ca_cert=@/tmp/k8s-ca.crt token_reviewer_jwt=@/tmp/k8s-jwt + rm -f /tmp/k8s-ca.crt /tmp/k8s-jwt + ' + chdir: "{{ deploy_dir }}" + changed_when: true + no_log: true + +# Политика доступа к kv-v2 на пути secrets/ — том самом, что включает vault-init +# и на который ссылаются аннотации приложений (secrets/data/apps//...). +- name: Создать политику чтения секретов + ansible.builtin.shell: + cmd: | + {{ compose_cmd }} exec -T vault-init sh -ec ' + export VAULT_TOKEN=$(cat /vault/init/root.token) + vault policy write {{ vault_apps_policy }} - < -> /var/lib/rancher/k3s/storage + # Благодаря этому данные PVC переживают пересоздание контейнеров нод и + # доступны для бэкапа обычными средствами хоста. Дефолт чарта + # (/opt/local-path-provisioner) никуда не смонтирован — данные исчезли бы + # вместе с контейнером ноды. + nodePathMap: + - node: DEFAULT_PATH_FOR_NON_LISTED_NODES + paths: + - /var/lib/rancher/k3s/storage + rbac: + create: true + serviceAccount: + create: true + name: "" + resources: {} + nodeSelector: {} + tolerations: [] + affinity: {} + configmap: + name: local-path-config + setup: |- + set -eu + mkdir -m 0777 -p "$VOL_DIR" + teardown: |- + set -eu + rm -rf "$VOL_DIR" diff --git a/infrastructure/minio/aero/kustomization.yaml b/infrastructure/minio/aero/kustomization.yaml new file mode 100644 index 0000000..a747147 --- /dev/null +++ b/infrastructure/minio/aero/kustomization.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../base +patches: + - path: minio.yaml + target: + kind: HelmRelease + name: minio diff --git a/infrastructure/minio/aero/minio.yaml b/infrastructure/minio/aero/minio.yaml new file mode 100644 index 0000000..173a141 --- /dev/null +++ b/infrastructure/minio/aero/minio.yaml @@ -0,0 +1,40 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: minio + namespace: minio +spec: + interval: 5m + timeout: 10m + values: + nameOverride: "minio" + mode: standalone + replicas: 1 + drivesPerNode: 1 + imagePullSecrets: + - name: regcred + environment: + # Внешние адреса: консоль отдаётся с /console/ того же домена, + # маршрутизацию задаёт infrastructure/istio-config/aero. + MINIO_SERVER_URL: "https://minio.sarex.local.lonsdaleites.ru" + MINIO_BROWSER_REDIRECT_URL: "https://minio.sarex.local.lonsdaleites.ru/console/" + MINIO_API_CORS_ALLOW_ORIGIN: "https://minio.sarex.local.lonsdaleites.ru" + # Root-креды приезжают из Vault через vault-agent-injector: секрет кладёт + # ansible (роль sarex_stack, vault_app_secrets), роль minio в auth/kubernetes + # создаётся там же. В манифестах и в git паролей нет. + vaultRoot: + enabled: true + role: minio + authPath: auth/kubernetes + secretPath: secrets/data/minio/admin + rootUserKey: rootUser + rootPasswordKey: rootPassword + # В yc-k8s-test minio прибит к выделенным нодам (nodeSelector/tolerations + # dedicated=s3). В контуре aero таких нод нет — всего три, и планировщику + # ничего ограничивать не нужно. + persistence: + storageClass: local-path + size: 20Gi + resources: + requests: + memory: 512Mi diff --git a/infrastructure/rabbitmq/aero/kustomization.yaml b/infrastructure/rabbitmq/aero/kustomization.yaml new file mode 100644 index 0000000..516b44e --- /dev/null +++ b/infrastructure/rabbitmq/aero/kustomization.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../base +patches: + - path: rabbitmq.yaml + target: + kind: HelmRelease + name: rabbitmq +# JSON6902-патча с null здесь БОЛЬШЕ НЕТ — он не работал. +# kustomize нормализовал null обратно в {}, а пустую карту Helm сливает с +# дефолтами чарта, возвращая их целиком. Отключение перенесено в postRenderers +# внутри rabbitmq.yaml, где ресурсы вырезаются уже из готового манифеста. diff --git a/infrastructure/rabbitmq/aero/rabbitmq.yaml b/infrastructure/rabbitmq/aero/rabbitmq.yaml new file mode 100644 index 0000000..3ecb76e --- /dev/null +++ b/infrastructure/rabbitmq/aero/rabbitmq.yaml @@ -0,0 +1,105 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: rabbitmq + namespace: rabbitmq +spec: + interval: 5m + timeout: 10m + # Вырезаем ресурсы ПОСЛЕ рендера чарта. Через values это недостижимо: + # * `null` до Helm не доезжает — kustomize нормализует его в {}; + # * `{}` Helm сливает с дефолтом (coalesceMaps), и дефолт возвращается. + # postRenderers работает уже с готовым манифестом, где коалесинг позади. + # + # Что вырезаем: собственные Gateway/VirtualService/Certificate чарта на + # посторонний домен rabbitmq.infra.sarex.io. Маршрутизация контура описана + # централизованно в infrastructure/istio-config/aero. + # + # Особенно важен rmq-tls: домен не наш, ACME отдаёт 503 и проверка не пройдёт + # никогда. Пока он был в релизе, Helm ждал его выпуска, install падал по + # таймауту, срабатывал откат — и весь слой infra-controllers стоял. + postRenderers: + - kustomize: + patches: + - target: + kind: Gateway + name: rmq-gateway + patch: | + $patch: delete + apiVersion: networking.istio.io/v1beta1 + kind: Gateway + metadata: + name: rmq-gateway + namespace: gateway + - target: + kind: VirtualService + name: rmq-virt-service + patch: | + $patch: delete + apiVersion: networking.istio.io/v1 + kind: VirtualService + metadata: + name: rmq-virt-service + namespace: rabbitmq + - target: + kind: Certificate + name: rmq-tls + patch: | + $patch: delete + apiVersion: cert-manager.io/v1 + kind: Certificate + metadata: + name: rmq-tls + namespace: gateway + values: + global: + security: + allowInsecureImages: true + # Собственные VirtualService/Gateway/Certificate чарта отключаются НЕ здесь, + # а JSON6902-патчем в kustomization.yaml рядом. Причина — в противоположной + # семантике null у двух инструментов: + # + # Helm : удалить дефолт можно ТОЛЬКО значением null; + # {} он глубоко сливает с дефолтом, и дефолт остаётся; + # kustomize : в strategic-merge-патче null означает УДАЛИТЬ КЛЮЧ, + # то есть до Helm он просто не доедет. + # + # Значит ни `virtualService: null`, ни `virtualService: {}` здесь не + # работают: первое стирается kustomize, второе игнорируется Helm. Нужен + # JSON6902, где null — это именно значение, а не команда удаления. + # + # Цена ошибки не косметическая: чарт создавал rmq-gateway, rmq-virt-service + # и сертификат rmq-tls на посторонний домен rabbitmq.infra.sarex.io. + # Последний не проходит проверку ACME никогда, впустую жжёт лимиты + # Let's Encrypt, а релиз висит в install, ожидая его выпуска, и блокирует + # весь слой infra-controllers. + metrics: + serviceMonitor: + enabled: false + default: + enabled: false + perObject: + enabled: false + detailed: + enabled: false + extraServiceMonitors: [] + replicaCount: 1 + persistence: + storageClass: local-path + size: 8Gi + resources: + requests: + memory: 512Mi + # Логин/пароль приезжают из Vault через vault-agent-injector: секрет кладёт + # ansible (роль sarex_stack, vault_app_secrets), роль rabbitmq в + # auth/kubernetes создаётся там же. + auth: + securePassword: true + existingPasswordSecret: "" + vault: + enabled: true + role: rabbitmq + authPath: auth/kubernetes + secretPath: secrets/data/rabbitmq/auth + usernameKey: username + passwordKey: password diff --git a/infrastructure/vault-injector/aero/kustomization.yaml b/infrastructure/vault-injector/aero/kustomization.yaml new file mode 100644 index 0000000..736651a --- /dev/null +++ b/infrastructure/vault-injector/aero/kustomization.yaml @@ -0,0 +1,5 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../base diff --git a/infrastructure/vault-injector/base/helmrelease.yaml b/infrastructure/vault-injector/base/helmrelease.yaml new file mode 100644 index 0000000..04cbb1c --- /dev/null +++ b/infrastructure/vault-injector/base/helmrelease.yaml @@ -0,0 +1,49 @@ +# Vault Agent Injector контура aero. +# +# Тот же чарт vault-contour, что и в остальных кластерах (это официальный +# hashicorp/vault-helm, перепубликованный в cr.yandex), но в режиме «внешний +# Vault»: сервер Vault здесь живёт в docker-compose, а в кластер ставится +# ТОЛЬКО injector. +# +# global.externalVaultAddr сам по себе отключает деплой vault-сервера — это +# документированное поведение чарта, отдельный server.enabled=false не нужен. +# Адрес указывает на DNS-мост (Service vault в namespace vault), который +# заводит ansible: k3s/manifests/vault/bridge-vault.yaml. +# +# Имя релиза — vault-injector, а не vault (как в прод-кластерах): чарт +# составляет имена ресурсов из имени релиза, и релиз `vault` рисковал бы +# перекрыть Service `vault` моста, оставив Vault недоступным. +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: vault-injector + namespace: vault +spec: + interval: 10m + chart: + spec: + chart: vault-contour + version: "0.2.3" + sourceRef: + kind: HelmRepository + name: yc-oci-charts + namespace: flux-system + interval: 10m + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + values: + global: + # Отключает vault-сервер и задаёт адрес внешнего Vault для агентов. + externalVaultAddr: http://vault.vault.svc.cluster.local:8200 + # Образы чарта лежат в приватном cr.yandex; секрет создаёт роль + # sarex_stack (registry_secrets), см. registry-secret.yaml.j2. + imagePullSecrets: + - name: regcred + tlsDisable: true + injector: + enabled: true diff --git a/infrastructure/vault-injector/base/kustomization.yaml b/infrastructure/vault-injector/base/kustomization.yaml new file mode 100644 index 0000000..ed56a61 --- /dev/null +++ b/infrastructure/vault-injector/base/kustomization.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - helmrelease.yaml + +# namespace vault здесь намеренно НЕ объявлен: его создаёт ansible вместе с +# DNS-мостом и token reviewer'ом (k3s/manifests/vault/), потому что Service +# моста нужно куда-то положить ещё до того, как Flux дойдёт до этого слоя. +# Дублировать объект в двух источниках правды — значит спорить за владение, +# поэтому чарту достаточно install.createNamespace на случай чистого кластера. diff --git a/k3s/manifests/vault/bridge-vault.yaml b/k3s/manifests/vault/bridge-vault.yaml new file mode 100644 index 0000000..617930e --- /dev/null +++ b/k3s/manifests/vault/bridge-vault.yaml @@ -0,0 +1,41 @@ +# DNS-мост: имя `vault` в namespace vault → compose-контейнер vault. +# Service без селектора + ручной Endpoints, по образцу bridge-gitea/bridge-postgres. +# +# Зачем: Vault контура живёт в docker-compose, а не в кластере (см. README). +# vault-agent-injector и внедрённые им в поды агенты работают ВНУТРИ k3s, где +# резолвит CoreDNS, и имя compose-сети `vault` там не существует. Поды ходят до +# 172.28.0.0/16 через SNAT на ноде, поэтому мост указывает на статический IP. +# +# __VAULT_BRIDGE_IP__ подставляет ansible (роль sarex_stack, tasks/vault-k8s.yml) +# из переменной VAULT_BRIDGE_IP — она же задаёт ipv4_address сервиса vault +# в docker-compose.yaml. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: vault +--- +apiVersion: v1 +kind: Service +metadata: + name: vault + namespace: vault +spec: + ports: + - name: http + port: 8200 + targetPort: 8200 + protocol: TCP +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: vault + namespace: vault +subsets: + - addresses: + - ip: __VAULT_BRIDGE_IP__ + ports: + - name: http + port: 8200 + protocol: TCP diff --git a/k3s/manifests/vault/vault-auth.yaml b/k3s/manifests/vault/vault-auth.yaml new file mode 100644 index 0000000..ab88946 --- /dev/null +++ b/k3s/manifests/vault/vault-auth.yaml @@ -0,0 +1,44 @@ +# Token reviewer для метода auth/kubernetes. +# +# Vault живёт ВНЕ кластера, поэтому не может воспользоваться ни своим +# ServiceAccount-токеном, ни CA из /var/run/secrets. Чтобы он мог проверять +# токены подов, ему выдаётся отдельный ServiceAccount с правом обращаться к +# TokenReview API — ClusterRole `system:auth-delegator` (встроенная). +# +# Схема целиком: под с аннотациями vault.hashicorp.com/* → injector добавляет +# init-контейнер vault-agent → агент логинится в Vault токеном ServiceAccount'а +# пода → Vault валидирует этот токен через TokenReview от имени vault-auth → +# при успехе отдаёт секреты, агент кладёт их файлами в /vault/secrets/. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-auth + namespace: vault +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault-auth + namespace: vault +--- +# Долгоживущий токен ServiceAccount'а. С k8s 1.24 секрет-токен больше не +# создаётся автоматически при создании SA, а `kubectl create token` выдаёт +# токен с TTL — он протух бы, и Vault перестал бы валидировать логины. +# Секрет типа service-account-token с этой аннотацией заполняет +# token-controller: в него попадают и `token`, и `ca.crt` кластера. +apiVersion: v1 +kind: Secret +metadata: + name: vault-auth-token + namespace: vault + annotations: + kubernetes.io/service-account.name: vault-auth +type: kubernetes.io/service-account-token