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

ADR 0136 — Глобальная страница контейнеров: потолок на загрузку в broker и container_remove в абсолютном потолке

Контекст​

ADR 0130 (broker контейнеров) объявил пять операций DockerPort — start/stop/remove/inspect/exec — и явный комментарий в ContainerBrokerPort: «stop/remove seguem sem chamador: são EFEITO EXTERNO e não acontecem sem proposed_action própria, que ainda não existe.» ADR 0133/0134 (PR 1.5/1.6) подключили start/exec; этот PR (1.8, последний из Части 1 плана «Выполнение в настоящем контейнере») закрывает оставшийся пробел и поставляет экран, на который уже указывал комментарий controller ContainersController (маршрут lifecycle по проекту): «a página global que a consome é outro PR.»

При проектировании этого экрана возникли два конкретных архитектурных решения, и ни на одно не было готового ответа в предыдущих ADR:

  1. Экран МЕЖПРОЕКТНЫЙ — одна строка на каждый проект workspace, у которого уже есть project_containers. ЗАРЕГИСТРИРОВАННОЕ агрегируется бесплатно (это чтение из базы, тот же шаблон, что у ProjectsSummaryRepository), но НАБЛЮДАЕМОЕ — запрос к broker о текущем состоянии, ADR 0130, — это СЕТЕВОЙ вызов на каждый проект. Спрашивать ВСЕХ без потолка нарушило бы правило CLAUDE.md: «leitura composta que chama o provider N vezes tem orçamento e cache, senão vira amplificador de tráfego» (ADR 0060).
  2. container_remove качественно отличается от container_start/ container_stop: он уничтожает контейнер и вынуждает заново подготавливать его с нуля. Исходный план не уточнял точный калибр — лишь то, что три действия рождаются как proposed_action.

Решение​

Потолок вызовов broker задаётся ДВУМЯ линейками, а не одной​

ObterVisaoGeralDeContainersUseCase спрашивает broker (через уже существующий ObterEstadoObservadoDoContainerUseCase) только для строк, чьё ЗАРЕГИСТРИРОВАННОЕ — provisioning/running: контейнеру stopped/failed/ removed не нужно подтверждение демона, чтобы экран имел смысл, и исключить их из запроса бесплатно (их защищает не потолок, а пригодность). Среди пригодных не более TETO_DE_VERIFICACOES_POR_CARGA (20, пересматриваемое ЧИСЛО, а не «все») опрашиваются за загрузку, ПАРАЛЛЕЛЬНО (Promise.all, никогда последовательно — цепочка умножила бы задержку на потолок без толку).

То, что не проходит ни одну из двух линеек, несёт СОБСТВЕННОЕ поле naoVerificado (fora_do_escopo_da_verificacao | teto_de_verificacoes_atingido) — никогда не путаемое с naoObservado (broker-nao-configurado/broker-sem-resposta/broker-recusou, которое уже существовало и означает «broker БЫЛ опрошен и отказал/не ответил»). У строки вне потолка все три поля наблюдения (observado/ naoObservado/detalheDaObservacao) равны null — экран никогда не выдумывает ответ, которого broker не дал.

Почему 20: сегодня (ФАЗА 25b) никакой автоматической подготовки нет — контейнер поднимается только по вручную одобренному container_start, — поэтому число одновременных контейнеров provisioning/running обычно невелико. 20 — щедрый запас на настоящее, а не жёсткое отсечение, измеренное по реальному объёму; пересмотр числа — одна строка, а не миграция.

ЗАРЕГИСТРИРОВАННОЕ агрегируется в ТРИ постоянных запроса, никогда не по одному на проект​

ContainersOverviewRepository/DrizzleContainersOverviewRepository следует ТОМУ ЖЕ шаблону, что и ProjectsSummaryRepository (RN-090): первый запрос соединяет projects с project_containers (INNER JOIN — входит только тот, у кого уже ЕСТЬ строка, это линейка экрана), второй пакетно ищет события artifact.project_image найденных проектов (чтобы определить текст образа каждой ЗАМОРОЖЕННОЙ imageVersion — а не действующей, которая могла быть пересмотрена после рождения контейнера; decisaoNaVersao, domain/containers/project-container.ts, вынесенная из ObterContainerDoProjetoUseCase, чтобы не дублировать логику проверки/деградации), а третий пакетно ищет ожидающие proposed_actions контейнера тех же проектов — это позволяет экрану заменить три кнопки действий на встроенный ApprovalCard без четвёртого запроса на проект. Тест (containers-overview.repository.spec.ts, тот же дизайн, что у ProjectsSummaryRepository) доказывает, что число запросов ОДИНАКОВО при 2 и при 20 проектах.

container_remove входит в абсолютный потолок decide.ts; container_stop следует калибру container_start​

