Reference in New Issue
Block a user
Delete Branch "feat/11-autoupdate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.resolveContainerUpdateRouting(labels, stackLookup)(Go-аналог TSresolveContainerUpdatePathиз M3) +groupContainersForUpdate(дедуп стеков) +InUpdateScope/IsMonitorOnly(меткиio.portainer.update.enable/monitor-only+ watchtower-алиасы) — чистые, юнит-тестируемые.service.go— запускает оба джоба (heal+update) по настройкам,Reload()пере-планирует оба;NewServiceрасширен (containerService,stackDeployer,gitService).ContainerAutomation.AutoUpdate {Enabled, PollInterval, Scope, Cleanup}(api/portainer.go), дефолты (init.go: disabled/6h/labeled/false), backfill в M1-миграции (golden обновлён), валидация в settings_update.dataStore.Registry().ReadAll()(admin-путь, демон — системный актор без user-контекста); движок шлёт креды сам.Cleanup=on— НЕфорсированныйImageRemoveстарого образа (Docker сам откажет, если образ тегирован/используется → удаляется только реально dangling).Фронт: панель
AutoUpdatePanelв SettingsView (master switch/interval/scope/cleanup) + read-onlyAutoUpdateRowв деталях контейнера (метки иммутабельны, как 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.
Известные ограничения (документированы в коде)
Review checklist
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>Ревью M4 «Native auto-update daemon» — agent_reviewer
Полный веер 8 аспектов (security / stability / regressions / test-coverage / conventions / documentation / simplification / architecture) против базы стека
origin/feat/8-autoheal...(headb3ae5f3).Демон сделан крепко: точно зеркалит M1-autoheal (раздельные
healJobID/updateJobID, независимый overlap-guardupdateRunningс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. Они уже расходятся:
stackLookupForEndpoint(autoupdate.go:171) пропускает не-DockerComposeStack→ одноимённый swarm/k8s-стек даётexternal; TSresolveContainerUpdatePath.tsищет только поName+EndpointIdбез фильтраType→ тот же контейнер даётstack+redeploy. Для compose-контейнера с именем проекта, совпавшим с одноимённым swarm-стеком, ручной апдейт редеплоит, а демон пропускает — разное действие на одном объекте.IsGit: st.WorkflowID != 0(канон —Stack.GitConfigпомеченDeprecatedвportainer.go:1303-1308); TSisGitStack: !!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дублирует существующийparseEnableboolLabel(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:145label="Remove dangling old images after update"— ноcleanupOldImageзовётся только изupdateStandalone; стек-redeploy идётprune=false(зафиксировано в godocapi/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 — раунд 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 прошлого rawRegistry().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-placeContainerRestartс тем же 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всё ещё передаёт его. При этом godocNewService(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; поправить godocservice.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:147label="Remove dangling old images after update"— но cleanup пробрасывается только вupdateStandalone; стек-redeploy его не получает (api/portainer.go:1226, godoccleanupOldImage— «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.
Объединено в один PR на эпик #3 → #19 (M4 вошёл целиком). Этот PR закрываю как superseded; ветка будет удалена (все коммиты в feat/3-auto-update).
Pull request closed