ADR 0130 — Broker контейнеров: кто говорит с Docker сервера и почему он не принимает спецификацию
Контекст
ADR 0065 решил, что образ проекта — АРТЕФАКТ Arquiteto, а ADR
0081 создал таблицу жизненного цикла (project_containers). Ни один из двух
не поднял контейнер: до сегодняшнего дня CLAUDE.md фиксирует «FASE 25b segue cortada: NENHUM
serviço chama Docker». ADR 0128 построил фундамент — DockerPort из
ПЯТИ операций (start/stop/remove/inspect/exec) и адаптер поверх
execFile('docker', …) — внутри apps/runner, и заранее написал,
что этот файл ПЕРЕЕДЕТ, когда родится второй потребитель: «um segundo
arquivo com as mesmas cinco operações e uma sexta 'só no broker' é o começo do
fim da contenção».
Этот ADR и есть второй потребитель. Он решает три неразделимые вещи: где начинает жить порт, что такое broker и что не даёт сервису, эквивалентному root на хосте, превратиться в произвольный доступ к Docker.
Предыдущее решение, которое он НЕ пересматривает: api никогда не получает сокет. Как и engine. Сокет монтируется в один сервис, существующий для этого.
Решение
1. Порт ПЕРЕЕЗЖАЕТ в packages/docker-port
apps/runner/src/docker-port.ts и docker-cli.ts (плюс два .spec.ts)
становятся packages/docker-port/src/ в новом пакете workspace
(@brabo/docker-port), потребляемом apps/runner и apps/broker. Ни один из
файлов не был скопирован.
packages/shared был отклонён, и причина проверена, а не эстетическая.
Этот пакет на 100% ТИПОВОЙ, и инвариант честно поддерживается
apps/api/test/packages-shared-so-tipos.spec.ts — его main указывает на
сырой .ts, и production-образ api умирает при загрузке с
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, если хоть одно значение переживёт
tsc. Порт с классами ошибок, константами и функциями проверки — это
runtime-код.
У нового пакета нет шага сборки, и именно это делает его потребляемым.
Оба потребителя его УПАКОВЫВАЮТ: runner через tsup и через
bun build --compile, broker через tsup. Ни один из двух не разрешает его из
node_modules в runtime внутри образа. В разработке .ts
выполняется напрямую через type stripping Node — ссылка pnpm является symlink, и
realpath выпадает за пределы node_modules, поэтому отказ Node не применяется
(измерено, а не предположено).
Объявленное следствие: api НЕ может потреблять этот пакет.
pnpm deploy --prod её образа копирует пакет по-настоящему, без symlink, и
тогда realpath попадает внутрь node_modules — ровно ту ошибку, для предотвращения которой
существует тест packages/shared. Поэтому
validarDecisaoDeImagem (домен api) не переезжает сюда, и поэтому
broker его не импортирует. У broker СОБСТВЕННЫЙ разбор
(especificacaoValidada), и следующий раздел объясняет, почему это не
вторая версия того же правила.
Упаковка, измеренная после перемещения. pnpm --filter runner build,
build:bin, smoke и smoke:bin остаются зелёными; автономный бинарник
(linux-x64) загружает порт и опрашивает демон изнутри, что
ADR 0128 уже доказывал. Бандл Bun вырос с 79 до 83 модулей. Пакет
входит как devDependency runner — та же полка, что у phoenix, — потому что
tsup оставляет dependencies как ВНЕШНИЙ require и встраивает devDependency, и
потому что пакет, опубликованный в npm, не может нести workspace:*, который никто
вне этого репозитория не разрешает. npm pack --dry-run в apps/runner по-прежнему
производит те же четыре файла.
2. Broker не принимает спецификацию контейнера
apps/broker — сервис Node/TS, из того же семейства, что runner: без веб-
фреймворка (маршрутов шесть), чистый node:http, собственный образ в docker/broker/.
Не-произвольным его делает не allowlist чего-либо. А ФОРМА входа:
Ele recebe um
projectIde uma das cinco operações. Vai à api LER a decisão do Arquiteto e o contexto do projeto (GET /internal/projects/:projectId/container-spec). COMPÕE a especificação ele mesmo. Não existe parâmetro em que se escrevaprivileged,cap_add,network: hostou um-vlivre — porque não existe parâmetro.
Если бы spec передавалась в теле, сдерживание процесса, эквивалентного root на хосте, зависело бы от корректности ВЫЗЫВАЮЩЕГО. Один лишний HTTP-вызов — цена того, чтобы сдерживание ни от кого не зависело, и цена низкая: api находится на другой стороне той же внутренней сети.
Почему broker перепроверяет то, что вернула сама api. Потому что он не может
предполагать корректность отвечающего — и потому что две проверки
отвечают на разные вопросы. validarDecisaoDeImagem в api спрашивает
«это архитектурное решение поддаётся ревью?» (требует rationale, отклоняет latest,
применяет RECURSOS_MAXIMOS, возвращает отказ МОДЕЛИ через tool-result).
especificacaoValidada здесь спрашивает «могу ли я передать это демону?» — это
разбор недоверенного JSON в закрытый тип. Перекрытие
небольшое и объявленное (tag/digest, один числовой потолок), и это не зеркало, которое нужно
поддерживать: здешние потолки — это потолки BROKER, последний рубеж, который он никогда
не превышает, откуда бы ни пришёл запрос. Сегодня числа совпадают с числами api
намеренно; если однажды разойдутся, побеждает меньшее, и ничего не ломается, потому что ни
одно не утверждает, что является другим.
Одна проверка существует только на этой стороне, и она новая: ссылка на образ,
начинающаяся с -. image становится ПОЗИЦИОННЫМ аргументом docker run, и
--privileged в качестве «имени образа» CLI прочитал бы как флаг. execFile
без shell решает инъекцию КОМАНДЫ; инъекция АРГУМЕНТА — другое дело, и
решается здесь.
3. pidsLimit входит в порт
У EspecificacaoDeContainer из ADR 0128 были cpus и memoriaMb, а у
артефакта Arquiteto всегда было три числа — RecursosDoContainer.pidsLimit
— это то, что сдерживает fork bomb, не завися от распознавания команды. Без поля
broker прочитал бы артефакт и отбросил третье: артефакт обещал бы потолок,
который контейнер не получает, — это «артефакт, обещающий больше, чем
получает контейнер, лжёт тому, кто его проверяет», который называет сам домен api.
Добавление поля в порт — движение, заслуживающее заметки, потому что мерило этой
конструкции — не добавлять параметр. Мерило действует для того, что ОСЛАБЛЯЕТ. Это
ужесточает и становится --pids-limit рядом с --cap-drop ALL.
4. Сеть, порт и сокет
- СОБСТВЕННАЯ сеть в Compose (
broker,internal: true), в обоих файлах. Api участвует в двух сетях; engine и web в этой не участвуют, поэтому не достают до broker.internal: trueтакже убирает у broker маршрут выхода в интернет, которым он не пользуется. - Без опубликованного порта. Ничему на хосте не нужно до него достучаться.
/var/run/docker.sockмонтируется только здесь.:roна сокете было бы косметикой — протокол Docker — это HTTP поверх этого сокета, и каждая операция пишет, — и поэтому он не использовался, будто это сдерживание.- Аутентификация через
BRABO_SERVICE_TOKEN, тот же секрет и тот же заголовок, уже используемые api↔engine в обоих направлениях, сравниваемые за постоянное время. В production broker ОТКАЗЫВАЕТСЯ подниматься с пустой переменной, с публичным литералом репозитория или с менее чем 16 символами — мерка ADR 0059/RN-114, которая здесь весит больше, чем в любом другом сервисе. - Broker поднимается под
profiles: ["container-broker"]в обоих compose, и поэтому не поднимается по умолчанию. Ничто пока не вызывает его для записи; давать доступ к Docker хоста каждой машине разработчика в обмен ни на что было бы сменой позиции без встречной выгоды.docker/smoke.shработает без profile и по-прежнему доказывает те же три образа.
5. Ни один путь не пересекает границу
Api не передаёт никакого пути — ни workspace_path, ни управляемый корень. -v
у docker run разрешается ДЕМОНОМ против файловой системы ХОСТА;
/data/project-workspaces/<x> — путь изнутри контейнера api, и
его передача заставила бы демона создать и смонтировать ПУСТУЮ папку, а dev agent
работал бы в каталоге без кода, и ничто не указывало бы почему.
Broker составляет путь из PROJECT_WORKSPACES_HOST_ROOT, СВОЕЙ
конфигурации, плюс workspaceDirName, замороженного при создании проекта (RN-109). Без
этой переменной start отказывает, называя её; остальные четыре операции продолжают
работать. Отказ — это решение: угадывание произвело бы пустое монтирование
молча.
6. Что api получает и чего она намеренно не получает
Она получает ПОРТ (ContainerBrokerPort) и HTTP-клиент. Из пяти
объявленных операций у одной есть вызывающий: inspect, через маршрут жизненного цикла
контейнера, который начинает возвращать НАБЛЮДАЕМОЕ состояние рядом с ЗАРЕГИСТРИРОВАННЫМ. Остальные
четыре — внешний эффект и не происходят без proposed_action — предлагать
будет Infra Lead, с окончательными полномочиями пользователя, и это другой PR.
Объявить пять сейчас и подключить четыре позже — противоположность сокрытию
триггера: метод существует, и ничто его не вызывает, что любой поиск вызывающего
покажет за секунду.
Она не получает никакой новой записи. RegistrarTransicaoDeContainerUseCase остаётся
байт в байт каким был, включая guard режима, — он упадёт вместе с барьером
RN-105 в трёх режимах, в PR, решающем обе вещи одновременно. Как
следствие, broker объявляет ту же политику на своей стороне: проект
mounted/runner отклоняется с 409, потому что их код живёт в папке
пользователя, которую хост broker не видит. Изменить сейчас только одну из двух сторон
значило бы оставить api и broker в несогласии о том, что существует.
Последствия
Broker эквивалентен root на хосте, и это цена. Это записано, а не скрыто. Сдерживается пятью независимыми слоями: без опубликованного порта; внутренняя сеть, до которой достаёт только api; служебный токен за постоянное время; пять операций закрытой поверхности; и вычисляемая спецификация, никогда не принимаемая. Ни один из них не доверяет другим.
Один механизм, а не два. ADR 0128 оставлял dockerode технически
возможным в broker (он никогда не становится автономным бинарником). Решение владельца
продукта — CLI с обеих сторон, и причина не техническая: два механизма для
одной операции означают два способа падать, две поверхности ошибок для
классификации и две версии трансляции spec → демон. Цена в том, что образу
broker нужен бинарник docker внутри — docker-cli, только клиент, —
и она оплачена в Dockerfile, а DockerCliAusenteError называет день, когда
кто-то смонтирует сокет, не установив клиент.
Таблица project_containers теперь может ЛГАТЬ, и чтение об этом говорит.
Раньше не могла: container_id всегда был NULL. Согласование происходит ПРИ ЧТЕНИИ,
а не фоновым демоном — контейнер, убитый извне, отображается как зарегистрированный
running и наблюдаемый exited. Две колонки никогда не сливаются, а «не
смог посмотреть» имеет собственную причину (broker-nao-configurado,
broker-sem-resposta, broker-recusou), а не наследует зарегистрированное. Это
применённая RN-468, и ни один отказ broker не роняет чтение зарегистрированного,
которое само по себе является законной информацией.
Объявленный пробел: осиротевший контейнер проекта, у которого никогда не было строки
жизненного цикла, не появляется на этом маршруте — он сначала читает зарегистрированное и только потом
спрашивает демона. Сирот находит просмотр по brabo.managed=true, а
глобальная страница, которая его потребляет, — другой PR.
Образ broker НЕ публикуется в GHCR. docker-bake.hcl,
scripts/ci/images-manifest.ts (ALVOS) и release.yml остаются с
ЧЕТЫРЬМЯ образами ADR 0119. Broker поднимается под profile, не входит в
стандартный деплой, и ничто не вызывает его для записи; его публикация сейчас поместила бы в
публичный registry образ, который никто не устанавливает. Когда PR Infra Lead сделает его
необходимым, он войдёт тем же путём, что и остальные четыре, — это одна строка в каждом
из трёх мест, а у ALVOS есть тест.
FASE 25b перестаёт быть полностью отрезанной, и только. Существует
сервис, способный вызывать Docker, и путь ЧТЕНИЯ, который его вызывает. Ни один
контейнер не поднимается: нет цикла, нет очереди, нет proposed_action для
container_start. Фраза CLAUDE.md меняется, чтобы сказать ровно это, и ничего
сверх.
Эксплуатационная новинка, которой не было: DOCKER_GID. Сокет —
root:docker на хосте, а процесс broker работает non-root, поэтому compose использует
group_add. Значение по умолчанию (999) самое частое и неверно в нескольких
дистрибутивах; ошибка даёт «permission denied» на сокете, который
DockerIndisponivelError уже называет с подсказкой о группе. Это есть в .env.example
и в runbook.