container_start (RN-491) и container_stop (RN-495) — это maintainer, require_approval по умолчанию, и МОГУТ быть настроены как auto_approve — ни одно решение не необратимо без повторной подготовки: остановка контейнера, зарегистрированного как running, — это состояние, из которого можно вернуться (stopped -> running). container_remove иной: он уничтожает контейнер, и машина состояний (container-lifecycle.ts) выходит из removed только ПОДГОТАВЛИВАЯ ЗАНОВО — никогда «возвращаясь к жизни». Поэтому container_remove входит в ТОТ ЖЕ абсолютный потолок decide.ts, который уже покрывает git push/привилегированную команду (RN-418/ADR 0102): безусловный require_approval, даже при включённом «автоматическом режиме», даже при настроенном permissions.json — ApproveAlwaysActionUseCase отказывается (400) записывать для него шаблон «всегда разрешать», закрывая щель в ИСТОЧНИКЕ, тот же механизм, что у RN-418.

ContainerBrokerPort.remove уже был (со времён ADR 0128/0130, реализован в packages/docker-port) docker rm --force — удаляет даже контейнер running за один вызов. Машина состояний НЕ получила короткого пути running -> removed: ExecuteContainerRemoveUseCase регистрирует ОБА шага (stopped, затем removed), когда зарегистрированное ещё говорило running, отражая то, что действительно произошло на стороне Docker, не расширяя машину состояний путём, который существовал бы только для этого сценария использования.

«Поднять снова» ПОВТОРНО ИСПОЛЬЗУЕТ container_start — это не четвёртый тип​

Исследовано и подтверждено: ExecuteContainerStartUseCase уже выполняет весь танец из ЛЮБОГО состояния — provisioning/running (идемпотентно), stopped (переходит прямо в running, без повторной подготовки) и failed/removed/без строки (подготавливает с нуля), — потому что он уже существует для обслуживания выбора Infra (RN-491), который тоже может произойти после падения контейнера. Экран (ContainersPage.tsx) собирает payload container_start из ДЕЙСТВУЮЩЕГО РЕШЕНИЯ проекта (GET .../container, тот же маршрут, который читает вкладка Code) — образ, сеть, ресурсы — вместо того чтобы выдумывать новую форму; если Arquiteto/Infra ещё не решил (такого не должно быть для уже существующей строки, но чтение деградирует, а не предполагает), клик завершается ошибкой с названным сообщением и никогда не предлагает пустой payload. Для этой третьей кнопки изменений backend не понадобилось.

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

Страница /containers — первое межпроектное чтение, сочетающее базу (без N+1) с внешним provider под бюджетом (с явным потолком). Шаблон — сначала пригодность, затем потолок, СОБСТВЕННАЯ причина отсутствия, никогда не путаемая с причиной provider, — доступен следующему составному чтению, которому понадобится та же дисциплина.

stop/remove из пяти операций ADR 0128/0130 теперь имеют реального вызывающего. Из пяти лишь inspect (чтение, без proposed_action) остаётся как было; start/stop/exec имели вызывающего ещё до этого PR. В container-lifecycle.ts был docblock, утверждавший «ни один сервис продукта не монтирует /var/run/docker.sock», — ложно со времён ADR 0130 и тем более ложно теперь; исправлено в этом PR, в том же файле, который он уже редактировал по другой причине.

Потолок 20 — это ЧИСЛО, а не измерение. Нет production-данных о том, сколько контейнеров одновременно находится в provisioning/running, — у ФАЗЫ 25b никогда не было автоматической подготовки. Если объём вырастет, число пересматривается; для его изменения не нужно другое архитектурное решение, лишь измерение, которого сегодня нет (та же объявленная линейка, что для весов гибридного поиска RAG, ADR 0080).

container_remove никогда не одобряется автоматически, нигде, навсегда — пока действует это решение. Это намеренно: единственный способ отменить это — другое явное архитектурное решение, а не настройка.

Рассмотренная и отвергнутая альтернатива: расширить container-lifecycle.ts прямым переходом running -> removed. Это моделировало бы то, что broker ДЕЛАЕТ (принудительное удаление), но смешало бы то, что машина состояний МОДЕЛИРУЕТ (наблюдаемое состояние продукта), с тем, что Docker допускает как механический короткий путь — машина осталась бы права о том, «что api знает о случившемся», а ExecuteContainerRemoveUseCase уже решает это, регистрируя два шага, не касаясь домена, общего для всей остальной системы (включая container_start, зависящий от той же машины).

Рассмотренная и отвергнутая альтернатива: потолок вызовов broker на основе cache/TTL вместо подсчёта за загрузку. Кэш уменьшил бы вызовы при быстрых последовательных загрузках, но ввёл бы окно, в котором наблюдаемое может быть устаревшим БЕЗ ведома экрана, — что RN-468 запрещает (сигнал окружения говорит то, что ЗНАЕТ, никогда не изображает свежесть, которой нет). Потолок за загрузку проще и честнее: каждая загрузка говорит ровно то, что спросила.