Перейти к основному содержимому

ADR 0160 — Compose установщика едет вместе с ним, как подписанный asset

Статус​

Accepted. Решено сопровождающим 2026-09-13 (AT-026). Расширяет ADR 0149 — подписанный checksums.txt начинает покрывать не только бинарники — и закрывает пробел ADR 0150, который спроектировал установщик одной строкой, не сказав, как compose установки попадёт на машину того, кто устанавливает. Ни один из них не редактируется; этот документ на них ссылается.

Контекст​

E2E установщика на чистой машине впервые прошёл в сессии 8 ФАЗЫ 30 (RN-549) и ответил нет на вопрос, ради ответа на который существовал. install.sh поднимал стек через docker compose -f docker/docker-compose.install.yml — путь, относительный каталогу, откуда он запускался, — использованный в семи местах, включая up -d --wait. Этот файл:

  • не был asset Release;
  • не входил в подписанный checksums.txt;
  • нигде не скачивался — единственными загрузками скрипта были cosign, манифест и его подпись, images.json и бинарник runner.

А compose bind-монтирует два файла относительно себя: ./postgres/init.sql (postgres, создающий расширение pgvector) и ./ollama/pull-models.sh (ollama-model-loader, под profile llm). Обходной шаг в install-e2e.yml приносил ровно эти три из тега, через raw.githubusercontent.com.

Симптом был поздним, и именно это делало его дорогим. Тот, кто следовал однострочнику из runbook в пустой папке, умирал на "no such file or directory" после того, как скрипт проверил собственную подпись, определил машину, спросил базу и записал .env с пятью секретами. Половина установки и сообщение, которое не говорило, что делать.

Измерено при реализации: их четыре, а не три. Путь МИГРАЦИИ (migrar_instalacao_anterior, RN-530) запускает bash docker/backup/test-restore-compose.sh — ещё один относительный путь и скрипт, исполняемый на хосте. E2E никогда до него не доходил, потому что на чистой машине мигрировать нечего, и поэтому обход его не приносил. В папке без checkout миграция умерла бы там — до того, как что-либо удалить, по построению RN-530, но умерла бы.

Ни один слой ниже E2E не мог этого увидеть: каждая деталь работала и имела свой набор тестов. Дефект был между опубликованным артефактом и машиной того, кто устанавливает, — это граница, которую пересекает только E2E, а он запускается только на теге.

Решение​

1. Файлы становятся assets Release и входят в ТОТ ЖЕ подписанный checksums.txt. Четыре, с именами, которые не сталкиваются:

asset в Releaseпуть в репозитории и в папке установкипочему
brabo-install-compose.ymldocker/docker-compose.install.ymlcompose, поднимающий установку
brabo-install-postgres-init.sqldocker/postgres/init.sqlbind-монтируется postgres
brabo-install-ollama-pull-models.shdocker/ollama/pull-models.shbind-монтируется ollama-model-loader
brabo-install-backup-test-restore.shdocker/backup/test-restore-compose.shдоказательство восстановления при миграции

Release плоская — asset это имя, без папки, — а манифест индексирован по имени. init.sql и pull-models.sh достаточно общие, чтобы столкнуться со следующим asset, который кто-нибудь приложит, а два файла с одинаковым именем были бы одной строкой, и проверка одного проходила бы против хеша другого. Поэтому префикс brabo-install-, который также не совпадает с --pattern 'brabo-runner-*', которым job checksums скачивает бинарники.

Таблица живёт в scripts/ci/assets-do-instalador.ts (кто ПУБЛИКУЕТ) и в case функции destino_do_asset_do_instalador в install.sh (кто СКАЧИВАЕТ). Их две, потому что установщик работает с bash 3.2 на macOS и без Node; расхождение проваливает assets-do-instalador.spec.ts, который читает case из shell. Тот же spec ВЫВОДИТ из compose каждый bind-mount ./… и проваливает то, чего нет в таблице, — ровно так дефект и родился: кто-то добавил mount, и никто не спросил, как он поедет.

2. Публикует job checksums из build-runner-binaries.yml, тот же, что уже публикует install.sh: файлы берутся из checkout тега (тот же коммит, что произвёл всё остальное, что там подписывается), копируются под именем asset скриптом, входят в sha256sum, генерирующий манифест, и прикладываются вместе. Ничего не меняется в ожидании assets из RN-565: эти приходят не из матрицы, поэтому ждать их нечего. Job начинает также проверять каждую строку манифеста против файла (sha256sum -c --strict) после проверки подписи и перед приложением — подпись доказывает, кто написал манифест; проверка доказывает, что прикладываемое — это то, что он говорит.

3. install.sh скачивает и проверяет все четыре ДО того, как что-либо спросить или записать. Сразу после verificar_a_si_mesmo и против ТОГО ЖЕ checksums.txt, который эта функция уже проверила (никогда второй загрузки, по причине, которую она записывает). Сравнение — по точному равенству поля (awk), а не по шаблону: в имени есть точки. Три отказа, названных и различных, потому что требуют разных действий — Release не публикует asset (она старше этого ADR или вышла наполовину), манифест его не покрывает, хеш не совпадает (инцидент). Все говорят "Nada foi gravado", и это правда: до этого момента писали только во временный каталог.

