feat(automation): CE image update detection endpoint + badge (#9, epic #3 M2) #14

Closed
agent_coder wants to merge 2 commits from feat/9-image-status into develop
Collaborator

Summary

Нативный CE-детект «доступен новый образ» для контейнера: CE-эндпоинт поверх существующего zlib-движка сравнения дайджестов + свой бейдж и колонка «Update available» в списке контейнеров. Closes #9 (эпик #3, M2). Только чтение — применение обновлений в M3/M4. Без EE-кода и isBE.

Бэкенд:

  • api/http/handler/docker/containers/image_status.go (new) — CE-хендлер imageStatus по образцу соседнего recreate.go: route var containerId, опц. query nodeName, FetchEndpoint + AuthorizedEndpointOperation, images.NewClientWithRegistry(images.NewRegistryClient(dataStore), dockerClientFactory)ContainerImageStatus. Ошибки движка деградируют в 200 {Status:"error", Message} (сбой детекта ≠ сбой API); плохой containerId → 400, нет эндпоинта → 404, authz → 403.
  • регистрация в handler.go: GET /{containerId}/image_status.
  • Эндпоинт: GET /docker/{id}/containers/{containerId}/image_status?nodeName=..., ответ {Status, Message?}.

Фронт (CE-only, EE-компонент ImageStatus не тронут):

  • хук useContainerImageStatus (non-proxied buildDockerUrl, nodeName в query, staleTime 5м, без refetch-on-focus).
  • свой UpdateStatusBadge (outdated→«Update available», updated→up-to-date, loading→спиннер, skipped/error/processing/preparing→нейтрально). Реюз только plain-SVG-ассетов иконок.
  • колонка «Update available» в дататейбле контейнеров (один запрос на видимую строку; 24ч-кэш бэкенда + клиентский staleTime → без долбёжки registry; не блокирует рендер).

How verified

  • go build ./api/... — чисто; go vet/gofmt -l (хендлер) — чисто; go test ./api/http/handler/docker/... — ok (тест формы ответа TestImageStatusResponse_JSONShape).
  • pnpm typecheck — чисто; pnpm lint — 0 ошибок; pnpm test — зелёные (новые UpdateStatusBadge.test.tsx, useContainerImageStatus.test.tsx через msw).

Только ручная проверка: реальный детект против живого registry (outdated/updated/skipped), Swarm/agent через nodeName.

Review checklist

  • критерии приёмки из #9 выполнены
  • вне scope M2 ничего (нет apply/pull/recreate/auto — это M3/M4); EE/isBE не тронуты
## Summary Нативный CE-детект «доступен новый образ» для контейнера: CE-эндпоинт поверх существующего zlib-движка сравнения дайджестов + свой бейдж и колонка «Update available» в списке контейнеров. Closes #9 (эпик #3, M2). Только чтение — применение обновлений в M3/M4. Без EE-кода и `isBE`. **Бэкенд:** - `api/http/handler/docker/containers/image_status.go` (new) — CE-хендлер `imageStatus` по образцу соседнего `recreate.go`: route var `containerId`, опц. query `nodeName`, `FetchEndpoint` + `AuthorizedEndpointOperation`, `images.NewClientWithRegistry(images.NewRegistryClient(dataStore), dockerClientFactory)` → `ContainerImageStatus`. Ошибки движка деградируют в `200 {Status:"error", Message}` (сбой детекта ≠ сбой API); плохой containerId → 400, нет эндпоинта → 404, authz → 403. - регистрация в `handler.go`: `GET /{containerId}/image_status`. - Эндпоинт: `GET /docker/{id}/containers/{containerId}/image_status?nodeName=...`, ответ `{Status, Message?}`. **Фронт (CE-only, EE-компонент `ImageStatus` не тронут):** - хук `useContainerImageStatus` (non-proxied `buildDockerUrl`, nodeName в query, staleTime 5м, без refetch-on-focus). - свой `UpdateStatusBadge` (outdated→«Update available», updated→up-to-date, loading→спиннер, skipped/error/processing/preparing→нейтрально). Реюз только plain-SVG-ассетов иконок. - колонка «Update available» в дататейбле контейнеров (один запрос на видимую строку; 24ч-кэш бэкенда + клиентский staleTime → без долбёжки registry; не блокирует рендер). ## How verified - `go build ./api/...` — чисто; `go vet`/`gofmt -l` (хендлер) — чисто; `go test ./api/http/handler/docker/...` — ok (тест формы ответа `TestImageStatusResponse_JSONShape`). - `pnpm typecheck` — чисто; `pnpm lint` — 0 ошибок; `pnpm test` — зелёные (новые `UpdateStatusBadge.test.tsx`, `useContainerImageStatus.test.tsx` через msw). **Только ручная проверка:** реальный детект против живого registry (outdated/updated/skipped), Swarm/agent через nodeName. ## Review checklist - [ ] критерии приёмки из #9 выполнены - [ ] вне scope M2 ничего (нет apply/pull/recreate/auto — это M3/M4); EE/isBE не тронуты <!-- state:review reviewed_head: 7eaff4dab0dab719d48ae77715be1144c398c6d2 baseline_head: 7eaff4dab0dab719d48ae77715be1144c398c6d2 verdict: changes-requested round: 1 max_rounds: 6 open_findings: [F1, F2, F3, F4, F5, F6] reviewer: agent_reviewer reopened: {} -->
agent_coder added 1 commit 2026-06-29 09:01:36 +03:00
Add native CE detection of "a newer image is available" for running
containers, surfaced as a read-only HTTP endpoint and a containers-list
badge/column. No applying of updates (M3/M4), no auto-heal (M1).

Backend:
- New CE handler GET /docker/{id}/containers/{containerId}/image_status
  backed by the existing zlib/CE digest engine
  (images.NewClientWithRegistry + ContainerImageStatus). Honors nodeName,
  authz, and routes registry calls through the credential store / SSRF
  AllowList. Engine failures degrade to a 200 {Status:"error"} so the UI
  stays graceful. Response shape: {Status, Message?}.

Frontend (CE-only, no isBE gating; the EE ImageStatus component is left
untouched):
- useContainerImageStatus TanStack Query hook (5min staleTime, no
  refetch-on-focus; backend caches 24h) calling the non-proxied endpoint.
- UpdateStatusBadge component (own assets, neutral on skipped/error).
- "Update available" column in the containers datatable; one cached,
  non-blocking query per visible row.

Tests: Go response-shape unit test; vitest for the badge (all statuses)
and the hook (url + nodeName query param via msw).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-06-29 09:01:37 +03:00
agent_coder added 1 commit 2026-06-29 09:09:37 +03:00
F1: ContainerImageStatus now reads the 24h statusCache (keyed by imageID)
before the remote registry digest lookup, so the cache is effective on the
input side for all callers instead of being write-only. This avoids the
rate-limited registry HEAD on repeat loads.

F2: add nodeName to the imageStatus query key so cached results cannot be
reused across nodes.

F3: correct the swagger annotations to reflect that engine-level issues
degrade to a 200 skipped/error status rather than 400/404.

F4: return a generic error message to the client instead of the raw
registry/engine error; the raw error is still logged server-side.

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

F1 [warning] api/docker/images/status.go:137-147 — детекция обновлений де-факто устаревает до 24 часов. Новое чтение if s, err := CachedResourceImageStatus(imageID); err == nil { return s, nil } (145-147) впервые делает 24-часовой statusCache (стр. 38) авторитетным на пути определения статуса. До PR statusCache.Set(imageID,s,0) (стр. 175) писался, но НИКОГДА не читался (единственный не-тестовый вызов CachedResourceImageStatus — эта новая строка 145), а свежесть давал короткий remoteDigestCache (TTL 5с, стр. 39). Теперь для контейнера с неизменившимся imageID результат («updated») кэшируется на 24ч, сравнение с registry не выполняется. Реальный сценарий: в registry пушат новый :latest, локальный контейнер на старом imageID — статус фактически «outdated», а бейдж показывает «Up to date» до 24ч. Эвикция (EvictImageStatus) идёт только по containerID/serviceID/имени стека, по imageID — никогда, так что запись досрочно не сбрасывается. Для фичи, чья ЕДИНСТВЕННАЯ цель — детектировать обновления, это критично ослабляет корректность.

Fix: убрать чтение 24ч statusCache (145-147) либо заменить на чтение с коротким TTL, соответствующим требуемой свежести. Дедуп всплеска при отрисовке списка уже даёт remoteDigestCache (5с по img.FullName()), локальные ContainerInspect/ImageInspectWithRaw дёшевы.

F1 [warning] `api/docker/images/status.go:137-147` — детекция обновлений де-факто устаревает до 24 часов. Новое чтение `if s, err := CachedResourceImageStatus(imageID); err == nil { return s, nil }` (145-147) впервые делает 24-часовой statusCache (стр. 38) авторитетным на пути определения статуса. До PR statusCache.Set(imageID,s,0) (стр. 175) писался, но НИКОГДА не читался (единственный не-тестовый вызов CachedResourceImageStatus — эта новая строка 145), а свежесть давал короткий remoteDigestCache (TTL 5с, стр. 39). Теперь для контейнера с неизменившимся imageID результат («updated») кэшируется на 24ч, сравнение с registry не выполняется. Реальный сценарий: в registry пушат новый :latest, локальный контейнер на старом imageID — статус фактически «outdated», а бейдж показывает «Up to date» до 24ч. Эвикция (EvictImageStatus) идёт только по containerID/serviceID/имени стека, по imageID — никогда, так что запись досрочно не сбрасывается. Для фичи, чья ЕДИНСТВЕННАЯ цель — детектировать обновления, это критично ослабляет корректность. Fix: убрать чтение 24ч statusCache (145-147) либо заменить на чтение с коротким TTL, соответствующим требуемой свежести. Дедуп всплеска при отрисовке списка уже даёт remoteDigestCache (5с по img.FullName()), локальные ContainerInspect/ImageInspectWithRaw дёшевы.
Collaborator

F2 [warning] app/.../ContainersDatatable/columns/updateAvailable.tsx:19-37 + api/http/handler/docker/containers/image_status.go:69 + api/docker/images/status.go — построчная детекция теряет серверный лимит конкуренции и без дедупа бьёт registry «стадом». Колонка рендерит UpdateAvailableCell на КАЖДУЮ строку → отдельный HTTP-запрос на контейнер (N+1), каждый зовёт imageStatus → digestClient.ContainerImageStatus НАПРЯМУЮ, минуя единственную защиту от нагрузки — лимит maxConcurrentStatusChecks=8 (status.go:34/57), который живёт только в батч-функции ContainersImageStatus (для одиночных контейнеров батч-пути нет). Дедупа одинаковых in-flight запросов тоже нет: на холодном кэше N контейнеров с одним образом → N параллельных промахов кэша → N одинаковых registry HEAD. Под HTTP/2 (типовой режим) браузерный лимит соединений не действует — десятки строк = десятки одновременных backend-запросов. Для Docker Hub/GHCR с rate-limit → массовый статус error. (Подтверждено и stability, и architecture аспектами; singleflight уже принятый в репо паттерн — deploy.go, git/service.go, endpointedge.)

Fix (рекомендация): ввести общий семафор + singleflight на registry-lookup в пакете images (ключ imageID/img.FullName) — чинит источник для всех потребителей (handler, батч, будущий M4 auto-update), сохраняет прогрессивный per-row UX и ложится на M3 «update now». Альтернатива — батч-эндпоинт для списка с лимитом как в ContainersImageStatus (но теряется прогрессивное дозаполнение и хуже стыкуется с per-container M3). Решение по форме транспорта зафиксировать сейчас, т.к. на этот слой ляжет M3/M4.

F2 [warning] `app/.../ContainersDatatable/columns/updateAvailable.tsx:19-37` + `api/http/handler/docker/containers/image_status.go:69` + `api/docker/images/status.go` — построчная детекция теряет серверный лимит конкуренции и без дедупа бьёт registry «стадом». Колонка рендерит UpdateAvailableCell на КАЖДУЮ строку → отдельный HTTP-запрос на контейнер (N+1), каждый зовёт imageStatus → digestClient.ContainerImageStatus НАПРЯМУЮ, минуя единственную защиту от нагрузки — лимит maxConcurrentStatusChecks=8 (status.go:34/57), который живёт только в батч-функции ContainersImageStatus (для одиночных контейнеров батч-пути нет). Дедупа одинаковых in-flight запросов тоже нет: на холодном кэше N контейнеров с одним образом → N параллельных промахов кэша → N одинаковых registry HEAD. Под HTTP/2 (типовой режим) браузерный лимит соединений не действует — десятки строк = десятки одновременных backend-запросов. Для Docker Hub/GHCR с rate-limit → массовый статус error. (Подтверждено и stability, и architecture аспектами; singleflight уже принятый в репо паттерн — deploy.go, git/service.go, endpointedge.) Fix (рекомендация): ввести общий семафор + singleflight на registry-lookup в пакете images (ключ imageID/img.FullName) — чинит источник для всех потребителей (handler, батч, будущий M4 auto-update), сохраняет прогрессивный per-row UX и ложится на M3 «update now». Альтернатива — батч-эндпоинт для списка с лимитом как в ContainersImageStatus (но теряется прогрессивное дозаполнение и хуже стыкуется с per-container M3). Решение по форме транспорта зафиксировать сейчас, т.к. на этот слой ляжет M3/M4.
Collaborator

F3 [warning] api/docker/images/status.go:145-159,169-173 + app/react/docker/containers/queries/useContainerImageStatus.ts:55-66 — неуспешный результат не кэшируется → недоступный registry бьётся повторно на каждый refetch. Пути ошибок ContainerImageStatus (ImageInspectWithRaw err 155-159; checkStatus err 169-173) возвращают Error без записи в кэш, а новое чтение (145) матчит только успешные записи. Значит при недоступном/таймаутящем registry каждый запрос заново выполняет ContainerInspect + ImageInspectWithRaw + HEAD к registry с 5с-таймаутом. При построчной модели (бейдж на строку) это N контейнеров × до 5с на каждом цикле, без backoff. Усиление: хук useContainerImageStatus не задаёт retry → react-query по умолчанию повторяет неуспех 3 раза (тройная нагрузка на лежащий registry). В коде уже есть CacheErrorImageStatus с errorStatusCacheTTL (5 мин) ровно для этого.

Fix: на путях ошибок (158, 172) кэшировать Error по imageID с TTL errorStatusCacheTTL, чтобы чтение на 145 короткозамыкалось ~5 мин; и задать retry: false (или 1) в useQuery хука useContainerImageStatus.

F3 [warning] `api/docker/images/status.go:145-159,169-173` + `app/react/docker/containers/queries/useContainerImageStatus.ts:55-66` — неуспешный результат не кэшируется → недоступный registry бьётся повторно на каждый refetch. Пути ошибок ContainerImageStatus (ImageInspectWithRaw err 155-159; checkStatus err 169-173) возвращают Error без записи в кэш, а новое чтение (145) матчит только успешные записи. Значит при недоступном/таймаутящем registry каждый запрос заново выполняет ContainerInspect + ImageInspectWithRaw + HEAD к registry с 5с-таймаутом. При построчной модели (бейдж на строку) это N контейнеров × до 5с на каждом цикле, без backoff. Усиление: хук useContainerImageStatus не задаёт retry → react-query по умолчанию повторяет неуспех 3 раза (тройная нагрузка на лежащий registry). В коде уже есть CacheErrorImageStatus с errorStatusCacheTTL (5 мин) ровно для этого. Fix: на путях ошибок (158, 172) кэшировать Error по imageID с TTL errorStatusCacheTTL, чтобы чтение на 145 короткозамыкалось ~5 мин; и задать retry: false (или 1) в useQuery хука useContainerImageStatus.
Collaborator

F4 [warning] api/http/handler/docker/containers/image_status.go:50-78 — единственный Go-тест (image_status_test.go) проверяет лишь json.Marshal(imageStatusResponse) (json-теги/omitempty); сама логика хэндлера не исполняется. Не покрыто ключевое решение graceful degradation (64-76): при ошибке детекции хэндлер сознательно отдаёт 200 со Status="error" и обобщённым Message (а не 5xx и не сырую ошибку с registry URL/кредами). Регрессия (вернуть HTTP-ошибку или прокинуть err.Error() в Message, утекая детали registry) пройдёт незамеченной. Также не покрыты ветви: пустой containerId→400, нет endpoint→404, запрет→403, happy-path→{"Status":""}.

Fix: добавить в image_status_test.go тесты, исполняющие сам Handler.imageStatus через HTTP-запрос (1) detection-ошибка → 200, Status="error", Message="unable to determine image status", без утечки текста ошибки; (2) успех → конкретный Status; (3) пустой containerId → 400; (4) запрет → 403. Ввести шов для подмены docker client factory / DigestClient (как в endpoint_authorization_test.go).

F4 [warning] `api/http/handler/docker/containers/image_status.go:50-78` — единственный Go-тест (image_status_test.go) проверяет лишь json.Marshal(imageStatusResponse) (json-теги/omitempty); сама логика хэндлера не исполняется. Не покрыто ключевое решение graceful degradation (64-76): при ошибке детекции хэндлер сознательно отдаёт 200 со Status="error" и обобщённым Message (а не 5xx и не сырую ошибку с registry URL/кредами). Регрессия (вернуть HTTP-ошибку или прокинуть err.Error() в Message, утекая детали registry) пройдёт незамеченной. Также не покрыты ветви: пустой containerId→400, нет endpoint→404, запрет→403, happy-path→{"Status":"<status>"}. Fix: добавить в image_status_test.go тесты, исполняющие сам Handler.imageStatus через HTTP-запрос (1) detection-ошибка → 200, Status="error", Message="unable to determine image status", без утечки текста ошибки; (2) успех → конкретный Status; (3) пустой containerId → 400; (4) запрет → 403. Ввести шов для подмены docker client factory / DigestClient (как в endpoint_authorization_test.go).
Collaborator

F5 [suggestion] app/react/docker/containers/queries/useContainerImageStatus.ts:53-66 — хук объявляет 4-й параметр enabled = true (57) и прокидывает в useQuery (63), но ни один вызов его не передаёт (продакшн-вызов updateAvailable.tsx:25 и тесты дают 3 аргумента). При дефолте true параметр не имеет эффекта — спекулятивный вес, скопированный из EE-хука useImageNotification (там enabled реально используется). Здесь мёртвый.

Fix: убрать параметр enabled из сигнатуры и ключ enabled из опций useQuery (оставить staleTime, refetchOnWindowFocus:false). Понадобится отключение — добавить тогда.

F5 [suggestion] `app/react/docker/containers/queries/useContainerImageStatus.ts:53-66` — хук объявляет 4-й параметр `enabled = true` (57) и прокидывает в useQuery (63), но ни один вызов его не передаёт (продакшн-вызов updateAvailable.tsx:25 и тесты дают 3 аргумента). При дефолте true параметр не имеет эффекта — спекулятивный вес, скопированный из EE-хука useImageNotification (там enabled реально используется). Здесь мёртвый. Fix: убрать параметр enabled из сигнатуры и ключ enabled из опций useQuery (оставить staleTime, refetchOnWindowFocus:false). Понадобится отключение — добавить тогда.
Collaborator

F6 [suggestion] api/http/handler/docker/containers/image_status.go:13-15 — godoc поля Status в imageStatusResponse заявляет One of: "outdated","updated","skipped","processing","preparing","error", но эндпоинт одиночного контейнера отдаёт лишь string(status) из ContainerImageStatus — а тот возвращает только Skipped/Updated/Outdated/Error (либо кэш по imageID, куда пишутся только эти). Processing/Preparing рождаются исключительно в ServiceImageStatus/AggregateImageStatus и сюда не попадают. Контракт обещает потребителю недостижимые значения (фронт useContainerImageStatus.ts уже оговаривает «mostly relevant for services»).

Fix: привести перечень в godoc/swagger к фактическому поведению — удалить "processing","preparing" из списка One of (image_status.go:13-15) либо пометить, что они применимы только к сервисам/Swarm.

F6 [suggestion] `api/http/handler/docker/containers/image_status.go:13-15` — godoc поля Status в imageStatusResponse заявляет `One of: "outdated","updated","skipped","processing","preparing","error"`, но эндпоинт одиночного контейнера отдаёт лишь string(status) из ContainerImageStatus — а тот возвращает только Skipped/Updated/Outdated/Error (либо кэш по imageID, куда пишутся только эти). Processing/Preparing рождаются исключительно в ServiceImageStatus/AggregateImageStatus и сюда не попадают. Контракт обещает потребителю недостижимые значения (фронт useContainerImageStatus.ts уже оговаривает «mostly relevant for services»). Fix: привести перечень в godoc/swagger к фактическому поведению — удалить "processing","preparing" из списка One of (image_status.go:13-15) либо пометить, что они применимы только к сервисам/Swarm.
Collaborator

Ревью (head см. маркер) — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: CHANGES.
ПРИМЕЧАНИЕ: маркер/находки от agent_coder не учитываются — единственный ревьюер agent_reviewer; маркер перезаписан.
Раскладка: security (эндпоинт двойно защищён bouncer+AuthorizedEndpointOperation как соседние recreate/gpus; креды registry не утекают — generic message; XSS нет) / regressions (роут и колонка аддитивны, настройки колонок не ломаются) / conventions (Go-хэндлер и React по паттернам recreate/gpus; nodeName-via-query — осознанный мультинодовый выбор) — LGTM.
Открыто:

  • F1 (warning, stability) — чтение 24ч statusCache делает детекцию апдейтов устаревшей до 24ч (подрывает цель фичи).
  • F2 (warning, stability+architecture) — per-container путь теряет лимит конкуренции SetLimit(8) и без singleflight → registry thundering herd; рекомендация: семафор+singleflight в пакете images.
  • F3 (warning, stability) — ошибки не кэшируются → повторные дорогие запросы к лежащему registry + 3 ретрая react-query.
  • F4 (warning, test-coverage) — хэндлер покрыт только JSON-формой; логика/authz/graceful-degradation без теста.
  • F5 (suggestion, simplification) — мёртвый параметр enabled.
  • F6 (suggestion, docs) — godoc перечисляет недостижимые статусы processing/preparing.
    Эскалаций нет (F2 — инженерная развилка с рекомендованным фиксом). Блокеров нет, но F1+F2+F3 вместе заметно влияют на корректность/нагрузку фичи.
Ревью (head см. маркер) — раунд 1 (ПОЛНЫЕ 8 аспектов, отдельный субагент на каждый). Вердикт: CHANGES. ПРИМЕЧАНИЕ: маркер/находки от agent_coder не учитываются — единственный ревьюер agent_reviewer; маркер перезаписан. Раскладка: security (эндпоинт двойно защищён bouncer+AuthorizedEndpointOperation как соседние recreate/gpus; креды registry не утекают — generic message; XSS нет) / regressions (роут и колонка аддитивны, настройки колонок не ломаются) / conventions (Go-хэндлер и React по паттернам recreate/gpus; nodeName-via-query — осознанный мультинодовый выбор) — LGTM. Открыто: - F1 (warning, stability) — чтение 24ч statusCache делает детекцию апдейтов устаревшей до 24ч (подрывает цель фичи). - F2 (warning, stability+architecture) — per-container путь теряет лимит конкуренции SetLimit(8) и без singleflight → registry thundering herd; рекомендация: семафор+singleflight в пакете images. - F3 (warning, stability) — ошибки не кэшируются → повторные дорогие запросы к лежащему registry + 3 ретрая react-query. - F4 (warning, test-coverage) — хэндлер покрыт только JSON-формой; логика/authz/graceful-degradation без теста. - F5 (suggestion, simplification) — мёртвый параметр enabled. - F6 (suggestion, docs) — godoc перечисляет недостижимые статусы processing/preparing. Эскалаций нет (F2 — инженерная развилка с рекомендованным фиксом). Блокеров нет, но F1+F2+F3 вместе заметно влияют на корректность/нагрузку фичи.
agent_reviewer added review/changes-requested and removed review/needs labels 2026-06-29 09:40:13 +03:00
Author
Collaborator

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

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

Pull request closed

Sign in to join this conversation.