diff --git a/roles/app_image/README.md b/roles/app_image/README.md new file mode 100644 index 0000000..a58f8e4 --- /dev/null +++ b/roles/app_image/README.md @@ -0,0 +1,72 @@ +# app_image + +Собирает docker-образ приложения **локально** (на control-хосте) и доставляет +его на сервер через `docker save`/`load`. Тулчейн сборки (Go, node, …) на +сервере не нужен — туда едет готовый образ. + +Роль — первый кирпичик деплоя: её задача, чтобы на сервере оказался образ +`:`. Запуском приложения (рендер config и docker-compose, +`docker compose up`) занимается остальная часть плейбука — позже это может стать +отдельным кирпичиком. + +Тег образа — случайный `BUILD_ID`, который роль генерит на каждый прогон. +Дедупликации по содержимому нет: каждый деплой везёт образ и пересоздаёт +контейнер. Для ручного нечастого деплоя это осознанный выбор — простота важнее +экономии одного рестарта. + +## Контракт с приложением + +Приложение реализует **команду сборки образа** в своей рабочей директории. +Роль — инициатор: она вызывает эту команду и передаёт ей случайный `BUILD_ID` +через окружение. Команда обязана собрать **полный** образ и затегать его +`:$BUILD_ID`. + +Пример реализации на Taskfile (тег из `$BUILD_ID`, по умолчанию `dev`): + +```yaml +image: + desc: 'Полный docker-образ. Тег из $BUILD_ID (по умолчанию dev).' + deps: [build] + cmds: + - docker build -t {{.BINARY}}:${BUILD_ID:-dev} . +``` + +`${BUILD_ID:-dev}` держит команду обратно совместимой: `task image` без +переменной по-прежнему собирает `:dev` для локальной работы. + +## Как работает + +1. Роль генерит случайный `BUILD_ID` — это же и есть `app_image_tag`. +2. Гоняет `app_image_command` в `app_image_src_dir` → приложение собирает + `:$BUILD_ID`. +3. `docker save` образа → копирует tar на сервер → `docker load`. +4. Отдаёт факт `app_image_tag`, чистит локальный тег и оба tar-файла. + +Локальный тег на control-хосте снимается по завершении (образ уже на сервере). +Старые образы на сервере подчищает `docker image prune -af` по крону (см. +`playbook-system.yml`). + +## Переменные + +| Переменная | Обязат. | По умолчанию | Назначение | +|---|---|---|---| +| `app_image_name` | да | — | имя приложения; под ним тегается и живёт образ | +| `app_image_src_dir` | да | — | рабочая директория приложения на control-хосте | +| `app_image_command` | нет | `task image` | команда сборки образа (контракт); одна команда через `command`, без shell-синтаксиса | + +## Выход + +- Факт **`app_image_tag`** — тег доставленного образа (= `BUILD_ID`). +- На сервере — образ `:`. + +## Пример + +```yaml +- name: 'Build and deliver application image' + ansible.builtin.import_role: + name: app_image + vars: + app_image_name: 'jellybit' + app_image_src_dir: '{{ (playbook_dir, "..", "jellybit") | path_join }}' +# далее compose-роль использует app_image_tag +``` diff --git a/roles/app_image/defaults/main.yml b/roles/app_image/defaults/main.yml new file mode 100644 index 0000000..08022fc --- /dev/null +++ b/roles/app_image/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# defaults file for app_image + +# Имя приложения/образа (обязательно). Под этим именем приложение тегает +# собранный образ (:), под ним же образ живёт на сервере. +app_image_name: "" + +# Рабочая директория приложения на control-хосте — репозиторий с исходниками +# и командой сборки образа. Обязательный параметр: задаётся явно в плейбуке. +app_image_src_dir: "" + +# Команда сборки образа — контракт приложения. Запускается в app_image_src_dir, +# получает случайный BUILD_ID через окружение и обязана собрать ПОЛНЫЙ образ, +# затегав его :$BUILD_ID. По умолчанию — Taskfile-таск `task image`. +# Исполняется через command (без shell): одна команда, без пайпов/&&/VAR=x-префиксов. +# Подробности контракта — в README.md роли. +app_image_command: "task image" diff --git a/roles/app_image/meta/main.yml b/roles/app_image/meta/main.yml new file mode 100644 index 0000000..123e296 --- /dev/null +++ b/roles/app_image/meta/main.yml @@ -0,0 +1,21 @@ +--- +galaxy_info: + role_name: app_image + author: "Anton Vakhrushev" + description: "Локальная сборка образа приложения и доставка его на сервер через docker save/load" # yamllint disable-line rule:line-length + license: "MIT" + min_ansible_version: "2.14" + platforms: + - name: Ubuntu + versions: + - all + - name: Debian + versions: + - all + galaxy_tags: + - docker + - image + - build + - deploy + +dependencies: [] diff --git a/roles/app_image/tasks/main.yml b/roles/app_image/tasks/main.yml new file mode 100644 index 0000000..ba50a55 --- /dev/null +++ b/roles/app_image/tasks/main.yml @@ -0,0 +1,98 @@ +--- +# tasks file for app_image +# +# Роль-инициатор сборки. Гоняет команду сборки приложения на control-хосте и +# доставляет получившийся образ на сервер через docker save/load. Тулчейн сборки +# (Go, node, …) на сервере не нужен — туда едет готовый образ. +# +# Контракт приложения (app_image_command): команда в app_image_src_dir получает +# BUILD_ID из окружения и собирает полный образ :$BUILD_ID. +# +# Тег образа = случайный BUILD_ID (роль его генерит). Дедупликации по содержимому +# нет — каждый деплой везёт образ и пересоздаёт контейнер. Для ручного нечастого +# деплоя это осознанный выбор: простота важнее экономии рестарта. Старые образы +# на сервере чистит `docker image prune -af` по крону (см. playbook-system.yml). +# +# Рассчитана на один целевой хост за прогон: сборка делегируется на control-хост, +# при нескольких хостах в play повторится на каждый (мы деплоим последовательно). +# +# Выход: факт app_image_tag (= BUILD_ID) + образ : +# на сервере. Тег забирает следующий кирпичик (рендер compose). + +- name: "Validate role input" + ansible.builtin.assert: + that: + # Формат имени docker-образа: он же идёт в имя tar-файла (vars/main.yml), + # поэтому '/' и прочее ломало бы путь и docker-референс. + - app_image_name is string and app_image_name is match('^[a-z0-9][a-z0-9._-]*$') + - app_image_src_dir is string and app_image_src_dir | length > 0 + - app_image_command is string and app_image_command | length > 0 + fail_msg: >- + app_image_name — валидное имя docker-образа (^[a-z0-9][a-z0-9._-]*$); + app_image_src_dir и app_image_command — непустые строки + quiet: true + +- name: "Generate build id (image tag)" + ansible.builtin.set_fact: + app_image_tag: "{{ lookup('ansible.builtin.password', '/dev/null chars=ascii_lowercase,digits length=16') }}" + +- name: "Build image and deliver it to server" + # В --check command-таски пропускаются, tar не создаётся и copy упал бы «нет + # файла на контроллере». Пропускаем всю доставку — факт app_image_tag уже + # выставлен, поэтому рендер compose ниже честно покажет diff со своим тегом. + when: "not ansible_check_mode" + block: + - name: "Build image locally via app contract" + delegate_to: localhost + # Сборка идёт от локального юзера. Инвентарь включает become глобально, но + # локальная сборка под root не нужна — фиксируем инвариант явно. + become: false + ansible.builtin.command: + cmd: "{{ app_image_command }}" + chdir: "{{ app_image_src_dir }}" + environment: + BUILD_ID: "{{ app_image_tag }}" + # Локальная сборка — средство доставки; факт изменения системы (сервера) + # отражает загрузка образа ниже. + changed_when: false + + - name: "Save image to tar locally" + delegate_to: localhost + become: false + ansible.builtin.command: + cmd: "docker save --output {{ app_image_local_tar }} {{ app_image_name }}:{{ app_image_tag }}" + changed_when: false + + - name: "Copy image tar to server" + ansible.builtin.copy: + src: "{{ app_image_local_tar }}" + dest: "{{ app_image_remote_tar }}" + mode: "0644" + + - name: "Load image on server" + ansible.builtin.command: + cmd: "docker load --input {{ app_image_remote_tar }}" + changed_when: true + + always: + - name: "Remove local image tar" + delegate_to: localhost + become: false + ansible.builtin.file: + path: "{{ app_image_local_tar }}" + state: absent + + - name: "Remove remote image tar" + ansible.builtin.file: + path: "{{ app_image_remote_tar }}" + state: absent + + # Локальный тег — временная ручка (на сервер образ уехал через save/load). + # Снимаем, чтобы control-хост не копил : от каждого прогона. + - name: "Remove transient local image tag" + delegate_to: localhost + become: false + ansible.builtin.command: + cmd: "docker image rm {{ app_image_name }}:{{ app_image_tag }}" + changed_when: false + failed_when: false diff --git a/roles/app_image/vars/main.yml b/roles/app_image/vars/main.yml new file mode 100644 index 0000000..b104323 --- /dev/null +++ b/roles/app_image/vars/main.yml @@ -0,0 +1,9 @@ +--- +# vars file for app_image — внутренние значения роли, не для переопределения. + +# Временный tar с образом для переноса control-хост → сервер. Имя завязано на +# имя приложения и тег (= BUILD_ID), чтобы параллельные приложения и прогоны не +# топтали файлы друг друга. Референсит app_image_tag (выставляется в tasks до +# блока доставки) — vars роли шаблонизируются лениво, факт уже определён. +app_image_local_tar: '/tmp/app_image-{{ app_image_name }}-{{ app_image_tag }}.tar' +app_image_remote_tar: '/tmp/app_image-{{ app_image_name }}-{{ app_image_tag }}.tar'