4. Никогда не откатывается к относительному пути. COMPOSE_DE_INSTALACAO перестаёт быть константой 'docker/docker-compose.install.yml' и рождается пустой; она получает значение — абсолютный путь — только когда materializar_os_arquivos_da_instalacao копирует проверенные копии в <pasta de onde o script roda>/docker/, ту же папку, что .env. Это происходит в первый же момент, когда файл нужен (миграция или подъём сразу после .env), и никогда раньше: путь без TTY по-прежнему ничего не записывает. То, что уже есть в месте назначения, заменяется, никогда не читается — при обновлении это compose предыдущей версии, — с rm перед копированием, чтобы symlink не увёл запись в другое место. Раскладка под папкой намеренно та же, что и раньше: относительные bind-mounts compose продолжают разрешаться, маркер продолжает записывать compose, а команда, которую runbook даёт оператору, не меняется.

5. E2E инвертирует то, что проверяет. Обходной шаг уходит; на его место приходит утверждение, что папка job начинается без docker/ (у него нет checkout), что без TTY установщик проверил файлы и не записал ни одного, и — в отдельном шаге, после установки, чтобы токена не было в окружении установщика, — что записанные файлы байт в байт те, что покрывает манифест Release. install-e2e.spec.ts перестаёт проверять «обе половины» обхода и начинает проверять, что он не вернётся.

Что этот ADR явно отвергает​

  • Клонировать репозиторий на теге. Это был бы один файл для поддержки, всегда согласованный с тегом, — и это заменило бы цепочку подписи доверием к транспорту git, добавив зависимость, которой может не быть на чистой машине. То, что поднимает установку, стало бы единственным из используемого установщиком, что не проверяется так, как проверяется бинарник.
  • Tarball со всеми четырьмя. Лишний файл для распаковки на чужой машине и строка манифеста, покрывающая четыре файла, не называя их, — та же причина, по которой ADR 0149 предпочёл манифест пяти подписям, применённая наоборот.
  • Встроить файлы в install.sh (heredoc). В compose сотни строк, и он меняется вместе с топологией; установщик начал бы меняться с каждым сервисом, а composes-em-conformidade.spec.ts пришлось бы читать shell.
  • Переменная или флаг, чтобы указать другой URL Release, для теста. Это кратчайшая форма двери для пропуска проверки (ADR 0150). Тесты shell передают URL как АРГУМЕНТ функции, что делает и main.
  • Запускать install-e2e.yml на pull_request. Остаётся вне, по причине из ADR 0150.

Последствия​

  • Закрыто в коде, доказано только на следующем финальном теге. Конвейер публикует только на теге, а E2E запускается только после него; в PR выполняется статическая часть (assets-do-instalador.spec.ts, install-e2e.spec.ts) и функциональная часть shell против поддельной Release (install-arquivos-da-instalacao.spec.ts). До первого тега после этого merge ни одна опубликованная Release не имеет этих assets.
  • Никаких действий оператора, и ни одна работавшая установка не перестаёт работать. Новый install.sh доходит до кого-либо только внутри Release, и это ТОТ ЖЕ шаг ТОГО ЖЕ job, который прикладывает его и прикладывает четыре assets, — так что Release с новым установщиком и без файлов существует, только если этот шаг упадёт посередине, и тогда установщик отказывает рано, с "a Release não publica …", а не умирает после .env. Запуск нового install.sh из checkout против старой Release уже отклонялся раньше, проверкой происхождения (RN-526): его хеша нет в старом манифесте. Уже опубликованные Releases остаются со старым установщиком и обходом — запускать его изнутри checkout на теге.
  • Release растёт на четыре assets (~29 KB), а манифест — на четыре строки, и четыре файла начинают версионироваться вместе с установщиком: изменение одного из них меняет то, что подпишет следующий тег. Изменение ТАБЛИЦЫ требует изменения обеих сторон, и spec проваливается, если изменится только одна.
  • У checkout, чья папка является папкой установки, docker/ перезаписывается копиями из Release. На устанавливаемом теге они идентичны; вне его git status становится грязным, и скрипт ГОВОРИТ, какие заменил. Это цена неизменной раскладки, и она существует только для тех, кто устанавливает изнутри checkout.
  • Прокси GET /runner-releases/binary не меняется: он читает манифест как отображение имя → хеш (RN-525), и лишние строки на него не влияют.
  • Смежное, измеренное и НЕ исправленное. Доказательство восстановления при миграции (test-restore-compose.sh) вызывает docker compose -f <compose> без --env-file, а Compose ищет .env в папке COMPOSE, а не в той, откуда запускается (измерено: docker compose -f docker/c.yml config с .env только в текущем каталоге падает на интерполяции). Поскольку compose живёт в docker/, а .env в папке выше, переменная BRABO_BACKUP_IMAGE не доходит, и доказательство склонно проваливаться — БЕЗОПАСНЫЙ исход по RN-530 (ничего не удаляется), но миграция через compose не завершается. Это предсуществующий дефект пути миграции, который не проверяет ни один E2E; он объявлен здесь для того направления, которое его проверит.