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

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 projectId e 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 escreva privileged, cap_add, network: host ou um -v livre — 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.