diff --git a/.gitignore b/.gitignore index 0ac2c8f..8fa8faf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ !/deploy/docker/.env.example !/deploy/docker/.env.prod.example /deploy/docker/control-api/control-api.docker.yaml +# Ansible: рабочий env-файл запуска validator-agent (шаблон .example остаётся в git). +/deploy/ansible/env/*.env /rxprod-compose/.env # Live runtime database for the rxprod-compose control-api container. diff --git a/README.md b/README.md index bc5b7d4..bd4ccc1 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ docs/ документация и планы доработок ## Публикация и эксплуатация - **Бинарники.** Готовые linux/amd64 лежат в `bin/` и **не обновляются автоматически**: после правок кода пересоберите их и обновите `SHA256SUMS` (команды — [docs/SETUP.md](docs/SETUP.md#вариант-b-сборка-из-исходников)); Dockerfile копируют именно `bin/*`. - **systemd.** Юниты в `deploy/systemd/`; у `control-api` — `EnvironmentFile` с учётными данными OpenStack. -- **Docker.** `deploy/docker/docker-compose.yml` + `docker-compose.override.yml` (dev, mock) или `docker-compose.prod.yml` (без публикации портов, `restart: unless-stopped`). Какие сервисы поднимаются на хосте, задаёт `COMPOSE_PROFILES`: `control-plane`, `dashboard`, `prober`, `validator`. БД — volume `cloud-ip-validator-db`. +- **Docker.** `deploy/docker/docker-compose.yml` + `docker-compose.override.yml` (dev, mock) или `docker-compose.prod.yml` (без публикации портов, `restart: unless-stopped`). Какие сервисы поднимаются на хосте, задаёт `COMPOSE_PROFILES`: `control-plane`, `dashboard`, `prober`, `validator`. БД — volume `cloud-ip-validator-db`. Массовая доставка `validator-agent` на валидаторы (сборка образа на каждом хосте, замена контейнера) — Ansible-сценарий [`deploy/ansible/`](deploy/ansible/README.md). - **Реальный стенд.** `rxprod-compose/` — compose с готовыми образами, собственным `control-api.yaml` и каталогом БД `capi-db/`; `.env` с учётными данными в репозиторий не входит. - **Миграции** применяются при старте `control-api`; версия схемы — `PRAGMA user_version`. Начальная загрузка (`validators`, `sites`, `targets`, `check_types`, `inbound_checks`) выполняется только в пустые таблицы. - Остановка (`SIGTERM`) корректно завершает HTTP-сервер и фоновые циклы. Состояние автоцикла и очереди сохраняется в БД. diff --git a/deploy/ansible/README.md b/deploy/ansible/README.md new file mode 100644 index 0000000..d405154 --- /dev/null +++ b/deploy/ansible/README.md @@ -0,0 +1,107 @@ +# Доставка validator-agent на валидаторы (Ansible) + +Сценарий запускается с jump-хоста и на каждой ВМ-валидаторе: обновляет git-клон в `/opt/cloud-ip-validator`, +**собирает образ на самом хосте**, останавливает и удаляет старый контейнер `cloud-ip-validator-validator-agent` +и поднимает на его месте новый. Параметры запуска агента вынесены в env-файл. + +Порядок безопасен: сначала проверки, обновление кода и сборка образа, и только потом замена контейнера. Если что-то +упало до замены, старый контейнер продолжает работать. Простой валидатора — секунды (`stop` + `rm` + `run`). + +## Требования + +| Где | Что | +|---|---| +| jump-хост (Debian 13) | `apt install ansible-core`; SSH-доступ по ключу ко всем валидаторам | +| валидаторы | Docker, git, клон репозитория в `/opt/cloud-ip-validator`, `sudo` без пароля для SSH-пользователя, доступ к Gitea и Docker Hub (`alpine:3.20`), архитектура x86_64 | + +Только модули `ansible.builtin`: Python Docker SDK и дополнительные коллекции на валидаторах не нужны. +Go на валидаторах не нужен: образ копирует закоммиченный `bin/validator-agent` (его сумма проверяется по `bin/SHA256SUMS`). + +## Подготовка (один раз) + +```bash +cd deploy/ansible +cp env/validator-agent.env.example env/validator-agent.env +chmod 600 env/validator-agent.env +$EDITOR env/validator-agent.env # адрес control-api, токен, способы самопроверки, таймауты +ansible validators -m ping # проверка связи (20 хостов: validator-1 ... validator-20) +``` + +- В `inventory/hosts.yml` 20 хостов `validator-1 … validator-20` с адресами (`ansible_host`) и переменной `validator_id` + (`vkiplab-v1 … vkiplab-v20`, как в control-api). Соответствие `validator-N` → `vkiplab-vN` задано по порядку номеров; + **сценарий сверяет `validator_id` с тем, что записано в работающем контейнере на хосте, и при расхождении останавливается до замены + контейнера**. `VALIDATOR_AGENT_VALIDATOR_ID` в env-файл писать не нужно: сценарий подставляет его из inventory. +- Отпечатки хостов запоминаются при первом подключении (`StrictHostKeyChecking=accept-new` в `ansible.cfg`); сменившийся отпечаток известного хоста — ошибка. +- SSH: пользователь `debian` и ключ `~/.ssh/vk_cloud_priv.key` на jump-хосте (права 0600) — одинаковые на всех хостах; меняются в + `inventory/group_vars/validators.yml` (`ansible_user`, `ansible_ssh_private_key_file`). Для docker и записи env-файла сценарий + повышает права через `sudo`. +- Токен в env-файле можно зашифровать: `ansible-vault encrypt env/validator-agent.env`, запускать с `--ask-vault-pass`. + Рабочий `env/validator-agent.env` в git не попадает (`.gitignore`). + +## Запуск + +```bash +cd deploy/ansible +ansible-playbook playbooks/deploy-validator-agent.yml --limit vkiplab-v1 # канарейка: один валидатор +ansible-playbook playbooks/deploy-validator-agent.yml # все валидаторы волнами +ansible-playbook playbooks/deploy-validator-agent.yml --check # только проверки (preflight), без изменений +``` + +Волны по умолчанию: 1 хост, затем 4, затем все остальные (`deploy_serial: [1, 4, "100%"]`). Любой сбой в волне останавливает +прогон: следующая волна не начнётся. Все сразу: `-e '{"deploy_serial": ["100%"]}'`. + +Что выкатывается — `deploy_ref` (по умолчанию `main`): ветка, тег или коммит. Выкатить нужно **запушенный** коммит: +валидаторы берут код из репозитория, а не с jump-хоста. После правок кода сначала пересоберите `bin/validator-agent` +(см. [SETUP.md](../../docs/SETUP.md#обновление-образов-после-изменения-кода)) и закоммитьте его вместе с `bin/SHA256SUMS`. + +### Откат + +```bash +ansible-playbook playbooks/deploy-validator-agent.yml -e deploy_ref=<предыдущий коммит или тег> +``` + +Для тега или коммита клон переходит в detached HEAD; следующий запуск с `deploy_ref=main` возвращает его на ветку. + +## Что делает сценарий на каждом хосте + +1. **preflight** — env-файл на jump-хосте существует и в нём задан `VALIDATOR_AGENT_CONTROL_API_URL` (адрес-пример + `example.com` не принимается); на валидаторе отвечает Docker, есть git и клон, архитектура x86_64; `validator_id` из inventory совпадает + с `validator_id` работающего контейнера. Показывает текущий контейнер. +2. **git** — `fetch`, затем клон сбрасывается на `deploy_ref` (`checkout --force`). Через `git`, а не модуль `git`: + учётные данные, уже настроенные в клоне, не трогаются. Локальные правки отслеживаемых файлов в клоне будут сброшены; + неотслеживаемые и игнорируемые (`.env.*`) — нет. +3. **build** — проверка `bin/validator-agent` по `bin/SHA256SUMS`; `docker build --platform linux/amd64` с контекстом в корне репозитория, + образ получает метку ревизии (`cloud-ip-validator-validator-agent:<хеш>`) и `latest`. +4. **replace** — env-файл копируется в `/opt/cloud-ip-validator/deploy/docker/.env.validator` (0600; путь закрыт `.gitignore`), + затем `docker stop` → `docker rm -f` → `docker run -d --restart unless-stopped --cap-add NET_RAW --env-file …`. +5. **verify** — ждёт строку `registered` в логе агента (регистрация в control-api), проверяет, что контейнер запущен, без перезапусков + и на только что собранном образе. При неудаче хост падает с последними строками лога, следующие волны не стартуют. + Затем остаются 3 последних образа с метками ревизий (`keep_images`), остальные и «висячие» удаляются. + +Все шаги можно запускать по тегам: `--tags preflight|git|build|replace|verify`. + +## Параметры + +**`env/validator-agent.env`** — запуск агента (читает `deploy/docker/validator-agent/docker-entrypoint.sh`): +`VALIDATOR_AGENT_CONTROL_API_URL`, `CONTROL_API_AGENT_TOKEN`, `VALIDATOR_AGENT_SELF_CHECK_METHODS`, +`VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS`, `VALIDATOR_AGENT_POLL_INTERVAL_SECONDS`, `VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS`, +`VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS`, `VALIDATOR_AGENT_ICMP_COUNT`, `VALIDATOR_AGENT_SSH_ENABLED`, `VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS`. +Смена только env-файла не требует изменения кода: достаточно запустить сценарий (контейнер пересоздаётся с новыми значениями). + +**`inventory/group_vars/validators.yml`** — доставка: `repo_dir`, `repo_remote`, `deploy_ref`, `git_user` (пользователь, у которого в клоне +настроен доступ к репозиторию; пусто — SSH-пользователь), `image_name`, `container_name`, `platform`, `keep_images`, `restart_policy`, +`capabilities`, `log_max_size`, `log_max_file`, `stop_timeout`, `verify_retries`, `verify_delay`, `local_env_file`. Любой параметр +переопределяется ключом `-e`. + +## Разбор сбоев + +- **«Нет env-файла» / «не задан VALIDATOR_AGENT_CONTROL_API_URL»** — см. «Подготовка». +- **«в запущенном контейнере validator_id=…, а в inventory …»** — соответствие `validator-N` и `validator_id` в `inventory/hosts.yml` + неверно для этого хоста: исправьте inventory (контейнер при этом не тронут). +- **Контейнер не прошёл проверку** — в сообщении последние строки лога. `registration failed` — control-api недоступен с валидатора + или `validator_id` не заведён в control-api. Контейнер остаётся на хосте для разбора (`docker logs`). +- **`dubious ownership`, `could not read Username`** — клон принадлежит другому пользователю или учётные данные git есть у другого: + задайте `git_user`. +- **`bin/validator-agent` не совпадает с суммой** — в репозитории устарел `bin/SHA256SUMS`: пересоберите бинарник и обновите сумму. +- **Сборка падает на `FROM alpine:3.20` / `apk add`** — с валидатора нет доступа к Docker Hub / репозиториям Alpine. +- **Старый образ другого имени остаётся** — сценарий чистит только образы `image_name`; образ с прежним именем удалите вручную (`docker rmi`). diff --git a/deploy/ansible/ansible.cfg b/deploy/ansible/ansible.cfg new file mode 100644 index 0000000..77eadc8 --- /dev/null +++ b/deploy/ansible/ansible.cfg @@ -0,0 +1,15 @@ +# Запускать из каталога deploy/ansible (там же лежит этот файл). +[defaults] +inventory = inventory/hosts.yml +roles_path = roles +forks = 20 +retry_files_enabled = False +interpreter_python = auto_silent +callback_result_format = yaml + +[ssh_connection] +pipelining = True +# accept-new: отпечаток нового хоста запоминается при первом подключении (без +# интерактивного вопроса); изменившийся отпечаток известного хоста по-прежнему +# приводит к ошибке. +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new diff --git a/deploy/ansible/env/validator-agent.env.example b/deploy/ansible/env/validator-agent.env.example new file mode 100644 index 0000000..917c0fd --- /dev/null +++ b/deploy/ansible/env/validator-agent.env.example @@ -0,0 +1,29 @@ +# Параметры запуска validator-agent. Скопируйте в validator-agent.env и заполните: +# cp validator-agent.env.example validator-agent.env && chmod 600 validator-agent.env +# Файл передаётся контейнеру как `docker run --env-file` (формат KEY=VALUE, без +# кавычек и пробелов вокруг "="). Читает их deploy/docker/validator-agent/docker-entrypoint.sh. +# VALIDATOR_AGENT_VALIDATOR_ID сюда НЕ пишется: сценарий подставляет имя хоста. + +# Адрес control-api (обязательно). Для внешнего размещения — адрес, доступный +# с валидаторов напрямую (через него же работает способ самопроверки control_api). +VALIDATOR_AGENT_CONTROL_API_URL=https://control-api.example.com + +# Токен агентов (CONTROL_API_AGENT_TOKEN на стороне control-api). Пусто — если +# токен на control-api ещё не включён. +CONTROL_API_AGENT_TOKEN= + +# Способы самопроверки в порядке приоритета: ip_echo, control_api. +# Самопроверка проходит, если адрес подтвердил любой способ. Без пробелов. +VALIDATOR_AGENT_SELF_CHECK_METHODS=[control_api,ip_echo] +# Таймаут одного способа, секунд. +VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS=10 + +# Период опроса control-api, секунд. +VALIDATOR_AGENT_POLL_INTERVAL_SECONDS=5 + +# Исходящие проверки. +VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS=10 +VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS=5 +VALIDATOR_AGENT_ICMP_COUNT=3 +VALIDATOR_AGENT_SSH_ENABLED=false +VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5 diff --git a/deploy/ansible/inventory/group_vars/validators.yml b/deploy/ansible/inventory/group_vars/validators.yml new file mode 100644 index 0000000..92870b0 --- /dev/null +++ b/deploy/ansible/inventory/group_vars/validators.yml @@ -0,0 +1,48 @@ +--- +# Параметры доставки validator-agent (не секреты). Любой из них можно +# переопределить в командной строке: -e deploy_ref=<коммит|тег>. +# Параметры запуска самого агента (адрес control-api, токен, способы +# самопроверки, таймауты) лежат в env-файле, см. local_env_file. + +# SSH: пользователь и ключ одинаковы на jump-хосте и на валидаторах. Путь к +# ключу — на jump-хосте (ключ с правами 0600). Для docker и записи env-файла +# сценарий повышает права через sudo (become). +ansible_user: debian +ansible_ssh_private_key_file: ~/.ssh/vk_cloud_priv.key + +# --- git-клон на валидаторе --------------------------------------------- +repo_dir: /opt/cloud-ip-validator +repo_remote: origin +# Ветка, тег или коммит, который нужно выкатить (откат: -e deploy_ref=<коммит>). +deploy_ref: main +# Пользователь, у которого в клоне настроен доступ к репозиторию (git fetch). +# Пусто — git работает от SSH-пользователя без sudo. +git_user: "" + +# --- образ и контейнер -------------------------------------------------- +image_name: cloud-ip-validator-validator-agent +container_name: cloud-ip-validator-validator-agent +platform: linux/amd64 +dockerfile: deploy/docker/validator-agent/Dockerfile +# Сколько образов с метками ревизий хранить (кроме latest); старые удаляются. +keep_images: 3 + +# --- запуск контейнера (как в deploy/docker/RUN.txt) -------------------- +restart_policy: unless-stopped +capabilities: [NET_RAW] +log_max_size: 10m +log_max_file: "3" +stop_timeout: 10 + +# --- проверка после запуска --------------------------------------------- +verify_retries: 10 +verify_delay: 3 + +# --- env-файл на jump-хосте --------------------------------------------- +# Параметры запуска агента. Рабочий файл создаётся из validator-agent.env.example +# и в git не попадает. Файл можно зашифровать: ansible-vault encrypt <файл> +# (тогда запускайте с --ask-vault-pass или --vault-password-file). +local_env_file: "{{ playbook_dir }}/../env/validator-agent.env" +# Куда файл копируется на валидатор (путь закрыт .gitignore репозитория, +# переживает git reset). +remote_env_file: "{{ repo_dir }}/deploy/docker/.env.validator" diff --git a/deploy/ansible/inventory/hosts.yml b/deploy/ansible/inventory/hosts.yml new file mode 100644 index 0000000..1cc8bf0 --- /dev/null +++ b/deploy/ansible/inventory/hosts.yml @@ -0,0 +1,69 @@ +# Валидаторы. Имя хоста (validator-N) — это имя ВМ; validator_id в control-api +# другой (vkiplab-vN) и задаётся переменной validator_id. Соответствие +# validator-N -> vkiplab-vN предполагается по порядку номеров; сценарий +# сверяет validator_id с тем, что записано в работающем контейнере на хосте, +# и при расхождении останавливается ДО замены контейнера. +all: + children: + validators: + hosts: + validator-1: + ansible_host: 10.11.12.161 + validator_id: vkiplab-v1 + validator-2: + ansible_host: 10.11.12.177 + validator_id: vkiplab-v2 + validator-3: + ansible_host: 10.11.12.33 + validator_id: vkiplab-v3 + validator-4: + ansible_host: 10.11.12.41 + validator_id: vkiplab-v4 + validator-5: + ansible_host: 10.11.12.193 + validator_id: vkiplab-v5 + validator-6: + ansible_host: 10.11.12.197 + validator_id: vkiplab-v6 + validator-7: + ansible_host: 10.11.12.198 + validator_id: vkiplab-v7 + validator-8: + ansible_host: 10.11.12.169 + validator_id: vkiplab-v8 + validator-9: + ansible_host: 10.11.12.185 + validator_id: vkiplab-v9 + validator-10: + ansible_host: 10.11.12.186 + validator_id: vkiplab-v10 + validator-11: + ansible_host: 10.11.12.199 + validator_id: vkiplab-v11 + validator-12: + ansible_host: 10.11.12.196 + validator_id: vkiplab-v12 + validator-13: + ansible_host: 10.11.12.194 + validator_id: vkiplab-v13 + validator-14: + ansible_host: 10.11.12.195 + validator_id: vkiplab-v14 + validator-15: + ansible_host: 10.11.12.65 + validator_id: vkiplab-v15 + validator-16: + ansible_host: 10.11.12.189 + validator_id: vkiplab-v16 + validator-17: + ansible_host: 10.11.12.190 + validator_id: vkiplab-v17 + validator-18: + ansible_host: 10.11.12.168 + validator_id: vkiplab-v18 + validator-19: + ansible_host: 10.11.12.191 + validator_id: vkiplab-v19 + validator-20: + ansible_host: 10.11.12.73 + validator_id: vkiplab-v20 diff --git a/deploy/ansible/playbooks/deploy-validator-agent.yml b/deploy/ansible/playbooks/deploy-validator-agent.yml new file mode 100644 index 0000000..b1e0504 --- /dev/null +++ b/deploy/ansible/playbooks/deploy-validator-agent.yml @@ -0,0 +1,17 @@ +--- +# Доставка validator-agent на все валидаторы: обновить git-клон, собрать образ +# на каждом хосте, остановить и удалить старый контейнер, поднять новый. +# Запуск (из deploy/ansible): ansible-playbook playbooks/deploy-validator-agent.yml +- name: Deliver validator-agent to the validators + hosts: validators + become: true + gather_facts: false + # Волны: один хост (канарейка), затем четыре, затем все остальные. Любой + # сбой останавливает прогон — следующая волна не начнётся. + # Все сразу: -e '{"deploy_serial": ["100%"]}'. + serial: "{{ deploy_serial }}" + max_fail_percentage: 0 + vars: + deploy_serial: [1, 4, "100%"] + roles: + - validator_agent diff --git a/deploy/ansible/roles/validator_agent/tasks/build.yml b/deploy/ansible/roles/validator_agent/tasks/build.yml new file mode 100644 index 0000000..2c1bde0 --- /dev/null +++ b/deploy/ansible/roles/validator_agent/tasks/build.yml @@ -0,0 +1,40 @@ +--- +# Dockerfile ничего не компилирует: в образ копируется закоммиченный +# bin/validator-agent. Не даём выкатить бинарник, не совпадающий с суммой. +- name: Check bin/validator-agent against SHA256SUMS + ansible.builtin.shell: | + set -o pipefail + grep -E '[[:space:]]validator-agent$' SHA256SUMS | sha256sum -c - + args: + chdir: "{{ repo_dir }}/bin" + executable: /bin/bash + changed_when: false + +# Контекст сборки — корень репозитория (так и в SETUP.md). Слои кэшируются, +# при смене bin/ образ пересобирается сам. +- name: Build the image + ansible.builtin.command: + argv: + - docker + - build + - --platform + - "{{ platform }}" + - --label + - "git.rev={{ rev_after.stdout }}" + - --label + - deployed.by=ansible + - -t + - "{{ image_ref }}" + - -f + - "{{ dockerfile }}" + - . + chdir: "{{ repo_dir }}" + +- name: Tag the image as latest + ansible.builtin.command: "docker tag {{ image_ref }} {{ image_name }}:latest" + +- name: Read the image id + ansible.builtin.command: + argv: [docker, image, inspect, --format, "{% raw %}{{.Id}}{% endraw %}", "{{ image_ref }}"] + changed_when: false + register: built_image diff --git a/deploy/ansible/roles/validator_agent/tasks/git.yml b/deploy/ansible/roles/validator_agent/tasks/git.yml new file mode 100644 index 0000000..d09773b --- /dev/null +++ b/deploy/ansible/roles/validator_agent/tasks/git.yml @@ -0,0 +1,80 @@ +--- +# Команды git вместо модуля git: модуль переписывает URL remote и может +# затереть учётные данные, уже настроенные в клоне. Работаем от git_user +# (или от SSH-пользователя, если он не задан). +- name: Remember the current revision + ansible.builtin.command: "{{ git_cmd }} rev-parse HEAD" + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + changed_when: false + register: rev_before + +- name: Fetch the remote + ansible.builtin.command: "{{ git_cmd }} fetch --prune --tags {{ repo_remote }}" + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + changed_when: false + +# deploy_ref — ветка, тег или коммит. Ветка берётся из remote (свежая), +# тег и коммит — как есть. +- name: Resolve deploy_ref as a remote branch + ansible.builtin.command: >- + {{ git_cmd }} rev-parse --verify --quiet + refs/remotes/{{ repo_remote }}/{{ deploy_ref }}^{commit} + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + changed_when: false + failed_when: false + register: ref_branch + +- name: Resolve deploy_ref as a tag or commit + ansible.builtin.command: "{{ git_cmd }} rev-parse --verify --quiet {{ deploy_ref }}^{commit}" + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + changed_when: false + failed_when: false + register: ref_other + when: ref_branch.rc != 0 + +- name: Fail if deploy_ref does not exist + ansible.builtin.assert: + that: ref_branch.rc == 0 or (ref_other.rc | default(1)) == 0 + fail_msg: "deploy_ref={{ deploy_ref }} не найден в {{ repo_dir }} ({{ repo_remote }})." + quiet: true + +- name: Fix the target revision + ansible.builtin.set_fact: + target_rev: "{{ ref_branch.stdout if ref_branch.rc == 0 else ref_other.stdout }}" + +# Ветка: остаёмся на локальной ветке (клон не уходит в detached HEAD), +# сброс на remote. Тег или коммит: detached HEAD. +- name: Check out the branch + ansible.builtin.command: "{{ git_cmd }} checkout --force -B {{ deploy_ref }} {{ target_rev }}" + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + when: ref_branch.rc == 0 + changed_when: rev_before.stdout != target_rev + +- name: Check out the tag or commit + ansible.builtin.command: "{{ git_cmd }} checkout --force --detach {{ target_rev }}" + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + when: ref_branch.rc != 0 + changed_when: rev_before.stdout != target_rev + +- name: Read the deployed revision + ansible.builtin.command: "{{ git_cmd }} rev-parse HEAD" + become: "{{ git_user | length > 0 }}" + become_user: "{{ git_user }}" + changed_when: false + register: rev_after + +- name: Check that the clone is at the target revision + ansible.builtin.assert: + that: rev_after.stdout == target_rev + fail_msg: "Клон на {{ rev_after.stdout }}, ожидалось {{ target_rev }}." + quiet: true + +- name: Remember the short revision + ansible.builtin.set_fact: + deploy_rev: "{{ rev_after.stdout[:12] }}" diff --git a/deploy/ansible/roles/validator_agent/tasks/main.yml b/deploy/ansible/roles/validator_agent/tasks/main.yml new file mode 100644 index 0000000..2125242 --- /dev/null +++ b/deploy/ansible/roles/validator_agent/tasks/main.yml @@ -0,0 +1,33 @@ +--- +# Порядок важен: сначала всё, что не трогает работающий контейнер (проверки, +# обновление кода, сборка образа), и только потом замена контейнера. Если +# что-то упало до replace, старый контейнер продолжает работать. +- name: Preflight checks + ansible.builtin.import_tasks: preflight.yml + tags: [preflight] + +- name: Dry run stops after preflight + ansible.builtin.debug: + msg: "check mode: git, build, replace and verify are skipped" + when: ansible_check_mode + tags: [always] + +- name: Update the git clone + ansible.builtin.import_tasks: git.yml + when: not ansible_check_mode + tags: [git] + +- name: Build the image + ansible.builtin.import_tasks: build.yml + when: not ansible_check_mode + tags: [build] + +- name: Replace the container + ansible.builtin.import_tasks: replace.yml + when: not ansible_check_mode + tags: [replace] + +- name: Verify the new container + ansible.builtin.import_tasks: verify.yml + when: not ansible_check_mode + tags: [verify] diff --git a/deploy/ansible/roles/validator_agent/tasks/preflight.yml b/deploy/ansible/roles/validator_agent/tasks/preflight.yml new file mode 100644 index 0000000..443bf51 --- /dev/null +++ b/deploy/ansible/roles/validator_agent/tasks/preflight.yml @@ -0,0 +1,122 @@ +--- +# --- на jump-хосте (один раз) ------------------------------------------- +- name: Check that the env file exists on the jump host + ansible.builtin.stat: + path: "{{ local_env_file }}" + delegate_to: localhost + become: false + run_once: true + check_mode: false + register: env_file_stat + +- name: Fail early without an env file + ansible.builtin.assert: + that: env_file_stat.stat.exists + fail_msg: >- + Нет env-файла {{ local_env_file }}. Создайте его: + cp env/validator-agent.env.example env/validator-agent.env и заполните. + quiet: true + run_once: true + +# Содержимое файла (в нём токен) не выводится: разбор идёт в задаче с no_log, +# а проверка и её сообщение — по готовым булевым значениям. +- name: Inspect the env file without printing it + ansible.builtin.set_fact: + env_url_set: "{{ env_file_text is regex('(?m)^VALIDATOR_AGENT_CONTROL_API_URL=\\S+') }}" + env_url_is_example: "{{ env_file_text is regex('(?m)^VALIDATOR_AGENT_CONTROL_API_URL=\\S*example\\.com') }}" + vars: + env_file_text: "{{ lookup('ansible.builtin.file', local_env_file) }}" + run_once: true + no_log: true + +- name: Check that the env file sets the control-api address + ansible.builtin.assert: + that: + - env_url_set | bool + - not (env_url_is_example | bool) + fail_msg: >- + В {{ local_env_file }} не задан VALIDATOR_AGENT_CONTROL_API_URL + (или остался адрес-пример example.com). + quiet: true + run_once: true + +# --- на каждом валидаторе ----------------------------------------------- +- name: Check that Docker answers + ansible.builtin.command: docker version --format {% raw %}'{{.Server.Version}}'{% endraw %} + changed_when: false + check_mode: false + +- name: Check that git is installed + ansible.builtin.command: git --version + changed_when: false + check_mode: false + +- name: Check that the git clone exists + ansible.builtin.stat: + path: "{{ repo_dir }}/.git" + check_mode: false + register: clone_stat + +- name: Fail without a clone + ansible.builtin.assert: + that: clone_stat.stat.exists + fail_msg: "Нет git-клона {{ repo_dir }} на {{ inventory_hostname }}." + quiet: true + +- name: Read the CPU architecture + ansible.builtin.command: uname -m + changed_when: false + check_mode: false + register: arch + +- name: The image is linux/amd64 only + ansible.builtin.assert: + that: arch.stdout in ['x86_64', 'amd64'] + fail_msg: "Архитектура {{ arch.stdout }}: образ {{ platform }} здесь не запустится (exec format error)." + quiet: true + +- name: Look at the current container + ansible.builtin.command: >- + docker container inspect --format + {% raw %}'{{.Config.Image}} {{.State.Status}}'{% endraw %} + {{ container_name }} + register: current_container + changed_when: false + failed_when: false + check_mode: false + +# validator_id работающего контейнера — эталон: если он отличается от +# inventory, заменять контейнер нельзя (агент зарегистрировался бы под чужим +# именем, адреса привязывались бы к порту другой ВМ). Выводится только он, +# а не все переменные окружения (там токен). +- name: Read validator_id of the running container + ansible.builtin.shell: | + set -o pipefail + docker container inspect --format '{% raw %}{{range .Config.Env}}{{println .}}{{end}}{% endraw %}' {{ container_name }} \ + | sed -n 's/^VALIDATOR_AGENT_VALIDATOR_ID=//p' + args: + executable: /bin/bash + register: running_validator_id + changed_when: false + failed_when: false + check_mode: false + when: current_container.rc == 0 + +- name: Check validator_id against the running container + ansible.builtin.assert: + that: >- + current_container.rc != 0 + or (running_validator_id.stdout | trim) == '' + or (running_validator_id.stdout | trim) == effective_validator_id + fail_msg: >- + {{ inventory_hostname }}: в запущенном контейнере validator_id={{ running_validator_id.stdout | default('') | trim }}, + а в inventory {{ effective_validator_id }}. Проверьте соответствие имени ВМ и validator_id + в inventory/hosts.yml; контейнер не тронут. + quiet: true + +- name: Report the current container + ansible.builtin.debug: + msg: >- + {{ container_name }}: + {{ current_container.stdout if current_container.rc == 0 else 'контейнера нет (будет создан)' }}; + validator_id для запуска: {{ effective_validator_id }} diff --git a/deploy/ansible/roles/validator_agent/tasks/replace.yml b/deploy/ansible/roles/validator_agent/tasks/replace.yml new file mode 100644 index 0000000..97940df --- /dev/null +++ b/deploy/ansible/roles/validator_agent/tasks/replace.yml @@ -0,0 +1,45 @@ +--- +# Образ уже собран: простой валидатора — только stop + rm + run. +- name: Copy the env file to the validator + ansible.builtin.copy: + src: "{{ local_env_file }}" + dest: "{{ remote_env_file }}" + owner: root + group: root + mode: "0600" + no_log: true + +- name: Check whether the container exists + ansible.builtin.command: "docker container inspect {{ container_name }}" + register: container_exists + changed_when: false + failed_when: false + +- name: Stop the current container + ansible.builtin.command: "docker stop -t {{ stop_timeout }} {{ container_name }}" + when: container_exists.rc == 0 + +- name: Remove the current container + ansible.builtin.command: "docker rm -f {{ container_name }}" + when: container_exists.rc == 0 + +# --restart нужен: агент завершается, если регистрация в control-api не +# удалась, и должен подняться снова. validator_id берётся из inventory +# (validator_id) и перекрывает env-файл. +- name: Start the new container + ansible.builtin.command: + argv: >- + {{ ['docker', 'run', '-d', + '--name', container_name, + '--restart', restart_policy, + '--platform', platform, + '--env-file', remote_env_file, + '-e', 'VALIDATOR_AGENT_VALIDATOR_ID=' ~ effective_validator_id, + '--log-driver', 'json-file', + '--log-opt', 'max-size=' ~ log_max_size, + '--log-opt', 'max-file=' ~ log_max_file, + '--label', 'git.rev=' ~ rev_after.stdout, + '--label', 'deployed.by=ansible'] + + (capabilities | map('regex_replace', '^(.*)$', '--cap-add=\1') | list) + + [image_ref] }} + register: started diff --git a/deploy/ansible/roles/validator_agent/tasks/verify.yml b/deploy/ansible/roles/validator_agent/tasks/verify.yml new file mode 100644 index 0000000..42c1187 --- /dev/null +++ b/deploy/ansible/roles/validator_agent/tasks/verify.yml @@ -0,0 +1,65 @@ +--- +- name: Verify the new container + block: + # Агент пишет "registered" после успешной регистрации в control-api. + - name: Wait for the agent to register in control-api + ansible.builtin.command: "docker logs --tail 200 {{ container_name }}" + register: agent_logs + changed_when: false + until: agent_logs.stdout is search('msg=registered validator_id=' ~ effective_validator_id ~ '(\s|$)') or agent_logs.stderr is search('msg=registered validator_id=' ~ effective_validator_id ~ '(\s|$)') + retries: "{{ verify_retries | int }}" + delay: "{{ verify_delay | int }}" + + - name: Inspect the container + ansible.builtin.command: + argv: [docker, inspect, --format, "{% raw %}{{.State.Running}} {{.RestartCount}} {{.Image}}{% endraw %}", "{{ container_name }}"] + register: container_state + changed_when: false + + - name: Check the container state + ansible.builtin.assert: + that: + - container_state.stdout.split()[0] == 'true' + - container_state.stdout.split()[1] == '0' + - container_state.stdout.split()[2] == built_image.stdout + fail_msg: >- + Контейнер {{ container_name }} в состоянии «{{ container_state.stdout }}» + (ожидалось: запущен, 0 перезапусков, образ {{ built_image.stdout }}). + quiet: true + rescue: + - name: Collect the container log + ansible.builtin.command: "docker logs --tail 30 {{ container_name }}" + register: failed_logs + changed_when: false + failed_when: false + + - name: Fail the host and stop the next waves + ansible.builtin.fail: + msg: |- + {{ inventory_hostname }}: контейнер не прошёл проверку после запуска. + Последние строки лога: + {{ failed_logs.stdout }}{{ failed_logs.stderr }} + +# Старые образы с метками ревизий: оставляем keep_images последних (docker +# выводит от новых к старым), образ работающего контейнера docker не удалит. +- name: List image tags + ansible.builtin.command: + argv: [docker, images, "{{ image_name }}", --format, "{% raw %}{{.Tag}}{% endraw %}"] + register: image_tags + changed_when: false + +- name: Remove old revision images + ansible.builtin.command: "docker rmi {{ image_name }}:{{ item }}" + loop: "{{ (image_tags.stdout_lines | reject('equalto', 'latest') | list)[keep_images | int:] }}" + changed_when: true + failed_when: false + +- name: Remove dangling images + ansible.builtin.command: docker image prune -f + changed_when: false + +- name: Summary + ansible.builtin.debug: + msg: >- + {{ inventory_hostname }} ({{ effective_validator_id }}): {{ deploy_rev }} ({{ deploy_ref }}), + образ {{ built_image.stdout[:19] }}, контейнер {{ container_name }} запущен diff --git a/deploy/ansible/roles/validator_agent/vars/main.yml b/deploy/ansible/roles/validator_agent/vars/main.yml new file mode 100644 index 0000000..9a28269 --- /dev/null +++ b/deploy/ansible/roles/validator_agent/vars/main.yml @@ -0,0 +1,7 @@ +--- +# git с явным safe.directory: клон может принадлежать другому пользователю. +git_cmd: "git -c safe.directory={{ repo_dir }} -C {{ repo_dir }}" +# validator_id в control-api: из inventory, иначе имя хоста. +effective_validator_id: "{{ validator_id | default(inventory_hostname) }}" +# Образ с меткой ревизии, собранный в этом прогоне. +image_ref: "{{ image_name }}:{{ deploy_rev | default('unknown') }}" diff --git a/docs/SETUP.md b/docs/SETUP.md index 9078231..4467973 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -739,6 +739,10 @@ docker compose up -d --build сборке) и `docker rm -f <имя> && docker run ... ` (или `docker restart`, если менялись только переменные окружения, а не сам бинарник/образ). +Для массового обновления валидаторов (git-клон, сборка образа на хосте, +замена контейнера, проверка регистрации) есть Ansible-сценарий: +[`deploy/ansible/`](../deploy/ansible/README.md). + ### Диагностика Docker-развёртывания - **Контейнер сразу падает, в логах `exec format error`** — образ собран