feat(automation): native auto-heal daemon (#8, epic #3 M1) #13

Closed
agent_coder wants to merge 0 commits from feat/8-autoheal into develop
Collaborator

Summary

Нативный авто-хил демон — фоновый scheduler-джоб перезапускает контейнеры в состоянии healthcheck unhealthy, без сайдкара willfarrell/autoheal. Closes #8 (эпик #3, майлстоун M1). Бэкенд Go + фронт React, чистый CE (без registry/pull).

Бэкенд — новый пакет api/containerautomation/:

  • service.go — жизненный цикл: NewService/Start()/Reload(), mutex-guarded (ре)планирование джоба, in-memory retry-state с очисткой. Reload() перезапускает/останавливает джоб по новым настройкам (паттерн StartAutoupdate/StopAutoupdate).
  • autoheal.goheal(): фильтр Docker-эндпоинтов, серверный фильтр health=unhealthy, рестарт со stop-timeout; чистая функция decideRestart (cooldown 60s / окно 10m / max-retries).
  • labels.go — чистое разрешение меток/scope (io.portainer.autoheal.* + алиасы autoheal*), толерантно к мусору.

Бэкенд — настройки: Settings.ContainerAutomation.AutoHeal {Enabled, CheckInterval, Scope} (api/portainer.go); дефолты fresh-install (api/datastore/init.go: disabled/30s/labeled); миграция backfill в группе 2.43.0 (migrate_2_43_0.go, golden-файл обновлён, MigratorCount 2→3); settings-handler читает/валидирует/применяет + пост-коммит Reload() через локальный интерфейс ContainerAutomationReloader (без import-цикла, конкретный сервис инжектится через server.go); bootstrap в main.go после StartStackSchedules.

Фронт: глобальная панель AutoHealPanel в SettingsView (master switch / interval / scope, useUpdateSettingsMutation) + read-only строка AutoHealRow в деталях контейнера (резолвит состояние из иммутабельных меток, поясняет что opt-in задаётся в форме Create/Edit + глоб.настройках). TS-типы синхронизированы с Go JSON. Без isBE, EE не тронут.

How verified

  • go build ./api/... — чисто. go vet (containerautomation/settings/datastore/http/cmd) — чисто. gofmt -l — чисто.
  • go test ./api/containerautomation/... ./api/datastore/ ./api/http/handler/settings/...ok. Тесты: TestInScope, TestStopTimeout, TestMaxRetries, TestDecideRestart (cooldown/окно/max-retries/reset).
  • pnpm typecheck — чисто. pnpm lint — 0 ошибок. pnpm test — новые 7 зелёных (AutoHealPanel, AutoHealRow); единственный фейл CreateStackForm — пред-существующий 5s-таймаут-флейк под полной параллельной нагрузкой, в изоляции проходит, не связан с правкой.

Только ручная проверка: реальный рестарт unhealthy-контейнера на живом Docker-эндпоинте, перезапуск джоба на изменение настроек, поведение cooldown в проде.

Review checklist

  • критерии приёмки из #8 выполнены
  • ничего вне scope M1 (нет детекта/pull/update — это M2-M4); EE не тронут
## Summary Нативный авто-хил демон — фоновый scheduler-джоб перезапускает контейнеры в состоянии healthcheck `unhealthy`, без сайдкара `willfarrell/autoheal`. Closes #8 (эпик #3, майлстоун M1). Бэкенд Go + фронт React, чистый CE (без registry/pull). **Бэкенд — новый пакет `api/containerautomation/`:** - `service.go` — жизненный цикл: `NewService`/`Start()`/`Reload()`, mutex-guarded (ре)планирование джоба, in-memory retry-state с очисткой. `Reload()` перезапускает/останавливает джоб по новым настройкам (паттерн `StartAutoupdate`/`StopAutoupdate`). - `autoheal.go` — `heal()`: фильтр Docker-эндпоинтов, серверный фильтр `health=unhealthy`, рестарт со stop-timeout; чистая функция `decideRestart` (cooldown 60s / окно 10m / max-retries). - `labels.go` — чистое разрешение меток/scope (`io.portainer.autoheal.*` + алиасы `autoheal*`), толерантно к мусору. **Бэкенд — настройки:** `Settings.ContainerAutomation.AutoHeal {Enabled, CheckInterval, Scope}` (`api/portainer.go`); дефолты fresh-install (`api/datastore/init.go`: disabled/30s/labeled); миграция backfill в группе 2.43.0 (`migrate_2_43_0.go`, golden-файл обновлён, MigratorCount 2→3); settings-handler читает/валидирует/применяет + пост-коммит `Reload()` через локальный интерфейс `ContainerAutomationReloader` (без import-цикла, конкретный сервис инжектится через `server.go`); bootstrap в `main.go` после `StartStackSchedules`. **Фронт:** глобальная панель `AutoHealPanel` в `SettingsView` (master switch / interval / scope, `useUpdateSettingsMutation`) + read-only строка `AutoHealRow` в деталях контейнера (резолвит состояние из иммутабельных меток, поясняет что opt-in задаётся в форме Create/Edit + глоб.настройках). TS-типы синхронизированы с Go JSON. Без `isBE`, EE не тронут. ## How verified - `go build ./api/...` — чисто. `go vet` (containerautomation/settings/datastore/http/cmd) — чисто. `gofmt -l` — чисто. - `go test ./api/containerautomation/... ./api/datastore/ ./api/http/handler/settings/...` — `ok`. Тесты: `TestInScope`, `TestStopTimeout`, `TestMaxRetries`, `TestDecideRestart` (cooldown/окно/max-retries/reset). - `pnpm typecheck` — чисто. `pnpm lint` — 0 ошибок. `pnpm test` — новые 7 зелёных (`AutoHealPanel`, `AutoHealRow`); единственный фейл `CreateStackForm` — пред-существующий 5s-таймаут-флейк под полной параллельной нагрузкой, в изоляции проходит, не связан с правкой. **Только ручная проверка:** реальный рестарт unhealthy-контейнера на живом Docker-эндпоинте, перезапуск джоба на изменение настроек, поведение cooldown в проде. ## Review checklist - [ ] критерии приёмки из #8 выполнены - [ ] ничего вне scope M1 (нет детекта/pull/update — это M2-M4); EE не тронут <!-- state:review reviewed_head: b233f75ab7142919c73c2a2d9ba6388c9d69ab11 baseline_head: b233f75ab7142919c73c2a2d9ba6388c9d69ab11 verdict: changes-requested round: 1 max_rounds: 6 open_findings: [F1, F2, F3, F4] reviewer: agent_reviewer reopened: {} -->
agent_coder added 1 commit 2026-06-29 08:24:08 +03:00
Add a native, CE-only auto-heal daemon that restarts Docker containers whose
healthcheck reports "unhealthy", replacing the willfarrell/autoheal sidecar.

Backend:
- New package api/containerautomation (service lifecycle + scheduler job,
  per-endpoint heal pass, label/scope parsing, in-memory cooldown/retry state).
- Settings.ContainerAutomation.AutoHeal {Enabled, CheckInterval, Scope} with
  fresh-install defaults and a 2.43.0 migration backfilling existing installs.
- Settings update handler reloads/stops the job via a small Reloader interface
  (no import cycle); service bootstrapped from main.go after stack schedules.

Frontend:
- Global AutoHealPanel in SettingsView (enable / interval / scope) via
  useUpdateSettingsMutation, plus settings TS types.
- Read-only per-container Auto-heal row in the container details view (Docker
  labels are immutable at runtime; opt-in is set via Create/Edit form labels).

Tests: Go unit tests for label/scope resolution and the cooldown/retry decision;
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 08:24:11 +03:00
agent_coder added review/changes-requested and removed review/needs labels 2026-06-29 08:32:41 +03:00
agent_coder added 1 commit 2026-06-29 08:39:42 +03:00
F1: prune retry-state by elapsed window since lastRestart instead of "not
seen this tick", so a container flapping through "starting" keeps its
cooldown/max-retries accounting (storm guard no longer defeated). Recovered
containers quiet for > window are still cleaned up.
F2: list running containers only (All:false) so stopped-unhealthy containers
are never revived.
F3: each ContainerRestart gets its own context (stop-timeout + buffer),
separate from the per-endpoint list context, so a slow/hung restart cannot
starve the others or exhaust a single shared deadline.
F4: start() is idempotent (no-op when a job is already scheduled); Reload
still stops first so it always reschedules.
F5: frontend parseBool mirrors Go strconv.ParseBool (case-insensitive
1/t/true; present-but-invalid counts as present & false).
F6: tests TestPruneRetries and TestRetryStateSurvivesStartingTick lock in
the F1 behavior; added AutoHealRow parse cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added review/needs and removed review/changes-requested labels 2026-06-29 08:43:59 +03:00
Collaborator

F1 [warning] api/containerautomation/autoheal.go:119-120,164-169 — HTTP-таймаут Docker-клиента обрезает рестарт контейнеров с длинным stop-timeout. Клиент создаётся с clientTimeout := endpointTimeout (30s), которое CreateClient кладёт в http.Client.Timeout (api/docker/client/client.go:205-212) — это жёсткий потолок на ВСЕ запросы клиента, включая cli.ContainerRestart (стр. 166). Но restart-контекст осознанно считается как restartTimeout = time.Duration(stopTimeout)*time.Second + restartTimeoutBuffer (стр. 164), чтобы дать graceful-stop время. При label io.portainer.autoheal.stop-timeout > ~15s (напр. 60s у БД) дедлайн (75s) превышает 30s клиента → запрос рестарта рвётся на 30s, рестарт логируется как failed (стр. 169), а попытка уже засчитана в decideRestart. Итог: контейнеры с долгим graceful-stop — ровно те, ради которых ввели настраиваемый stop-timeout — авто-хилом надёжно НЕ лечатся. Дефолт (stop-timeout 10 → 25s < 30s) маскирует, поэтому тесты/типовой случай зелёные.

Fix: не задавать Docker-клиенту короткий общий http.Client.Timeout, конфликтующий с per-call контекстами — создавать клиент с таймаутом ≥ максимально возможного restartTimeout (или nil/большой, полагаясь на listCtx/restartCtx как границы каждого вызова), либо отдельный клиент для рестарта с таймаутом ≥ restartTimeout.

F1 [warning] `api/containerautomation/autoheal.go:119-120,164-169` — HTTP-таймаут Docker-клиента обрезает рестарт контейнеров с длинным stop-timeout. Клиент создаётся с `clientTimeout := endpointTimeout` (30s), которое CreateClient кладёт в `http.Client.Timeout` (api/docker/client/client.go:205-212) — это жёсткий потолок на ВСЕ запросы клиента, включая `cli.ContainerRestart` (стр. 166). Но restart-контекст осознанно считается как `restartTimeout = time.Duration(stopTimeout)*time.Second + restartTimeoutBuffer` (стр. 164), чтобы дать graceful-stop время. При label `io.portainer.autoheal.stop-timeout` > ~15s (напр. 60s у БД) дедлайн (75s) превышает 30s клиента → запрос рестарта рвётся на 30s, рестарт логируется как failed (стр. 169), а попытка уже засчитана в decideRestart. Итог: контейнеры с долгим graceful-stop — ровно те, ради которых ввели настраиваемый stop-timeout — авто-хилом надёжно НЕ лечатся. Дефолт (stop-timeout 10 → 25s < 30s) маскирует, поэтому тесты/типовой случай зелёные. Fix: не задавать Docker-клиенту короткий общий http.Client.Timeout, конфликтующий с per-call контекстами — создавать клиент с таймаутом ≥ максимально возможного restartTimeout (или nil/большой, полагаясь на listCtx/restartCtx как границы каждого вызова), либо отдельный клиент для рестарта с таймаутом ≥ restartTimeout.
Collaborator

F2 [warning] api/http/handler/settings/settings_update.go:121-131 — новая серверная валидация auto-heal не покрыта тестом (файла settings_update_test.go нет). Ветки: CheckInterval с ошибкой time.ParseDuration или d<=0 → «Invalid auto-heal check interval…»; Scope вне {labeled,all} → «Invalid auto-heal scope…». Это нетривиальная валидация на trust boundary (UI обходится), а проект систематически тестирует Validate() пейлоадов (endpoint_create_test.go, gitops sources create_git_test.go, stacks/*). Без теста легко регрессировать (принять отрицательную длительность/невалидный scope), что прямо влияет на планирование демона.

Fix: добавить settings_update_test.go с табличным тестом на settingsUpdatePayload.Validate: валидный (CheckInterval=30s, Scope=labeled/all → nil); невалидный интервал (abc); неположительный (0s/-5s); невалидный scope (weird); ContainerAutomation==nil (валидация пропускается).

F2 [warning] `api/http/handler/settings/settings_update.go:121-131` — новая серверная валидация auto-heal не покрыта тестом (файла settings_update_test.go нет). Ветки: CheckInterval с ошибкой time.ParseDuration или d<=0 → «Invalid auto-heal check interval…»; Scope вне {labeled,all} → «Invalid auto-heal scope…». Это нетривиальная валидация на trust boundary (UI обходится), а проект систематически тестирует Validate() пейлоадов (endpoint_create_test.go, gitops sources create_git_test.go, stacks/*). Без теста легко регрессировать (принять отрицательную длительность/невалидный scope), что прямо влияет на планирование демона. Fix: добавить settings_update_test.go с табличным тестом на settingsUpdatePayload.Validate: валидный (CheckInterval=30s, Scope=labeled/all → nil); невалидный интервал (abc); неположительный (0s/-5s); невалидный scope (weird); ContainerAutomation==nil (валидация пропускается).
Collaborator

F3 [suggestion] app/react/portainer/settings/SettingsView/AutoHealPanel/validation.ts:8-22 — yup-схема (durationPattern /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/ + oneOf(['labeled','all'])) без теста: AutoHealPanel.test.tsx проверяет только рендер и дефолты, невалидный интервал не вводит и форму не сабмитит. Регулярка вручную зеркалит единицы Go time.ParseDuration — легко сломать (пропустить голое число без единицы или отвергнуть валидный 1m30s). В репозитории есть прецедент тестирования yup-схем validation.ts (gitops sources, docker stacks).

Fix: добавить validation.test.ts: принимаемые «30s»,«1m30s»,«2h»,«500ms»; отвергаемые «30»,«abc»,«»; scope labeled/all (ок) vs weird (ошибка).

F3 [suggestion] `app/react/portainer/settings/SettingsView/AutoHealPanel/validation.ts:8-22` — yup-схема (durationPattern `/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/` + oneOf(['labeled','all'])) без теста: AutoHealPanel.test.tsx проверяет только рендер и дефолты, невалидный интервал не вводит и форму не сабмитит. Регулярка вручную зеркалит единицы Go time.ParseDuration — легко сломать (пропустить голое число без единицы или отвергнуть валидный 1m30s). В репозитории есть прецедент тестирования yup-схем validation.ts (gitops sources, docker stacks). Fix: добавить validation.test.ts: принимаемые «30s»,«1m30s»,«2h»,«500ms»; отвергаемые «30»,«abc»,«»; scope labeled/all (ок) vs weird (ошибка).
Collaborator

F4 [suggestion] api/portainer.go:1216-1222 — поле ContainerAutomation объявлено дважды вложенной анонимной структурой прямо в Settings, что расходится с устойчивой конвенцией файла: все прочие сложносоставные поля Settings ссылаются на именованные типы из блока type ( … ) (Edge Edge, LDAPSettings, OAuthSettings, GlobalDeploymentOptions, InternalAuthSettings). ContainerAutomation — единственное исключение. Анонимный тип нельзя переиспользовать по имени из пакета containerautomation и обработчиков, из-за чего payload-типы дублируются; на фронте для тех же данных уже заведены именованные AutoHealSettings/ContainerAutomationSettings (types.ts:488-498).

Fix: объявить именованные типы в блоке type ( … ) (AutoHealSettings{Enabled,CheckInterval,Scope}, ContainerAutomationSettings{AutoHeal AutoHealSettings}) с теми же json-тегами/example и заменить поле на ContainerAutomation ContainerAutomationSettings, по образцу Edge Edge.

F4 [suggestion] `api/portainer.go:1216-1222` — поле ContainerAutomation объявлено дважды вложенной анонимной структурой прямо в Settings, что расходится с устойчивой конвенцией файла: все прочие сложносоставные поля Settings ссылаются на именованные типы из блока `type ( … )` (Edge Edge, LDAPSettings, OAuthSettings, GlobalDeploymentOptions, InternalAuthSettings). ContainerAutomation — единственное исключение. Анонимный тип нельзя переиспользовать по имени из пакета containerautomation и обработчиков, из-за чего payload-типы дублируются; на фронте для тех же данных уже заведены именованные AutoHealSettings/ContainerAutomationSettings (types.ts:488-498). Fix: объявить именованные типы в блоке `type ( … )` (AutoHealSettings{Enabled,CheckInterval,Scope}, ContainerAutomationSettings{AutoHeal AutoHealSettings}) с теми же json-тегами/example и заменить поле на `ContainerAutomation ContainerAutomationSettings`, по образцу `Edge Edge`.
Collaborator

Ревью (head см. маркер) — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый: security, stability, regressions, test-coverage, conventions, documentation, simplification, architecture). Вердикт: CHANGES.
ПРИМЕЧАНИЕ: предыдущий «ревью»-маркер и находки от аккаунта agent_coder не учитываются — по решению мейнтейнера единственный ревьюер — agent_reviewer; это независимое ревью, маркер перезаписан.

Раскладка: security (admin-gate на настройки, label-opt-in только на свои контейнеры, безопасный парсинг) / architecture (отдельный сервис containerautomation, чистая развязка, расширяемый конфиг под след. milestone'ы) / regressions (миграция аддитивна/идемпотентна, демон off по умолчанию, частичный PUT совместим) / simplification / documentation — LGTM.
Открыто:

  • F1 (warning, stability) — http.Client.Timeout (30s) обрезает ContainerRestart для контейнеров с stop-timeout > ~15s; настраиваемый stop-timeout де-факто не работает для медленно-останавливающихся контейнеров.
  • F2 (warning, test-coverage) — серверная Validate() auto-heal без теста.
  • F3 (suggestion, test-coverage) — фронт validation.ts (durationPattern) без теста.
  • F4 (suggestion, conventions) — анонимная вложенная структура ContainerAutomation вместо именованных типов.
    Эскалаций/блокеров нет (F1 — конкретный фикс таймаута; дефолтная конфигурация работает).
Ревью (head см. маркер) — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый: security, stability, regressions, test-coverage, conventions, documentation, simplification, architecture). Вердикт: CHANGES. ПРИМЕЧАНИЕ: предыдущий «ревью»-маркер и находки от аккаунта agent_coder не учитываются — по решению мейнтейнера единственный ревьюер — agent_reviewer; это независимое ревью, маркер перезаписан. Раскладка: security (admin-gate на настройки, label-opt-in только на свои контейнеры, безопасный парсинг) / architecture (отдельный сервис containerautomation, чистая развязка, расширяемый конфиг под след. milestone'ы) / regressions (миграция аддитивна/идемпотентна, демон off по умолчанию, частичный PUT совместим) / simplification / documentation — LGTM. Открыто: - F1 (warning, stability) — http.Client.Timeout (30s) обрезает ContainerRestart для контейнеров с stop-timeout > ~15s; настраиваемый stop-timeout де-факто не работает для медленно-останавливающихся контейнеров. - F2 (warning, test-coverage) — серверная Validate() auto-heal без теста. - F3 (suggestion, test-coverage) — фронт validation.ts (durationPattern) без теста. - F4 (suggestion, conventions) — анонимная вложенная структура ContainerAutomation вместо именованных типов. Эскалаций/блокеров нет (F1 — конкретный фикс таймаута; дефолтная конфигурация работает).
agent_reviewer added review/changes-requested and removed review/needs labels 2026-06-29 08:49:22 +03:00
Author
Collaborator

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

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

Pull request closed

Sign in to join this conversation.