Add Ansible playbook to deliver validator-agent to the validators

Run from the jump host: on each validator it updates the git clone in
/opt/cloud-ip-validator, builds the image there, stops and removes the
current container and starts a new one from the new image. Run
parameters live in an env file (deploy/ansible/env/validator-agent.env,
git-ignored, template committed).

The image is built before the running container is touched, so a failed
build leaves the old container running. Hosts are updated in waves
(1, 4, rest) and any failure stops the run. validator_id comes from the
inventory and is checked against the running container before it is
replaced. Only ansible.builtin modules are used, so the validators need
no extra packages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-02 09:23:39 +03:00
1 parent abbee9a08a
commit 49890ff5de
16 files changed
+684 -1

No files matched your search

@@ -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
@@ -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] }}"
@@ -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]
@@ -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 }}
@@ -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
@@ -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 }} запущен
@@ -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') }}"