diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 497b78d..31ffe17 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -14,6 +14,7 @@ - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts`** (BuildKit GC; дефолт `until=24h` — удаляет build cache старше суток, тёплый кэш сохраняет; `-a` опционально, только в паре с фильтром). Затрагивает **только** build cache — НЕ образы/контейнеры; рестарт docker daemon/прода не выполняется (self-hosting безопасность). В контейнере нет `docker` CLI (`Dockerfile:11`), поэтому уборка идёт **на хосте через ssh** каналом `deploy_ssh_user@deploy_ssh_host` (как `image_freshness`/`self_deploy`); пустой `deploy_ssh_host` → тик no-op (скоуп на self-host). never-raise (per-команда/per-tick); учёт результата in-memory (без миграции БД). Kill-switch `ORCH_BUILD_CACHE_PRUNE_ENABLED`; снимок — блок `build_cache_prune` в `GET /queue` (`enabled`/`interval_s`/`until`/`last_run_ts`/`last_reclaimed`/`last_error`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. Детали — `docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md`. - **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7 и [ADR-001](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md). - **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту. - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость). diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 714bccd..153dd54 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -27,6 +27,10 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0019 | Стандарт документов конвейера (PIPELINE_DOCS, слой 1) | accepted | 2026-06-09 | ORCH-075 | | adr-0020 | Единый frontmatter-контракт + спека handoff (reader/writer/валидатор) | accepted | 2026-06-09 | ORCH-076 | | adr-0021 | Канон Anthropic для агент-промптов + эмиссия frontmatter-схемы 52c | proposed | 2026-06-09 | ORCH-077 | +| adr-0022 | Стандарт трассировочных маркеров `ORCH-NNN` | accepted | 2026-06-09 | ORCH-078 | +| adr-0023 | Обзорная ось reviewer + закрытие эпика 52 | accepted | 2026-06-09 | ORCH-079 | +| adr-0024 | Disk-watchdog — heartbeat-сигнал заполнения хост-ФС | proposed | 2026-06-09 | ORCH-063 | +| adr-0025 | Build-cache-pruner — авто-prune docker build cache на хосте | proposed | 2026-06-09 | ORCH-062 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` @@ -36,6 +40,8 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- > adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082). > adr-0020 реализует машинный слой к adr-0019 (ORCH-52b→52c). > adr-0021 реализует слой промптов к adr-0019/0020 (ORCH-52d — замыкает эпик 52). +> adr-0025 **комплементарен** adr-0024 (watchdog сигналит о росте диска — pruner убирает +> доминирующего «пожирателя», docker build cache). ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0025-build-cache-pruner.md b/docs/architecture/adr/adr-0025-build-cache-pruner.md new file mode 100644 index 0000000..cf53935 --- /dev/null +++ b/docs/architecture/adr/adr-0025-build-cache-pruner.md @@ -0,0 +1,86 @@ +--- +work_item: ORCH-062 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# adr-0025: Build-cache-pruner — фоновый heartbeat-демон авто-уборки docker build cache на хосте + +> Сквозной (cross-cutting) ADR: вводит **новый фоновый компонент** оркестратора в ряду +> `reconciler` (adr-0007), `job_reaper` (adr-0011) и `disk_watchdog` (adr-0024). Детальное +> решение задачи — `docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md`. + +## Статус +Proposed (ORCH-062) + +## Контекст + +07.06.2026 диск хоста mva154 тихо дорос до 100% и положил **весь self-hosting-конвейер всех +проектов** (один прод-инстанс `orchestrator` на общей БД/очереди). Доминирующий «пожиратель» — +**docker build cache** (≈11 ГБ от частых пересборок прод/staging-образов). `disk_watchdog` +(adr-0024, ORCH-063) ввёл **сигнал** о заполнении (Telegram ≥85%) и явно отложил авто-очистку в +отдельную задачу. ORCH-062 — эта задача: **автоматическое освобождение build cache**, чтобы +инцидент не повторялся без оператора. + +Сверено по коду: контейнер `orchestrator` **не содержит docker CLI** (`Dockerfile:11` — только +`openssh-client git curl`); host-docker-операции приложение уже делает **через ssh на хост** +(`image_freshness.image_revision`, `self_deploy` Phase B), канал `deploy_ssh_user@deploy_ssh_host` +настроен. У оркестратора три проверенных фоновых daemon-потока с единым каркасом. + +## Решение + +Вводится четвёртый фоновый компонент **build-cache-pruner** (`src/build_cache_pruner.py`): +- **Калька каркаса** `disk_watchdog`/`reconciler`/`reaper`: daemon-поток, чистый стоп через + `_stop.wait(interval)`, контракт `start()`/`stop(timeout)`/`status()`, старт/стоп в + `main.lifespan` (старт последним — после `disk_watchdog.start()`; стоп первым в reverse), + наблюдаемость — аддитивный блок `build_cache_prune` в `GET /queue`. Leaf-модуль (без обратных + зависимостей на `stage_engine`/`stages`/`qg`). +- **Уборка — строго `docker builder prune -f --filter until=`** (BuildKit GC, дефолт + `until=24h`): удаляется только старый build cache, тёплый ≤24ч сохраняется. `-a` — опционально и + только в паре с возрастным фильтром. **Запрещены** `docker image prune`/`system prune`/удаление + образов запущенных сервисов/остановка-рестарт контейнеров. +- **Исполнение на хосте через ssh** (CLI в контейнере нет): `ssh deploy_ssh_user@deploy_ssh_host + "docker builder prune …"`, bounded таймаутом. **Нет ssh-таргета → тик no-op** → фича + естественно скоупится на self-hosting-прод. +- **Конфиг/kill-switch** (`ORCH_BUILD_CACHE_PRUNE_*`, дефолты безопасные): `enabled` (дефолт + `true`), `interval_s` (6ч), `until` (`24h`), `all` (`false`), `timeout_s`, `notify_min_gb`. + Валидаторы по образцу `disk_monitor_*` (невалид → лог + дефолт). +- **Сигнал + лечение как пара:** disk_watchdog сигналит о росте диска, build-cache-pruner убирает + доминирующего «пожирателя» — две половины одной операционной защиты. + +**Инварианты:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, `src/stage_engine.py`, схема БД +— **не меняются** (pruner — эксплуатационный демон, не Quality Gate, как watchdog/reaper). Без +миграции БД (учёт результата in-memory, best-effort). never-raise per-команда/per-tick. Уборка +**никогда** не рестартит docker daemon/прод-контейнер (self-hosting безопасность; рестарт-путь — +отвергнутый Вариант B). При выключенном kill-switch — поведение 1:1 как сейчас (нулевая регрессия +для enduro-trails). + +## Альтернативы +- **host `daemon.json builder.gc.defaultKeepStorage`** — отвергнуто: требует рестарта docker + daemon (останавливает ВСЕ контейнеры хоста = групповой self-hosting риск); политика по объёму, + не по возрасту; не наблюдаемо в `GET /queue`. +- **host-cron** — отвергнуто как основное (оставлено ручным fallback): off-git невидимая инфра, + без `/queue`-наблюдаемости, без config-kill-switch, не тестируется. +- **raw-HTTP по docker.sock / docker CLI в образе** — отвергнуто: лишний код / раздувание образа + против уже существующего ssh-канала. + +## Последствия +- **+** Корень инцидента 07.06 устраняется автоматически; тёплый кэш сохранён; без новых + зависимостей и без рестарта docker/прода (принцип «всё в Docker, минимум зависимостей»). +- **+** Знакомый паттерн фонового демона → низкий риск, наблюдаемость, обратимость, тестируемость. +- **−** Зависимость от ssh на хост (как `image_freshness`/`self_deploy`); нет таргета → no-op + (наблюдаемо), фича не работает, но ничего не ломает. +- **Откат:** `ORCH_BUILD_CACHE_PRUNE_ENABLED=false`; миграций БД нет. + +## Ссылки +- Задачный ADR: `docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md` +- Инфра/риски: `docs/work-items/ORCH-062/07-infra-requirements.md`, + `docs/work-items/ORCH-062/10-tech-risks.md` +- Комплемент: [adr-0024-disk-watchdog.md](adr-0024-disk-watchdog.md) (ORCH-063 — сигнал) +- Родственные компоненты: [adr-0007-reconciler.md](adr-0007-reconciler.md), + [adr-0011-job-reaper-lease-reclaim.md](adr-0011-job-reaper-lease-reclaim.md) +- Топология host / env-карта: `docs/operations/INFRA.md` + diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index cf56ade..8866eee 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -74,10 +74,30 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл `disk_monitor.enabled=false`; поведение 1:1 как сейчас). Наблюдаемость — блок `disk_monitor` в `GET /queue` (последний замер: `used_pct`/`free_gb`/`alerting`/`last_alert_at` по каждому пути). - **Что делать при алерте:** watchdog **только сигнализирует** — он не трогает диск/контейнер и не - рестартит прод (self-hosting безопасность). Освобождение места — **ручная** операция оператора: - типовые «пожиратели» — старые worktree-каталоги `/home/slin/repos/_wt/*` завершённых задач, - логи, dangling Docker-образы/слои (`docker image prune`, `docker builder prune`). Авто-очистка — - вне объёма ORCH-063 (отдельная задача). + рестартит прод (self-hosting безопасность). Освобождение **docker build cache** автоматизировано + отдельным демоном (ORCH-062, см. ниже); прочие «пожиратели» — старые worktree-каталоги + `/home/slin/repos/_wt/*` завершённых задач, логи, dangling-образы (`docker image prune`) — + по-прежнему **ручная** операция оператора (авто-уборка этих категорий — вне объёма ORCH-062/063). + +### Build-cache-pruner: авто-prune docker build cache на mva154 (ORCH-062) +Доминирующий «пожиратель» в инциденте 07.06.2026 — **docker build cache** (≈11 ГБ от частых +пересборок прод/staging-образов). Чтобы он не мог снова заполнить диск **без оператора**, работает +фоновый daemon-поток `src/build_cache_pruner.py` (каркас `disk_watchdog`) — «вторая половина» +watchdog'а: **watchdog сигналит, pruner убирает**. +- **Что делает:** каждые `ORCH_BUILD_CACHE_PRUNE_INTERVAL_S` (дефолт 21600с = 6ч) выполняет + **строго `docker builder prune -f --filter until=`** (BuildKit GC; дефолт `until=24h` — + удаляется build cache старше суток, тёплый свежий кэш сохраняется). Команда затрагивает **только + build cache** — НЕ образы/контейнеры запущенных сервисов; рестарт docker daemon/прода НЕ + выполняется (self-hosting безопасность). +- **Как исполняется:** в контейнере нет `docker` CLI (образ несёт только `openssh-client git`), + поэтому уборка идёт **на хосте через ssh** тем же каналом `ORCH_DEPLOY_SSH_USER@_HOST`, что + деплой/`image_freshness`. **Пустой `ORCH_DEPLOY_SSH_HOST` → тик no-op** (фича активна только на + self-host, где ssh настроен). +- **Как отключить:** `ORCH_BUILD_CACHE_PRUNE_ENABLED=false` (демон не стартует; поведение 1:1 как + до ORCH-062). Наблюдаемость — блок `build_cache_prune` в `GET /queue` (`enabled`/`interval_s`/ + `until`/`last_run_ts`/`last_reclaimed`/`last_error`); never-raise; in-memory учёт (без миграции). +- **Ручной fallback** (если ssh-канал недоступен) — host-cron на mva154: + `0 */6 * * * docker builder prune -f --filter until=24h` (off-git, процедура Owner). ## Переменные окружения (карта; значения — в `.env`) @@ -117,6 +137,12 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл | `ORCH_DISK_MONITOR_THRESHOLD_PCT` | порог заполнения для алерта, %; дефолт `85` (валидация 1..100, иначе → дефолт) | | `ORCH_DISK_MONITOR_REALERT_S` | cooldown повторного алерта, пока выше порога, сек; дефолт `21600` (~6 ч) | | `ORCH_DISK_MONITOR_PATHS` | CSV отслеживаемых **хост**-bind-путей; пусто → `/repos,/app/data` | +| `ORCH_BUILD_CACHE_PRUNE_ENABLED` | kill-switch build-cache-pruner (ORCH-062); дефолт `true`. `false` → демон не стартует, поведение 1:1 как до задачи | +| `ORCH_BUILD_CACHE_PRUNE_INTERVAL_S` | период тика авто-prune, сек; дефолт `21600` (~6 ч); валидация >0, иначе → дефолт | +| `ORCH_BUILD_CACHE_PRUNE_UNTIL` | возраст удержания тёплого кэша (`docker builder prune --filter until=`); дефолт `24h`; валидация `^\d+[smhdw]?$`, иначе → `24h` | +| `ORCH_BUILD_CACHE_PRUNE_ALL` | добавить `-a` к prune (только в паре с `until`); дефолт `false` | +| `ORCH_BUILD_CACHE_PRUNE_TIMEOUT_S` | таймаут ssh-команды prune, сек; дефолт `120` | +| `ORCH_BUILD_CACHE_PRUNE_NOTIFY_MIN_GB` | Telegram при освобождении ≥ N ГБ; дефолт `0` (тихо) | | `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука | **Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`. diff --git a/docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md b/docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md new file mode 100644 index 0000000..f2561a3 --- /dev/null +++ b/docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md @@ -0,0 +1,206 @@ +--- +work_item: ORCH-062 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# ADR-001: Авто-prune docker build cache — фоновый heartbeat-демон, выполняющий `docker builder prune` на хосте через ssh + +Work Item: **ORCH-062** — INFRA: авто-prune docker build cache на mva154 +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0025-build-cache-pruner.md`** (кросс-каттинг — +вводит новый фоновый компонент в ряду `reconciler`/`job_reaper`/`disk_watchdog`). + +## Статус +Proposed + +## Контекст + +07.06.2026 хост-диск mva154 тихо дорос до 100% и положил **весь self-hosting-конвейер всех +проектов** (один прод-инстанс `orchestrator` на общей БД/очереди обслуживает и `enduro-trails`, и +`orchestrator`). Доминирующий «пожиратель» — **docker build cache** (≈11 ГБ), накопленный частыми +пересборками (`docker compose up -d --build` при прод-деплое; пересборка staging-образа +`--profile staging`; build-once retag за `check_staging_image_fresh`, ORCH-058). ORCH-063 ввёл +disk-watchdog, который **только сигнализирует** (Telegram-алерт ≥85%) и явно отложил авто-очистку в +отдельную задачу. **ORCH-062 — эта задача.** + +BRD/ТЗ ставят развилку реализации (`06-adr` решает): +- **A** — heartbeat-демон в приложении (`src/build_cache_pruner.py`), 1:1 на `src/disk_watchdog.py`. +- **B** — host `daemon.json builder.gc.defaultKeepStorage` (BuildKit GC, инфра-процедура Owner). +- **C** — host-cron `docker builder prune -af --filter until=24h` (инфра-процедура Owner). + +**Факты, сверенные с кодом (важно для выбора):** +- **Контейнер `orchestrator` НЕ содержит `docker` CLI.** `Dockerfile:11` ставит только + `openssh-client git curl ca-certificates`. `src/image_freshness.py::image_revision` прямо + фиксирует: *«`docker` lives on the HOST (the container ships only `openssh-client git`), so when + `ssh_target` is given the inspect runs over ssh»*. → Любая docker-операция приложения над хостом + идёт **через ssh на хост** (`ssh deploy_ssh_user@deploy_ssh_host docker …`), как уже делают + `image_freshness` и `self_deploy` (Phase B). Допущение BRD A-1 («docker.sock смонтирован → + приложение может вызвать `docker builder prune`») верно на уровне сокета, но **не** даёт готового + CLI; raw-HTTP-over-UDS — лишний код против существующего ssh-канала. +- В оркестраторе уже три проверенных фоновых daemon-потока с единым каркасом + (`threading.Thread(daemon=True)` + `threading.Event`, `start()/stop(timeout)/status()`, + per-tick never-raise, kill-switch, снимок в `GET /queue`): `reconciler` (ORCH-053), + `job_reaper` (ORCH-065), `disk_watchdog` (ORCH-063, `src/disk_watchdog.py`). +- ssh-канал на хост сконфигурирован и доступен: `settings.deploy_ssh_user` (дефолт `slin`), + `settings.deploy_ssh_host`; ключи проброшены ro (`~/.orchestrator-ssh → /home/slin/.ssh`, + ORCH-040); `slin` — в группе docker (деплой-хук запускает `docker compose` на хосте). +- `docker builder prune` по контракту BuildKit затрагивает **только build cache**, не + останавливает контейнеры и не удаляет образы запущенных сервисов (основа BR-3). + +## Решение + +### Сводка + +Выбран **Вариант A — фоновый heartbeat-демон `src/build_cache_pruner.py`**, смоделированный +**1:1 на `src/disk_watchdog.py`** (тот же каркас, контракт, kill-switch, never-raise, блок в +`GET /queue`), который **периодически выполняет `docker builder prune` на ХОСТЕ через ssh** — +тем же каналом `deploy_ssh_user@deploy_ssh_host`, что уже используют `image_freshness` и +`self_deploy`. Это «вторая половина» disk-watchdog: **watchdog сигналит — pruner убирает**. + +Варианты B и C отклонены (см. «Альтернативы»). Вариант C сохраняется как +**задокументированный ручной fallback** в `07-infra-requirements.md` на случай, если ssh-канал +недоступен. + +### D1 — Механизм: фоновый демон приложения (A), не host-инфра (B/C) — BR-1/FR-1 + +Новый **leaf**-модуль `src/build_cache_pruner.py` (без обратных зависимостей на +`stage_engine`/`stages`/`qg`, как `disk_watchdog`/`serial_gate`/`task_deps`). Класс +`BuildCachePruner` с каркасом `disk_watchdog`: daemon-поток, чистый стоп через +`_stop.wait(interval)`, контракт `start()/stop(timeout)/status()`, модульный singleton +`build_cache_pruner`. Каждые `build_cache_prune_interval_s` (дефолт **21600с = 6ч**, NFR-4 +«порядка часов») один тик выполняет уборку. Выбор A над B/C даёт: наблюдаемость в `GET /queue`, +kill-switch из конфига, golden-source-в-git, юнит-тесты, и **симметрию с disk-watchdog** (один +паттерн на два смежных эксплуатационных демона) — это снижает стоимость сопровождения и +когнитивную нагрузку следующего агента. + +### D2 — Команда и политика удержания: строго BuildKit GC с возрастным фильтром — BR-2/BR-3/FR-2/FR-3 + +- Команда уборки — **строго `docker builder prune -f --filter until=`** (BuildKit GC). + Дефолт `until=24h` (`build_cache_prune_until`, ориентир из бизнес-запроса): удаляется build + cache **старше 24ч**, свежий тёплый кэш недавних сборок сохраняется (BR-2/AC-2). +- Флаг `-a/--all` — **только** опционально (`build_cache_prune_all`, дефолт `False`) и **всегда в + паре с возрастным фильтром**; «снести весь кэш» (`prune -af` без `until`) запрещён дефолтом. +- **Жёстко запрещены** `docker image prune`, `docker system prune`, любое удаление образов + запущенных сервисов, любая остановка/рестарт контейнеров. Затрагивается **только** build cache + (BR-3/AC-3). Уборка **никогда** не рестартит/не роняет прод-контейнер `orchestrator` + (групповой риск self-hosting). + +### D3 — Канал исполнения: ssh на хост (CLI в контейнере нет) — BR-3/FR-3/NFR-1 + +- Уборка исполняется на хосте: `ssh -o StrictHostKeyChecking=no + "docker builder prune -f --filter until="`, по образцу `image_freshness.image_revision` + (`ssh_target`-ветка). Это где **физически** живёт build cache (host docker daemon). +- **Нет ssh-таргета (`deploy_ssh_host` пуст) → тик no-op** (лог + `status()` отражает причину). + Это естественно **скоупит** фичу на self-hosting-прод (где ssh настроен) и делает дефолт + безопасным для любого окружения без host-доступа — параллель тому, как `self_deploy`/ + `image_freshness` деградируют без `_ssh_target()`. +- Вызов **bounded** таймаутом (`build_cache_prune_timeout_s`, дефлот 120с) и **неблокирующий** + конвейер (отдельный daemon-поток). Любой сбой — ниже D6. + +### D4 — Конфиг, kill-switch, дефолты — BR-5/BR-6/FR-5/NFR-3 + +Новый блок флагов в `src/config.py` рядом с `disk_monitor_*` (env-префикс `ORCH_BUILD_CACHE_PRUNE_*`): + +| Поле (`settings.*`) | env | Дефолт | Назначение | +|---|---|---|---| +| `build_cache_prune_enabled` | `ORCH_BUILD_CACHE_PRUNE_ENABLED` | `True` | kill-switch; `False` → демон не стартует, поведение 1:1 как до задачи (NFR-3) | +| `build_cache_prune_interval_s` | `ORCH_BUILD_CACHE_PRUNE_INTERVAL_S` | `21600` (6ч) | период тика, сек | +| `build_cache_prune_until` | `ORCH_BUILD_CACHE_PRUNE_UNTIL` | `24h` | возраст удержания (`--filter until=`) | +| `build_cache_prune_all` | `ORCH_BUILD_CACHE_PRUNE_ALL` | `False` | добавить `-a` (только в паре с `until`) | +| `build_cache_prune_timeout_s` | `ORCH_BUILD_CACHE_PRUNE_TIMEOUT_S` | `120` | таймаут ssh-команды, сек | +| `build_cache_prune_notify_min_gb` | `ORCH_BUILD_CACHE_PRUNE_NOTIFY_MIN_GB` | `0` | Telegram при освобождении ≥ N ГБ; `0` → тихо (без нотификаций) | + +**Дефолт `enabled=True` (обоснование, не самоочевидно):** (а) бизнес-цель BR-1 — авто-предотвращение +инцидента *без ручного вмешательства*; дефолт `False` означал бы, что оператор обязан вспомнить и +включить флаг, что подрывает саму задачу; (б) операция документированно-безопасна (только build +cache, never images/containers/restart — D2/A-2); (в) при отсутствии ssh-таргета тик no-op (D3) → +фича безопасна-по-построению в любом окружении без host-доступа; (г) полностью обратима kill-switch. +Это сознательный, явно зафиксированный компромисс «безопасный дефолт vs авто-цель» в пользу +авто-цели, при сохранённой обратимости. Параллель: `disk_monitor_enabled` тоже дефолт `True`. + +**Валидаторы** (паттерн `_disk_positive_int`/`_disk_threshold_pct` из `config.py`): невалидный +`interval_s`/`timeout_s` (не-int / ≤0) → лог-warning + дефолт; невалидный `until` (не матчит +`^\d+[smhdw]?$`) → лог-warning + `24h`. Невалидное значение **никогда** не роняет старт (AC-6). + +### D5 — Наблюдаемость — BR-4/FR-4 + +Аддитивный read-only блок `build_cache_prune` в `GET /queue` (как `disk_monitor`): +`enabled`, `interval_s`, `until`, `all`, `last_run_ts`, `last_reclaimed` (распарсенное +`Total reclaimed space: …` из вывода `docker builder prune`, best-effort), `last_error` +(строка причины последнего сбоя/no-op, или `null`). `status()` — never-raise (минимум +`{"enabled": …}` при ошибке). Опционально — `send_telegram` при освобождении +≥ `notify_min_gb` (по образцу recovery-сообщения watchdog'а; дефолт выключено). + +### D6 — Инварианты и never-raise — NFR-1/NFR-2/NFR-5/FR-6, AC-4/AC-8 + +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, `_parse_*`, `src/stage_engine.py`, схема БД + (`src/db.py`) — **не изменяются**. Pruner — эксплуатационный демон, не Quality Gate (категория + `reconciler`/`job_reaper`/`disk_watchdog`). +- **Без миграции БД**: учёт «когда убирали в последний раз»/последний результат — **in-memory**, + best-effort; сброс при рестарте безопасен (максимум одна лишняя безопасная уборка, NFR-5). +- **never-raise на двух уровнях:** per-команда (ненулевой rc / таймаут / `OSError` / + недоступность ssh / parsing-ошибка вывода → лог + проглот, тик жив) и per-tick (внешний + `try/except` в `_run`, как `disk_watchdog._run`). Фоновый цикл и конвейер не падают. +- **Self-hosting:** ssh выполняет `docker builder prune` на хосте под `slin` (в группе docker); + команда не трогает образы/контейнеры запущенных сервисов; прод не рестартится. Обслуживание + `enduro-trails` в общем инстансе не затронуто. + +### D7 — Жизненный цикл (`main.lifespan`) + +Старт демона — **последним**, сразу после `disk_watchdog.start()` (строки ~113–114 `main.py`); +стоп — **первым** в reverse-порядке, перед `disk_watchdog.stop()`. `start()` чтит kill-switch +(no-op при `enabled=False`), как `DiskWatchdog.start()`. + +## Альтернативы + +- **Вариант B — host `daemon.json builder.gc.defaultKeepStorage`** — **отвергнуто:** применение + конфигурации требует **рестарта docker daemon** на mva154, что останавливает **ВСЕ** контейнеры + хоста (прод `orchestrator` + всё остальное) → катастрофический self-hosting blast radius (BRD + C-1/R-3). Дополнительно: политика BuildKit GC — по **объёму** (`defaultKeepStorage`), а не по + возрасту (BR-2 хочет `until=24h`); состояние не наблюдаемо в `GET /queue` (только хостовый + `docker system df`); конфигурация — off-git host-артефакт. +- **Вариант C — host-cron** `docker builder prune -af --filter until=24h` — **отвергнуто как + основное** (сохранено как ручной fallback в `07`): off-git невидимая инфра (следующий + оператор/агент её не видит), **нет** наблюдаемости в `GET /queue`, **нет** kill-switch из + конфига, **не** покрывается `tests/` — ломает принцип self-contained/reproducible/observable, + которому следуют остальные демоны. +- **A через raw-HTTP по docker.sock (без ssh)** — **отвергнуто:** требует ручного HTTP-over-UDS + клиента (chunked-ответы, версионирование API) — лишний код против уже существующего, + проверенного ssh-канала `image_freshness`/`self_deploy`. +- **A через `docker` CLI, вкомпилированный в образ** — **отвергнуто:** раздувает образ и требует + пересборки/рестарта прода ради уборки; ssh-канал на хост уже есть и не трогает образ. + +## Последствия + +- **+** Корень инцидента 07.06 (build cache → 100% диска) устраняется **автоматически**, без + ручного вмешательства; тёплый кэш ≤24ч сохранён → штатные пересборки не «холодные». +- **+** Знакомый паттерн фонового демона (калька `disk_watchdog`) → низкий риск, наблюдаемость в + `GET /queue`, обратимость одним флагом, юнит-тестируемость, golden-source-в-git. +- **+** Без новых внешних зависимостей и без рестарта docker daemon/прода (принцип «всё в Docker + на одном сервере, минимум зависимостей»); ssh-канал переиспользован. +- **−** Зависимость от ssh-доступа на хост (как у `image_freshness`/`self_deploy`); при + отсутствии — тик no-op (наблюдаемо в `status().last_error`), фича просто не работает, но ничего + не ломает. Митигейшн: документированный host-prerequisite + fallback-cron (`07`). +- **−** In-memory учёт результата (без миграции) — допустим для эксплуатационного демона (не SLA). +- **Откат:** `ORCH_BUILD_CACHE_PRUNE_ENABLED=false` → демон не стартует, поведение 1:1 как до + задачи; миграций БД нет, удалять нечего. + +## Ссылки +- BRD: `docs/work-items/ORCH-062/01-brd.md` +- TRZ: `docs/work-items/ORCH-062/02-trz.md` +- Acceptance: `docs/work-items/ORCH-062/03-acceptance-criteria.md` +- Инфра-требования: `docs/work-items/ORCH-062/07-infra-requirements.md` +- Тех-риски: `docs/work-items/ORCH-062/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0025-build-cache-pruner.md` +- Сверено по коду: `src/disk_watchdog.py` (каркас-образец), `src/image_freshness.py` + (`image_revision`/`_ssh_target` — ssh-канал к host docker), `src/config.py` + (`disk_monitor_*` + валидаторы, `deploy_ssh_user/host`), `src/main.py` + (`lifespan` старт/стоп демонов, `GET /queue`), `Dockerfile:11` (нет docker CLI в образе). +- Родственные компоненты: `docs/architecture/adr/adr-0024-disk-watchdog.md` (ORCH-063), + `adr-0007-reconciler.md`, `adr-0011-job-reaper-lease-reclaim.md`. + + diff --git a/docs/work-items/ORCH-062/07-infra-requirements.md b/docs/work-items/ORCH-062/07-infra-requirements.md new file mode 100644 index 0000000..2a14f92 --- /dev/null +++ b/docs/work-items/ORCH-062/07-infra-requirements.md @@ -0,0 +1,76 @@ +--- +work_item: ORCH-062 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-062 — авто-prune docker build cache на mva154 + +Work Item: **ORCH-062** · Repo: **orchestrator** · Стадия: architecture + +> Решение: **Вариант A** (фоновый демон приложения, `docker builder prune` на хосте через ssh) — +> см. `06-adr/ADR-001-build-cache-pruner.md`. Этот файл фиксирует host-prerequisites выбранного +> пути и задокументированный ручной fallback (Вариант C, host-cron). + +## I-1. Топология / окружения + +- Без изменений топологии: **новый внутренний фоновый daemon-поток** в существующем прод-контейнере + `orchestrator` (8500), наравне с `reconciler`/`job_reaper`/`disk_watchdog`. Новых контейнеров, + портов, сетей, томов — **нет**. +- Уборка исполняется **на хосте mva154** (host docker daemon — там физически живёт build cache) + через уже существующий ssh-канал `deploy_ssh_user@deploy_ssh_host` + (по образцу `image_freshness`/`self_deploy` Phase B). В контейнере `docker` CLI **нет** + (`Dockerfile:11` — только `openssh-client git curl`), поэтому raw-вызов CLI в контейнере + невозможен — только ssh на хост. + +## I-2. Переменные окружения / секреты + +Новые env (дефолты безопасны; полная карта — `docs/operations/INFRA.md`; канон — `.env.example`): + +| env | Дефолт | Назначение | +|-----|--------|------------| +| `ORCH_BUILD_CACHE_PRUNE_ENABLED` | `true` | kill-switch; `false` → демон не стартует, 1:1 как до задачи | +| `ORCH_BUILD_CACHE_PRUNE_INTERVAL_S` | `21600` (6ч) | период тика, сек (валидация >0, иначе → дефолт) | +| `ORCH_BUILD_CACHE_PRUNE_UNTIL` | `24h` | возраст удержания тёплого кэша (`--filter until=`); валидация `^\d+[smhdw]?$`, иначе → `24h` | +| `ORCH_BUILD_CACHE_PRUNE_ALL` | `false` | добавить `-a` (только в паре с `until`) | +| `ORCH_BUILD_CACHE_PRUNE_TIMEOUT_S` | `120` | таймаут ssh-команды, сек | +| `ORCH_BUILD_CACHE_PRUNE_NOTIFY_MIN_GB` | `0` | Telegram при освобождении ≥ N ГБ; `0` → тихо | + +- Переиспользуются существующие `ORCH_DEPLOY_SSH_USER` (дефолт `slin`) / `ORCH_DEPLOY_SSH_HOST` как + ssh-таргет. **Пустой `ORCH_DEPLOY_SSH_HOST` → тик no-op** (фича не активна вне self-host). +- Секретов не добавляет. ssh-ключи уже проброшены ro (`~/.orchestrator-ssh → /home/slin/.ssh`, + ORCH-040); в git не коммитятся. + +## I-3. Деплой / рестарт + +- **Рестарт docker daemon — НЕ требуется** (ключевое отличие от отклонённого Варианта B). Уборка — + это `docker builder prune` (BuildKit GC), без правки `daemon.json`. +- **Рестарт прод-контейнера ради уборки — категорически НЕ требуется и запрещён** (self-hosting + групповой риск). Сам код демона активируется штатным конвейерным деплоем оркестратора + (staging 8501 → Confirm Deploy → prod), не отдельной операцией. +- Host-prerequisites выбранного пути A (процедура Owner, в git не коммитятся — как P-1…P-4 в + INFRA.md): + 1. На хосте установлен `docker` и пользователь `slin` — в группе `docker` (уже выполняется: + деплой-хук запускает `docker compose` на хосте). + 2. ssh с контейнера на хост под `slin` работает без пароля (уже настроено для Phase B деплоя). + Иные действия Owner не требуются — фича включена дефолтом и активна при наличии ssh-таргета. + +### Ручной fallback (Вариант C, host-cron) — если ssh-канал недоступен + +Если по какой-то причине ssh-канал на хост закрыт, эквивалентную защиту можно временно обеспечить +host-cron на mva154 (процедура Owner, off-git): +```cron +# каждые 6 часов: удалить build cache старше 24ч (только build cache, не образы/контейнеры) +0 */6 * * * docker builder prune -f --filter until=24h >> /var/log/orch-build-cache-prune.log 2>&1 +``` +Это fallback, не основной путь: cron не наблюдаем в `GET /queue` и не имеет config-kill-switch. + +## I-4. CI/CD + +- `.gitea/workflows/` — **без изменений**. Добавляется юнит-тест `tests/test_build_cache_pruner.py` + (путь A), исполняется существующим `pytest tests/ -q`; docker/ssh в тестах мокируются (как + `image_freshness`-тесты не требуют реального docker). + diff --git a/docs/work-items/ORCH-062/10-tech-risks.md b/docs/work-items/ORCH-062/10-tech-risks.md new file mode 100644 index 0000000..2e82ecb --- /dev/null +++ b/docs/work-items/ORCH-062/10-tech-risks.md @@ -0,0 +1,43 @@ +--- +work_item: ORCH-062 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-062 — авто-prune docker build cache на mva154 + +Work Item: **ORCH-062** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Детализация R-1…R-4 из BRD + риски, выявленные при +> архитектурном решении (Вариант A, ssh-на-хост). Решение — `06-adr/ADR-001-build-cache-pruner.md`. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Слишком агрессивная политика** (`-a` без возрастного фильтра / малый `until`) убивает тёплый кэш → каждая сборка «холодная», медленная (BRD R-1) | Низ. | Сред. | Дефолт `docker builder prune -f --filter until=24h` **без** `-a`; `-a` — только опционально и всегда в паре с `until` (D2/AC-2). Параметр удержания конфигурируем | +| TR-2 | **Гонка уборки с активной сборкой** staging/прод-образа (`check_staging_image_fresh`, build-once retag) — теоретическое удаление кэша во время сборки (BRD R-2) | Низ. | Низ. | `docker builder prune --filter until=24h` по контракту BuildKit не трогает кэш, занятый/использованный активной сборкой (свежий < 24ч); период тика — порядка часов (6ч), не конкурирует за ресурсы (NFR-4) | +| TR-3 | **Контейнер не имеет docker CLI** (`Dockerfile:11`) → наивный `subprocess.run(["docker",…])` упал бы FileNotFoundError | — (закрыт решением) | — | Решено архитектурно: уборка идёт через **ssh на хост** (`image_freshness`-канал), не CLI-в-контейнере. Не риск реализации, а зафиксированный инвариант D3 | +| TR-4 | **ssh-канал недоступен** (нет `deploy_ssh_host` / закрыт ssh) → уборка не выполняется | Низ. | Сред. | Тик no-op + причина в `status().last_error` (наблюдаемо в `GET /queue`); never-raise — конвейер не страдает; документированный host-cron fallback (`07` I-3); disk-watchdog продолжает сигналить о росте диска | +| TR-5 | **Расширение скоупа** на `docker image prune` / `system prune` → удаление образов запущенных контейнеров (BRD R-4) | Низ. | Выс. | Жёстко исключено D2/FR-3/AC-3: команда строго `docker builder prune`; reviewer проверяет отсутствие `image prune`/`system prune`/рестарта в коде и процедуре | +| TR-6 | **Рестарт прода/докера ради уборки** (групповой self-hosting риск) | — (исключён) | Выс. | Вариант B (рестарт docker daemon) отвергнут именно по этой причине; Вариант A не рестартит ни прод, ни docker daemon (D3/I-3) | +| TR-7 | **Сбой docker-команды/таймаут** на хосте всплывает в фоновый поток → останавливает цикл/конвейер | Низ. | Сред. | never-raise per-команда и per-tick (D6/FR-6/AC-4), как `disk_watchdog._run`/`tick`; ненулевой rc/таймаут/`OSError` логируются и проглатываются | +| TR-8 | **Telegram-шум** при каждом тике | Низ. | Низ. | Нотификация только при освобождении ≥ `notify_min_gb`; дефолт `0` → тихо (D4/D5) | + +## Сводный вывод + +Доминирующий класс — **операционная безопасность self-hosting** (уборка на проде, обслуживающем +все проекты). Все высоко-влиятельные риски (TR-5/TR-6) **структурно исключены** выбором узкой +команды `docker builder prune` и отказом от рестарта docker daemon/прода (отклонён Вариант B). +Остаточные риски — низкой вероятности и нейтрализуются never-raise + наблюдаемостью в `GET /queue` ++ обратимостью kill-switch. + +**Эскалация:** вводится **новый фоновый компонент** (leaf-демон) — формально подпадает под +`arch:major-change`. Однако это калька уже принятого паттерна `disk_watchdog`/`reconciler`/ +`job_reaper` **без** изменения `STAGE_TRANSITIONS`/`QG_CHECKS`/схемы БД и **без** рестарта прода, +поэтому остаточный риск для прод-конвейера — **низкий**; возврат в анализ не требуется (ТЗ +реализуемо без нарушения принципов архитектуры). +