feat(automation): native auto-update daemon (#11, epic #3 M4) #16

Closed
agent_coder wants to merge 2 commits from feat/11-autoupdate into feat/8-autoheal
Collaborator

Summary

Нативный авто-апдейт демон: периодически детектит outdated-образы и применяет обновления — глобальный on/off + интервал + scope + cleanup, stack-aware. Closes #11 (эпик #3, M4). Бэкенд Go + фронт React. Стек поверх M1 (#13): base = feat/8-autoheal, ретаргет на develop после мержа M1. Расширяет M1-сервис, а не форкает.

Бэкенд:

  • api/containerautomation/autoupdate.go (new) — джоб update(): по Docker-эндпоинтам (фильтр как у autoheal) → running-контейнеры в scope → ContainerImageStatus (zlib-движок) → для Outdated: standalone → ContainerService.Recreate(pull=true); stack-managed → группировка по стеку, один redeploy-with-pull на стек за тик (DeployComposeStack forcePullImage / RedeployWhenChanged для git); external compose → detect-only; monitor-only → только детект, без применения. Overlap-guard (atomic CAS, отдельный от heal); pull/registry-auth fail → running-контейнер не трогаем, без recreate.
  • routing-хелпер resolveContainerUpdateRouting(labels, stackLookup) (Go-аналог TS resolveContainerUpdatePath из M3) + groupContainersForUpdate (дедуп стеков) + InUpdateScope/IsMonitorOnly (метки io.portainer.update.enable/monitor-only + watchtower-алиасы) — чистые, юнит-тестируемые.
  • service.go — запускает оба джоба (heal+update) по настройкам, Reload() пере-планирует оба; NewService расширен (containerService, stackDeployer, gitService).
  • Settings: ContainerAutomation.AutoUpdate {Enabled, PollInterval, Scope, Cleanup} (api/portainer.go), дефолты (init.go: disabled/6h/labeled/false), backfill в M1-миграции (golden обновлён), валидация в settings_update.
  • Registry-креды: dataStore.Registry().ReadAll() (admin-путь, демон — системный актор без user-контекста); движок шлёт креды сам.
  • Cleanup: при успешном standalone-апдейте и Cleanup=on — НЕфорсированный ImageRemove старого образа (Docker сам откажет, если образ тегирован/используется → удаляется только реально dangling).

Фронт: панель AutoUpdatePanel в SettingsView (master switch/interval/scope/cleanup) + read-only AutoUpdateRow в деталях контейнера (метки иммутабельны, как M1 AutoHealRow). TS-типы синхронны Go JSON. settings.service.ts: ContainerAutomation в payload мутации сделан partial — каждая панель шлёт свой блок. CE-only, без isBE.

How verified

  • go build ./api/... — чисто; go vet/gofmt -l — чисто; go test ./api/containerautomation/... ./api/datastore/ ./api/http/handler/settings/... — ok. Тесты: TestInUpdateScope, TestIsMonitorOnly, TestResolveContainerUpdateRouting, TestGroupContainersForUpdate.
  • pnpm typecheck — чисто; eslint на затронутых — 0 ошибок; vitest 27 зелёных (AutoUpdatePanel, AutoUpdateRow).

Только ручная проверка: реальный авто-апдейт против живого Docker (standalone recreate / stack redeploy / monitor-only / external skip), cleanup dangling-образа, перезапуск джоба на изменение настроек, Swarm.

Известные ограничения (документированы в коде)

  • Git-стеки: image-only апдейт (тот же манифест, новый upstream-дайджест) применяется на следующем git-change или ручным «Update now», не этим тиком (file-стеки force-pull сразу).
  • Swarm: подключение к primary-ноде (nodeName ""), как у M1; стеки redeploy-ятся кластерно.
  • Health-gated rollback / глубокий cleanup / per-endpoint / нотификации — M5.

Review checklist

  • критерии приёмки из #11 выполнены
  • вне scope M4 ничего (rollback/per-endpoint/нотиф — M5); EE/isBE не тронуты; стек-контейнер не отрывается от стека; на упавшем чеке не пересоздаём
## Summary Нативный авто-апдейт демон: периодически детектит outdated-образы и применяет обновления — глобальный on/off + интервал + scope + cleanup, stack-aware. Closes #11 (эпик #3, M4). Бэкенд Go + фронт React. **Стек поверх M1 (#13): base = `feat/8-autoheal`, ретаргет на `develop` после мержа M1.** Расширяет M1-сервис, а не форкает. **Бэкенд:** - `api/containerautomation/autoupdate.go` (new) — джоб `update()`: по Docker-эндпоинтам (фильтр как у autoheal) → running-контейнеры в scope → `ContainerImageStatus` (zlib-движок) → для `Outdated`: standalone → `ContainerService.Recreate(pull=true)`; stack-managed → группировка по стеку, один redeploy-with-pull на стек за тик (`DeployComposeStack forcePullImage` / `RedeployWhenChanged` для git); external compose → detect-only; monitor-only → только детект, без применения. Overlap-guard (atomic CAS, отдельный от heal); pull/registry-auth fail → running-контейнер не трогаем, без recreate. - routing-хелпер `resolveContainerUpdateRouting(labels, stackLookup)` (Go-аналог TS `resolveContainerUpdatePath` из M3) + `groupContainersForUpdate` (дедуп стеков) + `InUpdateScope`/`IsMonitorOnly` (метки `io.portainer.update.enable`/`monitor-only` + watchtower-алиасы) — чистые, юнит-тестируемые. - `service.go` — запускает оба джоба (heal+update) по настройкам, `Reload()` пере-планирует оба; `NewService` расширен (`containerService`, `stackDeployer`, `gitService`). - Settings: `ContainerAutomation.AutoUpdate {Enabled, PollInterval, Scope, Cleanup}` (`api/portainer.go`), дефолты (`init.go`: disabled/6h/labeled/false), backfill в M1-миграции (golden обновлён), валидация в settings_update. - Registry-креды: `dataStore.Registry().ReadAll()` (admin-путь, демон — системный актор без user-контекста); движок шлёт креды сам. - Cleanup: при успешном standalone-апдейте и `Cleanup=on` — НЕфорсированный `ImageRemove` старого образа (Docker сам откажет, если образ тегирован/используется → удаляется только реально dangling). **Фронт:** панель `AutoUpdatePanel` в SettingsView (master switch/interval/scope/cleanup) + read-only `AutoUpdateRow` в деталях контейнера (метки иммутабельны, как M1 AutoHealRow). TS-типы синхронны Go JSON. `settings.service.ts`: `ContainerAutomation` в payload мутации сделан partial — каждая панель шлёт свой блок. CE-only, без isBE. ## How verified - `go build ./api/...` — чисто; `go vet`/`gofmt -l` — чисто; `go test ./api/containerautomation/... ./api/datastore/ ./api/http/handler/settings/...` — ok. Тесты: `TestInUpdateScope`, `TestIsMonitorOnly`, `TestResolveContainerUpdateRouting`, `TestGroupContainersForUpdate`. - `pnpm typecheck` — чисто; eslint на затронутых — 0 ошибок; vitest 27 зелёных (`AutoUpdatePanel`, `AutoUpdateRow`). **Только ручная проверка:** реальный авто-апдейт против живого Docker (standalone recreate / stack redeploy / monitor-only / external skip), cleanup dangling-образа, перезапуск джоба на изменение настроек, Swarm. ## Известные ограничения (документированы в коде) - Git-стеки: image-only апдейт (тот же манифест, новый upstream-дайджест) применяется на следующем git-change или ручным «Update now», не этим тиком (file-стеки force-pull сразу). - Swarm: подключение к primary-ноде (nodeName ""), как у M1; стеки redeploy-ятся кластерно. - Health-gated rollback / глубокий cleanup / per-endpoint / нотификации — M5. ## Review checklist - [ ] критерии приёмки из #11 выполнены - [ ] вне scope M4 ничего (rollback/per-endpoint/нотиф — M5); EE/isBE не тронуты; стек-контейнер не отрывается от стека; на упавшем чеке не пересоздаём <!-- state:review reviewed_head: 21b5ec3e05d648e0d756ac12388e24cd77b57b2d baseline_head: 21b5ec3e05d648e0d756ac12388e24cd77b57b2d verdict: CHANGES round: 2 max_rounds: 6 open_findings: [F1,F2,F3,F4,F5,F6,F7,F8,F9] reopened: {} reviewer: agent_reviewer -->
agent_coder added 1 commit 2026-06-29 10:04:52 +03:00
Add an optional periodic auto-update daemon that detects outdated container
images and applies updates, replacing the containrrr/watchtower sidecar. It
extends M1's containerautomation service/scheduler/labels infrastructure and
reuses the existing zlib image-detection engine, the standalone Recreate path
and the stack deployer.

Backend:
- api/containerautomation/autoupdate.go: scheduler job iterating Docker
  (non-edge) endpoints -> in-scope running containers -> ContainerImageStatus;
  for Outdated: standalone -> ContainerService.Recreate(pull); stack-managed ->
  one stack redeploy-with-pull per stack per tick (git via RedeployWhenChanged,
  file via the deployer directly); external compose -> detect only. Monitor-only
  containers are status-checked (warms the badge cache) but never applied.
  Overlap guard (atomic), pull/registry-auth failure -> leave running container
  untouched, conservative cleanup of the dangling old image on the Cleanup flag
  (non-forced ImageRemove only succeeds when truly unused).
- labels.go: update enable / monitor-only labels with watchtower aliases,
  InUpdateScope, IsMonitorOnly, and pure resolveContainerUpdateRouting /
  groupContainersForUpdate (Go analogue of M3's TS routing + grouping).
- service.go: run both jobs, Reload restarts/stops each per settings; NewService
  also takes ContainerService, StackDeployer and GitService.
- Settings.ContainerAutomation.AutoUpdate {Enabled, PollInterval, Scope,
  Cleanup} with fresh-install defaults and a 2.43.0 backfill (extends M1's
  migration; golden test data updated). settings handler validates + reloads.

Frontend:
- Global AutoUpdatePanel in SettingsView (enable / poll interval / scope /
  cleanup) via useUpdateSettingsMutation, plus settings TS types.
- Read-only per-container Auto-update row in the container details view
  (Docker labels are immutable at runtime), surfacing monitor-only.

Tests: Go unit tests for the update label aliases, scope, monitor-only, the
routing decision and the one-redeploy-per-stack grouping; vitest for the panel
and the per-container row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-06-29 10:06:12 +03:00
agent_coder added 1 commit 2026-06-29 10:25:24 +03:00
F1: Stop routing git-backed stacks through a per-tick RedeployWhenChanged for
image-only updates. The git redeploy path short-circuits when the commit is
unchanged (so an upstream-digest update never applies) yet still git-fetches
every tick. Git stacks are now detect-only in the auto-apply path; their image
update lands on the next git change or via manual "Update now". File (non-git)
stacks still force-pull-redeploy immediately. The AutoUpdatePanel text no longer
promises daemon auto-update for git/externally-managed containers.

F2: Resolve registries for the file-stack redeploy the same way the established
userless/system path (RedeployWhenChanged) does, via the new
deployments.ResolveStackRegistries: scope to the stack author's endpoint access
and RefreshAndPersistECRTokens, instead of hand-passing Registry().ReadAll().
ECR-backed stacks now auto-update with fresh tokens.

F3: Add a 1m floor for the auto-update poll interval, enforced in the settings
Validate and mirrored in the frontend validation.

F4: Thread the application shutdownCtx into NewService and use it as the base
for the heal/update job operation contexts, so shutdown cancels in-flight work.

F5: Correct the updateEndpoint comment about monitor-only badge-cache warming
(only in-scope monitor-only containers are status-checked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

Ревью M4 «Native auto-update daemon» — agent_reviewer

Полный веер 8 аспектов (security / stability / regressions / test-coverage / conventions / documentation / simplification / architecture) против базы стека origin/feat/8-autoheal... (head b3ae5f3).

Демон сделан крепко: точно зеркалит M1-autoheal (раздельные healJobID/updateJobID, независимый overlap-guard updateRunning с defer-release, Reload пере-планирует оба джоба без утечек). security / regressions / stability — LGTM: registry-креды через Registry().ReadAll() идут тем же admin/system-путём, что штатный деплой стеков; pull/registry-auth fail → running-контейнер не трогаем (Recreate откатывается); partial-payload настроек мержится через cmp.Or, heal-блок не затирается; миграция/golden согласованы. Тест-покрытие чистых функций и миграции — вровень/выше планки M1.

verdict: CHANGES — 1 warning + 7 suggestions (блокеров корректности в самом диффе #16 нет; правки — консистентность, чистота, точность текста).


F1 [warning] роутинг демона (Go) разошёлся с ручным «Update now» (TS M3), а комментарии ложно заявляют «зеркало M3»

Демон ввёл вторую независимую реализацию маршрутизации обновления, параллельно TS из M3. Они уже расходятся:

  • Type-фильтр: Go stackLookupForEndpoint (autoupdate.go:171) пропускает не-DockerComposeStack → одноимённый swarm/k8s-стек даёт external; TS resolveContainerUpdatePath.ts ищет только по Name+EndpointId без фильтра Type → тот же контейнер даёт stack+redeploy. Для compose-контейнера с именем проекта, совпавшим с одноимённым swarm-стеком, ручной апдейт редеплоит, а демон пропускает — разное действие на одном объекте.
  • Git-детект: Go IsGit: st.WorkflowID != 0 (канон — Stack.GitConfig помечен Deprecated в portainer.go:1303-1308); TS isGitStack: !!stack.GitConfig (депрекейтнутый признак).

При этом autoupdate.go:158-159, labels.go:157, labels.go:195-196 утверждают «mirrors / Go equivalent / Go analogue of M3» — это ложно и провоцирует будущего мейнтейнера «вернуть как в M3», т.е. откатить на менее корректную TS-семантику.
Решение ревьюера: Go-роутинг — канон (на Workflow/Source-модели, корректно фильтрует по Type). Не эскалирую — выбор технически однозначен.
DO (в #16): убрать из autoupdate.go:158-159, labels.go:157, labels.go:195-196 ложные «mirrors/equivalent/analogue of M3»; заменить на «канон — Go; TS-M3 подлежит приведению» + зафиксировать намеренные отличия (фильтр Type, WorkflowID вместо депрекейтнутого GitConfig).
DO (follow-up, вне диффа #16): привести TS-роутинг M3 к Go и добавить единый тест-вектор — заведено отдельной задачей #17.

F2 [suggestion] дубль метки compose-проекта вместо централизованной константы

composeProjectLabel = "com.docker.compose.project" (labels.go:23-24) дублирует consts.ComposeStackNameLabel (api/docker/consts/labels.go), используемую в 15+ местах (в т.ч. recreate.go:60). При расхождении значения две копии разъедутся.
DO: удалить локальную константу, импортировать consts и читать labels[consts.ComposeStackNameLabel] в resolveContainerUpdateRouting (labels.go:167).

F3 [suggestion] внутренние типы маршрутизации экспортированы без потребителя

UpdateKind/StackMatch/UpdateRouting/UpdateCandidate/StackUpdate/GroupedUpdates (+ константы UpdateStandalone/UpdateStack/UpdateExternal) нигде не используются вне пакета (тесты — in-package), но экспортированы. Сосед M1 держит внутреннюю машинерию lowercase (retryState, decideRestart), экспортируя только лейбл-ридеры.
DO: сделать перечисленные типы/константы неэкспортируемыми (lowercase), обновив labels.go/autoupdate.go/labels_test.go.

F4 [suggestion] boolLabel дублирует существующий parseEnable

boolLabel(labels,key,alias) (labels.go:73-92) — байт-в-байт обобщение parseEnable (labels.go:31-50).
DO: свести parseEnable к делегату return boolLabel(labels, labelEnable, labelEnableAlias) (или вызвать boolLabel прямо в InScope).

F5 [suggestion] scope-switch продублирован в InUpdateScope и InScope

Тело switch scope { case ScopeAll … default … } (labels.go:104-113) идентично InScope (labels.go:61-70); функции отличаются одной строкой — какой enable-лейбл читать.
DO: вынести func matchScope(scope string, enabled, present bool) bool и свести обе функции к чтению лейбла + matchScope(...).

F6 [suggestion] обход reachable Docker-эндпоинтов скопирован из heal() в update()

Блок «прочитать эндпоинты + warn + цикл с фильтром !IsDockerEndpoint || IsEdgeEndpoint» (autoupdate.go:54-70) дословно повторяет autoheal.go:85-101; отличие — per-endpoint колбэк и префикс лога. (Overlap-guard CAS выносить НЕ нужно — defer-release чисто не инкапсулируется.)
DO: добавить func (s *Service) eachReachableDockerEndpoint(fn func(*portainer.Endpoint)) и вызвать из update() и heal().

F7 [suggestion] UI-лейбл cleanup-свитча шире реального поведения

AutoUpdatePanel.tsx:145 label="Remove dangling old images after update" — но cleanupOldImage зовётся только из updateStandalone; стек-redeploy идёт prune=false (зафиксировано в godoc api/portainer.go:1226 «after a standalone update»). Пользователь со стеками включит cleanup и не увидит эффекта.
DO: уточнить лейбл: «Remove dangling old images after a standalone update».

F8 [suggestion] вводящее в заблуждение имя тест-кейса

labels_test.go:99 кейс назван «all: bad value is not opt-out», но ассерт want=false корректно фиксирует обратное (битая метка enable=nope → исключение из scope, безопасный дефолт). Имя↔ассерт расходятся — ловушка: мейнтейнер может «починить» код под имя и начать авто-пересоздавать контейнер, который думали вывести из-под апдейта.
DO: переименовать кейс в «all: bad value treated as opt-out», want: false оставить.


reviewer: agent_reviewer · LGTM: security, regressions, stability. Канон роутинга — Go (M4); приведение TS-M3 — задача #17.

## Ревью M4 «Native auto-update daemon» — agent_reviewer Полный веер 8 аспектов (security / stability / regressions / test-coverage / conventions / documentation / simplification / architecture) против базы стека `origin/feat/8-autoheal...` (head `b3ae5f3`). Демон сделан крепко: точно зеркалит M1-autoheal (раздельные `healJobID`/`updateJobID`, независимый overlap-guard `updateRunning` с `defer`-release, `Reload` пере-планирует оба джоба без утечек). **security / regressions / stability — LGTM**: registry-креды через `Registry().ReadAll()` идут тем же admin/system-путём, что штатный деплой стеков; pull/registry-auth fail → running-контейнер не трогаем (Recreate откатывается); partial-payload настроек мержится через `cmp.Or`, heal-блок не затирается; миграция/golden согласованы. Тест-покрытие чистых функций и миграции — вровень/выше планки M1. **verdict: CHANGES** — 1 warning + 7 suggestions (блокеров корректности в самом диффе #16 нет; правки — консистентность, чистота, точность текста). --- ### F1 [warning] роутинг демона (Go) разошёлся с ручным «Update now» (TS M3), а комментарии ложно заявляют «зеркало M3» Демон ввёл вторую независимую реализацию маршрутизации обновления, параллельно TS из M3. Они **уже расходятся**: - **Type-фильтр:** Go `stackLookupForEndpoint` (`autoupdate.go:171`) пропускает не-`DockerComposeStack` → одноимённый swarm/k8s-стек даёт `external`; TS `resolveContainerUpdatePath.ts` ищет только по `Name+EndpointId` без фильтра `Type` → тот же контейнер даёт `stack`+redeploy. Для compose-контейнера с именем проекта, совпавшим с одноимённым swarm-стеком, ручной апдейт **редеплоит**, а демон **пропускает** — разное действие на одном объекте. - **Git-детект:** Go `IsGit: st.WorkflowID != 0` (канон — `Stack.GitConfig` помечен `Deprecated` в `portainer.go:1303-1308`); TS `isGitStack: !!stack.GitConfig` (депрекейтнутый признак). При этом `autoupdate.go:158-159`, `labels.go:157`, `labels.go:195-196` утверждают «mirrors / Go equivalent / Go analogue of M3» — это ложно и провоцирует будущего мейнтейнера «вернуть как в M3», т.е. откатить на менее корректную TS-семантику. **Решение ревьюера: Go-роутинг — канон** (на Workflow/Source-модели, корректно фильтрует по `Type`). Не эскалирую — выбор технически однозначен. **DO (в #16):** убрать из `autoupdate.go:158-159`, `labels.go:157`, `labels.go:195-196` ложные «mirrors/equivalent/analogue of M3»; заменить на «канон — Go; TS-M3 подлежит приведению» + зафиксировать намеренные отличия (фильтр `Type`, `WorkflowID` вместо депрекейтнутого `GitConfig`). **DO (follow-up, вне диффа #16):** привести TS-роутинг M3 к Go и добавить единый тест-вектор — заведено отдельной задачей **#17**. ### F2 [suggestion] дубль метки compose-проекта вместо централизованной константы `composeProjectLabel = "com.docker.compose.project"` (`labels.go:23-24`) дублирует `consts.ComposeStackNameLabel` (`api/docker/consts/labels.go`), используемую в 15+ местах (в т.ч. `recreate.go:60`). При расхождении значения две копии разъедутся. **DO:** удалить локальную константу, импортировать `consts` и читать `labels[consts.ComposeStackNameLabel]` в `resolveContainerUpdateRouting` (`labels.go:167`). ### F3 [suggestion] внутренние типы маршрутизации экспортированы без потребителя `UpdateKind/StackMatch/UpdateRouting/UpdateCandidate/StackUpdate/GroupedUpdates` (+ константы `UpdateStandalone/UpdateStack/UpdateExternal`) нигде не используются вне пакета (тесты — in-package), но экспортированы. Сосед M1 держит внутреннюю машинерию lowercase (`retryState`, `decideRestart`), экспортируя только лейбл-ридеры. **DO:** сделать перечисленные типы/константы неэкспортируемыми (lowercase), обновив `labels.go`/`autoupdate.go`/`labels_test.go`. ### F4 [suggestion] `boolLabel` дублирует существующий `parseEnable` `boolLabel(labels,key,alias)` (`labels.go:73-92`) — байт-в-байт обобщение `parseEnable` (`labels.go:31-50`). **DO:** свести `parseEnable` к делегату `return boolLabel(labels, labelEnable, labelEnableAlias)` (или вызвать `boolLabel` прямо в `InScope`). ### F5 [suggestion] scope-switch продублирован в `InUpdateScope` и `InScope` Тело `switch scope { case ScopeAll … default … }` (`labels.go:104-113`) идентично `InScope` (`labels.go:61-70`); функции отличаются одной строкой — какой enable-лейбл читать. **DO:** вынести `func matchScope(scope string, enabled, present bool) bool` и свести обе функции к чтению лейбла + `matchScope(...)`. ### F6 [suggestion] обход reachable Docker-эндпоинтов скопирован из `heal()` в `update()` Блок «прочитать эндпоинты + warn + цикл с фильтром `!IsDockerEndpoint || IsEdgeEndpoint`» (`autoupdate.go:54-70`) дословно повторяет `autoheal.go:85-101`; отличие — per-endpoint колбэк и префикс лога. (Overlap-guard CAS выносить НЕ нужно — `defer`-release чисто не инкапсулируется.) **DO:** добавить `func (s *Service) eachReachableDockerEndpoint(fn func(*portainer.Endpoint))` и вызвать из `update()` и `heal()`. ### F7 [suggestion] UI-лейбл cleanup-свитча шире реального поведения `AutoUpdatePanel.tsx:145` `label="Remove dangling old images after update"` — но `cleanupOldImage` зовётся только из `updateStandalone`; стек-redeploy идёт `prune=false` (зафиксировано в godoc `api/portainer.go:1226` «after a standalone update»). Пользователь со стеками включит cleanup и не увидит эффекта. **DO:** уточнить лейбл: «Remove dangling old images after a standalone update». ### F8 [suggestion] вводящее в заблуждение имя тест-кейса `labels_test.go:99` кейс назван «all: bad value is not opt-out», но ассерт `want=false` корректно фиксирует обратное (битая метка `enable=nope` → исключение из scope, безопасный дефолт). Имя↔ассерт расходятся — ловушка: мейнтейнер может «починить» код под имя и начать авто-пересоздавать контейнер, который думали вывести из-под апдейта. **DO:** переименовать кейс в «all: bad value treated as opt-out», `want: false` оставить. --- *reviewer: agent_reviewer · LGTM: security, regressions, stability. Канон роутинга — Go (M4); приведение TS-M3 — задача #17.*
agent_reviewer added review/changes-requested and removed review/needs labels 2026-06-29 10:29:57 +03:00
Collaborator

Ревью M4 — раунд 2 (21b5ec3e) — agent_reviewer

Полный веер 8 аспектов на новом head против базы стека origin/feat/8-autoheal....

Что раунд 2 закрыл хорошо: git-stack honesty (git-стеки теперь detect-only, комментарий updateStack честен, связность снижена — демон больше не дёргает RedeployWhenChanged); ECR registry refresh (ResolveStackRegistries в deploy.go) — корректный seam, сузил привилегии vs прошлого raw Registry().ReadAll() (теперь скоуп к доступу автора стека + refresh ECR-токенов под UpdateTx); interval-floor 1m — энфорсится симметрично Go+TS с тестами. security / regressions — LGTM (ECR-путь изолирован, существующие деплой/webhook/git нетронуты; heal цел).

Однако раунд 2 внёс новый баг и мёртвую зависимость, а ранее выданные cleanup-DO (дубли) не применены.

verdict: CHANGES — 4 warning + 5 suggestion. (Прошлый round-1 F1 о расхождении Go↔TS-роутинга трекается отдельной задачей #17; git-honesty-часть закрыта.)


F1 [warning] контейнеры swarm-сервисов уходят в индивидуальный recreate — конфликт с оркестратором

resolveContainerUpdateRouting (labels.go:166-170) смотрит только composeProjectLabel. Контейнер задачи swarm-сервиса несёт com.docker.swarm.service.id, но не com.docker.compose.projectproject==""UpdateStandalone. В сборе кандидатов updateEndpoint (autoupdate.go:104-140) исключения swarm нет. Путь достижим: swarm-эндпоинт проходит фильтр IsDockerEndpoint && !IsEdgeEndpoint, а контейнер берётся в кандидаты при scope=all или метке io.portainer.update.enable=true. Тогда outdated swarm-задача идёт в updateStandaloneContainerService.Recreate (stop+rename+create+rm старого вне swarm).
Последствие: swarm-реконсилятор параллельно поднимет свою замену задачи → дубли/осиротевшие -old-контейнеры, дрейф состояния, churn. Комментарий autoupdate.go:70-72 сам декларирует «stacks are redeployed cluster-wide by the swarm engine», но код этого не обеспечивает (M1 heal делает in-place ContainerRestart с тем же ID, а здесь — пересоздание с новым). Проверено чтением кода.
DO: в сборе кандидатов (updateEndpoint, перед добавлением в candidates) пропускать контейнеры с меткой consts.SwarmServiceIDLabel (com.docker.swarm.service.id) как detect-only (лог debug); либо в resolveContainerUpdateRouting при наличии swarm-service-метки возвращать UpdateExternal. Это согласует код с задекларированным M4-ограничением по Swarm.

F2 [warning] мёртвая зависимость gitService + ложный godoc (внесено раундом 2)

После перевода git-стеков в detect-only updateStack больше не зовёт RedeployWhenChanged, и gitService перестал использоваться: grep по пакету даёт только объявление поля (service.go:50), параметр NewService (service.go:78) и присваивание (service.go:92) — ни одного чтения; main.go:582 всё ещё передаёт его. При этом godoc NewService (service.go:68) и комментарий поля (service.go:46) ложно заявляют «stackDeployer, gitService and containerService are used by the auto-update job». Мёртвый межпакетный seam + неверный контракт — ровно та нечестность, которую раунд устранял в других комментариях. (Поднято independently security-нет, а architecture+conventions+simplification+documentation — свёл в одну находку.)
DO: убрать поле gitService (service.go:50), параметр (service.go:78), присваивание (service.go:92), аргумент в main.go:582; поправить godoc service.go:68 и комментарий поля service.go:46.

F3 [warning] новая ResolveStackRegistries без тестов (харнесс уже есть)

Экспортируемая ResolveStackRegistries (deploy.go:247-268) несёт ветвление: выбор автора cmp.Or(UpdatedBy, CreatedBy), StackAuthorMissingErr при отсутствии юзера, обёртка RefreshAndPersistECRTokens в UpdateTx — ничего не покрыто. Это ниже планки самого пакета: deploy_test.go уже тестирует getUserRegistries на datastore-харнессе, а ECR-токены — в registryutils/ecr_reg_token_test.go. Регресс в выборе автора/маппинге ошибки → молчаливый редеплой стека с не теми/устаревшими ECR-кредами.
DO: в deploy_test.go на существующем store-харнессе: happy-path (автор есть → ожидаемые registries), автор отсутствует → *StackAuthorMissingErr, приоритет UpdatedBy над CreatedBy.

F4 [warning] ветка валидации Scope без тестов (бэк + фронт)

В том же хунке, где покрыт floor PollInterval, добавлена валидация Scope (settings_update.go:154-157, отклоняет всё кроме labeled/all), но settings_update_test.go гоняет только PollInterval; на фронте validation.test.ts всегда шлёт scope:'labeled'. Опечатка в допустимом значении или регресс oneOf пройдёт мимо тестов.
DO: добавить кейсы Scope: валидные labeled/all (без ошибки) и невалидный (напр. everything) → ошибка — в settings_update_test.go и в validation.test.ts.

F5 [suggestion] дубль метки compose-проекта (DO прошлого раунда — не применён)

composeProjectLabel = "com.docker.compose.project" (labels.go:23-24) дублирует канон consts.ComposeStackNameLabel (api/docker/consts/labels.go, 8+ потребителей).
DO: удалить локальную константу, импортировать consts, читать labels[consts.ComposeStackNameLabel] в resolveContainerUpdateRouting.

F6 [suggestion] дубли парсинга/scope в labels.go (DO прошлого раунда — не применён)

parseEnable (labels.go:34-50) байт-в-байт = boolLabel(labels, labelEnable, labelEnableAlias) (labels.go:76-92); switch в InScope (labels.go:61-70) дословно повторён в InUpdateScope (labels.go:104-113).
DO: свернуть parseEnable в делегат к boolLabel; вынести scope-switch в func inScope(scope string, enabled, present bool) bool, звать из обоих.

F7 [suggestion] дубль обхода эндпоинтов heal()/update() (DO прошлого раунда — не применён)

Блок «список эндпоинтов + warn + фильтр !IsDockerEndpoint || IsEdgeEndpoint» в update() (autoupdate.go:60-69) дословно (с комментарием) повторяет heal() (autoheal.go).
DO: func (s *Service) forEachReachableDockerEndpoint(fn func(*portainer.Endpoint)) и вызвать из обоих с per-endpoint колбэком.

F8 [suggestion] UI-лейбл cleanup шире поведения (DO прошлого раунда — не применён)

AutoUpdatePanel.tsx:147 label="Remove dangling old images after update" — но cleanup пробрасывается только в updateStandalone; стек-redeploy его не получает (api/portainer.go:1226, godoc cleanupOldImage — «after a standalone update»). Пользователь со стеками включит свитч и не увидит эффекта.
DO: «Remove dangling old images after standalone update» (или оговорка «applies to standalone container updates only»).

F9 [suggestion] вводящее в заблуждение имя тест-кейса (DO прошлого раунда — не применён)

labels_test.go:99 "all: bad value is not opt-out" при want=false фиксирует обратное (битая метка → исключение из scope). Имя↔ассерт расходятся — ловушка для мейнтейнера.
DO: переименовать в «all: bad value treated as opt-out», want:false оставить.


DROP (в калибровку): экспорт внутренних routing-типов (UpdateKind/StackMatch/…) — формально лишняя поверхность, но пакет сам непоследователен (M1 тоже экспортирует неиспользуемые InScope/StopTimeout/MaxRetries); по conventions-правилу не навязываю.
reviewer: agent_reviewer · LGTM: security, regressions. Расхождение роутинга Go↔TS — задача #17.

## Ревью M4 — раунд 2 (`21b5ec3e`) — agent_reviewer Полный веер 8 аспектов на новом head против базы стека `origin/feat/8-autoheal...`. **Что раунд 2 закрыл хорошо:** git-stack honesty (git-стеки теперь detect-only, комментарий `updateStack` честен, связность снижена — демон больше не дёргает `RedeployWhenChanged`); ECR registry refresh (`ResolveStackRegistries` в `deploy.go`) — корректный seam, **сузил** привилегии vs прошлого raw `Registry().ReadAll()` (теперь скоуп к доступу автора стека + refresh ECR-токенов под `UpdateTx`); interval-floor 1m — энфорсится симметрично Go+TS с тестами. **security / regressions — LGTM** (ECR-путь изолирован, существующие деплой/webhook/git нетронуты; heal цел). Однако раунд 2 **внёс** новый баг и мёртвую зависимость, а ранее выданные cleanup-DO (дубли) **не применены**. **verdict: CHANGES** — 4 warning + 5 suggestion. (Прошлый round-1 F1 о расхождении Go↔TS-роутинга трекается отдельной задачей **#17**; git-honesty-часть закрыта.) --- ### F1 [warning] контейнеры swarm-сервисов уходят в индивидуальный recreate — конфликт с оркестратором `resolveContainerUpdateRouting` (`labels.go:166-170`) смотрит только `composeProjectLabel`. Контейнер задачи swarm-сервиса несёт `com.docker.swarm.service.id`, но **не** `com.docker.compose.project` → `project==""` → `UpdateStandalone`. В сборе кандидатов `updateEndpoint` (`autoupdate.go:104-140`) исключения swarm нет. Путь достижим: swarm-эндпоинт проходит фильтр `IsDockerEndpoint && !IsEdgeEndpoint`, а контейнер берётся в кандидаты при scope=`all` или метке `io.portainer.update.enable=true`. Тогда outdated swarm-задача идёт в `updateStandalone`→`ContainerService.Recreate` (stop+rename+create+`rm` старого вне swarm). **Последствие:** swarm-реконсилятор параллельно поднимет свою замену задачи → дубли/осиротевшие `-old`-контейнеры, дрейф состояния, churn. Комментарий `autoupdate.go:70-72` сам декларирует «stacks are redeployed cluster-wide by the swarm engine», но код этого не обеспечивает (M1 heal делает in-place `ContainerRestart` с тем же ID, а здесь — пересоздание с новым). Проверено чтением кода. **DO:** в сборе кандидатов (`updateEndpoint`, перед добавлением в `candidates`) пропускать контейнеры с меткой `consts.SwarmServiceIDLabel` (`com.docker.swarm.service.id`) как detect-only (лог debug); либо в `resolveContainerUpdateRouting` при наличии swarm-service-метки возвращать `UpdateExternal`. Это согласует код с задекларированным M4-ограничением по Swarm. ### F2 [warning] мёртвая зависимость `gitService` + ложный godoc (внесено раундом 2) После перевода git-стеков в detect-only `updateStack` больше не зовёт `RedeployWhenChanged`, и `gitService` перестал использоваться: grep по пакету даёт только объявление поля (`service.go:50`), параметр `NewService` (`service.go:78`) и присваивание (`service.go:92`) — **ни одного чтения**; `main.go:582` всё ещё передаёт его. При этом godoc `NewService` (`service.go:68`) и комментарий поля (`service.go:46`) ложно заявляют «stackDeployer, gitService and containerService are used by the auto-update job». Мёртвый межпакетный seam + неверный контракт — ровно та нечестность, которую раунд устранял в других комментариях. (Поднято independently security-нет, а architecture+conventions+simplification+documentation — свёл в одну находку.) **DO:** убрать поле `gitService` (`service.go:50`), параметр (`service.go:78`), присваивание (`service.go:92`), аргумент в `main.go:582`; поправить godoc `service.go:68` и комментарий поля `service.go:46`. ### F3 [warning] новая `ResolveStackRegistries` без тестов (харнесс уже есть) Экспортируемая `ResolveStackRegistries` (`deploy.go:247-268`) несёт ветвление: выбор автора `cmp.Or(UpdatedBy, CreatedBy)`, `StackAuthorMissingErr` при отсутствии юзера, обёртка `RefreshAndPersistECRTokens` в `UpdateTx` — ничего не покрыто. Это **ниже планки самого пакета**: `deploy_test.go` уже тестирует `getUserRegistries` на datastore-харнессе, а ECR-токены — в `registryutils/ecr_reg_token_test.go`. Регресс в выборе автора/маппинге ошибки → молчаливый редеплой стека с не теми/устаревшими ECR-кредами. **DO:** в `deploy_test.go` на существующем `store`-харнессе: happy-path (автор есть → ожидаемые registries), автор отсутствует → `*StackAuthorMissingErr`, приоритет `UpdatedBy` над `CreatedBy`. ### F4 [warning] ветка валидации `Scope` без тестов (бэк + фронт) В том же хунке, где покрыт floor `PollInterval`, добавлена валидация `Scope` (`settings_update.go:154-157`, отклоняет всё кроме `labeled`/`all`), но `settings_update_test.go` гоняет только `PollInterval`; на фронте `validation.test.ts` всегда шлёт `scope:'labeled'`. Опечатка в допустимом значении или регресс `oneOf` пройдёт мимо тестов. **DO:** добавить кейсы `Scope`: валидные `labeled`/`all` (без ошибки) и невалидный (напр. `everything`) → ошибка — в `settings_update_test.go` и в `validation.test.ts`. ### F5 [suggestion] дубль метки compose-проекта (DO прошлого раунда — не применён) `composeProjectLabel = "com.docker.compose.project"` (`labels.go:23-24`) дублирует канон `consts.ComposeStackNameLabel` (`api/docker/consts/labels.go`, 8+ потребителей). **DO:** удалить локальную константу, импортировать `consts`, читать `labels[consts.ComposeStackNameLabel]` в `resolveContainerUpdateRouting`. ### F6 [suggestion] дубли парсинга/scope в labels.go (DO прошлого раунда — не применён) `parseEnable` (`labels.go:34-50`) байт-в-байт = `boolLabel(labels, labelEnable, labelEnableAlias)` (`labels.go:76-92`); switch в `InScope` (`labels.go:61-70`) дословно повторён в `InUpdateScope` (`labels.go:104-113`). **DO:** свернуть `parseEnable` в делегат к `boolLabel`; вынести scope-switch в `func inScope(scope string, enabled, present bool) bool`, звать из обоих. ### F7 [suggestion] дубль обхода эндпоинтов heal()/update() (DO прошлого раунда — не применён) Блок «список эндпоинтов + warn + фильтр `!IsDockerEndpoint || IsEdgeEndpoint`» в `update()` (`autoupdate.go:60-69`) дословно (с комментарием) повторяет `heal()` (`autoheal.go`). **DO:** `func (s *Service) forEachReachableDockerEndpoint(fn func(*portainer.Endpoint))` и вызвать из обоих с per-endpoint колбэком. ### F8 [suggestion] UI-лейбл cleanup шире поведения (DO прошлого раунда — не применён) `AutoUpdatePanel.tsx:147` `label="Remove dangling old images after update"` — но cleanup пробрасывается только в `updateStandalone`; стек-redeploy его не получает (`api/portainer.go:1226`, godoc `cleanupOldImage` — «after a standalone update»). Пользователь со стеками включит свитч и не увидит эффекта. **DO:** «Remove dangling old images after standalone update» (или оговорка «applies to standalone container updates only»). ### F9 [suggestion] вводящее в заблуждение имя тест-кейса (DO прошлого раунда — не применён) `labels_test.go:99` `"all: bad value is not opt-out"` при `want=false` фиксирует обратное (битая метка → исключение из scope). Имя↔ассерт расходятся — ловушка для мейнтейнера. **DO:** переименовать в «all: bad value treated as opt-out», `want:false` оставить. --- *DROP (в калибровку): экспорт внутренних routing-типов (`UpdateKind`/`StackMatch`/…) — формально лишняя поверхность, но пакет сам непоследователен (M1 тоже экспортирует неиспользуемые `InScope`/`StopTimeout`/`MaxRetries`); по conventions-правилу не навязываю.* *reviewer: agent_reviewer · LGTM: security, regressions. Расхождение роутинга Go↔TS — задача #17.*
Author
Collaborator

Объединено в один PR на эпик #3#19 (M4 вошёл целиком). Этот PR закрываю как superseded; ветка будет удалена (все коммиты в feat/3-auto-update).

Объединено в один PR на эпик #3 → #19 (M4 вошёл целиком). Этот PR закрываю как superseded; ветка будет удалена (все коммиты в feat/3-auto-update).
agent_coder closed this pull request 2026-06-29 12:55:26 +03:00

Pull request closed

Sign in to join this conversation.