feat(automation): входящий registry-push вебхук для мгновенного автообновления (#32) #33
Reference in New Issue
Block a user
Delete Branch "feat/32-registry-webhook"
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
Входящий вебхук
POST /api/webhooks/container-automation/{token}, который немедленно запускает полный проход авто-апдейта — вместо ожидания поллинга (до 6ч). Реестро-агностичный «общий пинок»: payload не разбирается, проход сам сверяет digest'ы и обновляет только устаревшее. Поллинг остаётся как fallback. closes #32Service.TriggerUpdate(): kick-канал (буфер 1, неблокирующий send) + воркер-горутина наbaseCtx, дебаунс ~10с (серия доставок multi-arch/multi-tag пуша → один проход).update()разбит наrunUpdatePass() bool— возвращает false, когда CASupdateRunningне взят; воркер бэкоффит и перезапускает, так что пинок, пришедший во время планового прохода, не теряется. Поллинг-таймер зовёт тот же проход.recover()(паритет с поллингом, который подcron.Recover), чтобы паника в проходе не роняла демон из этой голой горутины.runUpdatePassтакже перепроверяетAutoUpdate.Enabled(defense-in-depth против выключения в окне дебаунса).bouncer.PublicAccess(не коллизирует с/webhooks/{token}— разное число сегментов); токен сравниваетсяsubtle.ConstantTimeCompare; пустой/несовпавший → 404, выключенный авто-апдейт → 409, успех → 202. Токен генерится сервером (uuid) через действияRegenerateWebhookToken/ClearWebhookToken; от клиента не принимается; не входит в/settings/public(в admin GET есть — для UI).AutoUpdatePanel(readonly URL, копи-кнопка, Generate/Regenerate, Clear, подсказка про настройку реестра).Настройка на стороне реестра (для доки)
{origin}/api/webhooks/container-automation/{token}.registry_package(repo/org) на URL триггера, ЛИБО шагcurl -X POST {url}в конце workflow, публикующего образ.How verified
На стенде (Go 1.26.4):
go build ./...→ 0;go vet ./api/containerautomation/...→ 0;gofmtчисто.go test ./api/containerautomation/... ./api/http/handler/webhooks/... ./api/http/handler/settings/...→ все зелёные.go test -race ./api/containerautomation/...→ без гонок.tsc --noEmit→ 0;vitest AutoUpdatePanel.test.tsx→ 6 passed.Внутренний цикл: 1 проход ревью, адверсариальная сверка concurrency-ядра (kick-loss/CAS-release/shutdown) и токен-безопасности (пустой токен не матчится, токен не утекает в /settings/public) — критики нет. По WARNING добавил
recover()в воркер (паритет паник с поллингом) + тесты: паника в проходе не роняет воркер, kick во время идущего прохода буферизуется и отрабатывает на следующей итерации; по SUGGESTION — перепроверка Enabled.⚠️ Ручной QA за человеком: реальный end-to-end пуш в Gitea/GHCR → обновление контейнера за ~минуту; визуал секции AutoUpdatePanel в живом UI.
Checklist
Adds POST /api/webhooks/container-automation/{token} which kicks a full auto-update pass immediately, instead of waiting up to PollInterval (6h) for the poll. Registry-agnostic 'general kick': the payload is not parsed; the pass compares digests and updates only stale containers. Polling stays as a fallback. - Service.TriggerUpdate(): a buffer-1 non-blocking kick channel + a worker goroutine on baseCtx that debounces ~10s (collapsing a multi-arch/multi-tag push burst into one pass). update() is split into runUpdatePass() bool, which returns false when the updateRunning CAS is not acquired; the worker backs off and re-runs so a kick landing during a scheduled pass is never lost. The poll timer keeps calling the same pass. - Worker panics are recovered (poll parity — the poll path runs under the scheduler's cron.Recover), so a panic in the pass cannot crash the daemon from this bare goroutine. runUpdatePass also re-checks AutoUpdate.Enabled as defense-in-depth against a disable during the debounce window. - Route via bouncer.PublicAccess (distinct from /webhooks/{token} by segment count); token compared with subtle.ConstantTimeCompare; empty/mismatched token -> 404, disabled auto-update -> 409, success -> 202. Token is server-generated (uuid) via RegenerateWebhookToken / ClearWebhookToken settings actions; never accepted from the client; excluded from /settings/public (present in admin GET for the UI). - Frontend: an AutoUpdatePanel 'Update trigger webhook' section (readonly URL, copy, Generate/Regenerate, Clear, registry-setup hint). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Открыл PR по #32 (registry-push вебхук). Внутренний цикл: 1 проход, адверсариальная сверка concurrency (kick не теряется, CAS освобождается на всех путях вкл. панику, shutdown чист) + токен-безопасность (пустой токен не матчится, не утекает в /settings/public) — критики нет. По ревью добавил recover() в воркер (паритет паник с поллинг-путём под cron.Recover) + Enabled-перепроверку + тесты (паника не роняет воркер, буферизованный kick). go build/vet/test/-race зелёные, фронт tsc 0 / vitest 6. Registry-setup в теле PR. Реальный пуш-QA — за тобой.
Ревью — #33 (feat(automation): входящий registry-push вебхук для мгновенного автообновления, #32), round 1. Вердикт: CHANGES
Сильная, аккуратно написанная фича — веер 9 аспектов сошёлся, 6 LGTM. Security LGTM (auth solid:
subtle.ConstantTimeCompare; guardexpected == ""→ 404 ДО матча, ключуется от server-state не от инпута — пустой токен никогда не матчит; токен server-minteduuid.NewRandom(), от клиента не принимается; НЕ в/settings/public— там allowlist-structpublicSettingsResponse,WebhookTokenструктурно отсутствует; route-коллизии нет — 2 сегмента vs 1 у/webhooks/{token}; DoS ограничен — невалидный токен = 1 дешёвый read + const-time reject, валидный = буфер-1 kick + дебаунс + CAS). Stability LGTM (все 6 concurrency-проб звучны: kick-loss-инвариант держится, CAS релизитсяdeferдаже на панике, shutdown поbaseCtx.Done()чист без leak/send-on-closed,recover()per-iteration переживает панику, дебаунс-таймер fresh-per-call без reset-бага, Enabled-recheck из datastore не гонка). Architecture/Simplification/Regressions/Documentation LGTM (generic-kick + payload-not-parsed — верный seam; воркер в Service — правильный дом; public-token-gated консистентно с существующими вебхуками Portainer; поллинг не тронут; доки/свагер точны). Открыто 4 (2 warning + 2 low). Эскалации нет.Открыто: F1 (фронт хардкодит
window.location.originвместо sharedwebhookHelper→ URL неверен под reverse-proxy sub-path); F2 (фронт не отражает disabled-state → UI обещает «POST запускает проход», сервер 409'ит при выключенном авто-апдейте); F3 (CAS-РЕЛИЗ реальногоrunUpdatePassне покрыт тестом — сломанный defer завесит авто-апдейт навсегда при зелёном сьюте); F4 (rawNewError(StatusConflict)вместоhttperror.Conflict-хелпера).Объективка — частичная (важное ограничение): Go-тулчейн в моём окружении НЕДОСТУПЕН, поэтому Go-гейт (
go build/vet/test,-race) я прогнать не смог — опираюсь на заявленное кодером (Go 1.26.4: build 0, vet 0, тесты зелёные,-raceбез гонок) + сверку кода 9 аспектами (stability адверсариально протрассировал concurrency-ядро, test-coverage сверил тесты). Фронт-гейт прогнал сам, головаb6ba9b46: tsc 0, vitest AutoUpdatePanel 14 passed. Вердикт CHANGES (F3 — про пробел в тестах, не про красный тест), так что непрогнанный Go-гейт этот раунд не блокирует; но при следующем PASS Go-объективку нужно подтвердить (кодер прогоняет / CI).📋 Do (F1–F4) + DROP + что сверено
Do — почини, потом ставь
review/needsF1 [conventions · warning] Фронт хардкодит
window.location.origin— URL вебхука неверен под reverse-proxy sub-path —app/react/portainer/settings/SettingsView/AutoUpdatePanel/AutoUpdatePanel.tsx:110.Строит
${window.location.origin}/api/webhooks/container-automation/${token}, обходя sharedapp/portainer/helpers/webhookHelper.ts, чейgetBaseUrl()намеренно инкорпоритbaseHref()(<base href>для reverse-proxy sub-path-деплоя) + хендлитfile://desktop-билд и константуAPI_ENDPOINT_WEBHOOKS(сверил: helper строит${baseUrl}${API_ENDPOINT_WEBHOOKS}/${token},getBaseUrlиспользуетbaseHref()на стр.46/57). АналогичныйdockerWebhookUrl(token)— используется вWebhookRow.tsx. Под поддерживаемым sub-path-деплоем (Portainer за reverse-proxy с не-root base href) скопированный URL БЕЗ sub-path-префикса → неверный/нерабочий — ровно кейс, ради которого helper и существует. Fix: добавьcontainerAutomationWebhookUrl(token)вwebhookHelper.ts(зеркаляdockerWebhookUrl:${baseUrl}${API_ENDPOINT_WEBHOOKS}/container-automation/${token}), используй вWebhookTriggerSection, поправь assertion вAutoUpdatePanel.test.tsx.F2 [coherence · warning] Фронт не отражает disabled-state → UI противоречит серверу —
AutoUpdatePanel.tsx:106-147(WebhookTriggerSection).Секции передан только
token, но неEnabled-флаг авто-апдейта, так что при токене+выключенном авто-апдейте панель безусловно показывает живой URL, Copy и текст «Any POST to it immediately runs a full auto-update pass». Это прямое противоречие серверу, который (по дизайну этого же PR,webhook_container_automation.go) 409'ит при валидном токене иEnabled=false. Юзер, выключивший авто-апдейт но сохранивший токен (намеренный «keep the token»-путь), скопирует URL, воткнёт в реестр, и каждый пуш будет тихо 409'ить без эффекта, пока UI утверждает «запускает проход» — ровно UI-vs-сервер-mismatch, отмеченный в цели PR (d). Fix: прокиньautoUpdate.Enabled(доступен на стр.34) вWebhookTriggerSection, при disabled — нота «триггер неактивен, пока авто-апдейт выключен (POST'ы вернут 409)» и/или де-эмфазируй URL/Copy; токен и Regenerate/Clear оставь (токен сохраняется и ротируется в disabled).F3 [test-coverage · warning] CAS-релиз реального
runUpdatePassне покрыт → сломанный defer завесит авто-апдейт при зелёном сьюте —api/containerautomation/trigger_test.go(дляautoupdate.go:48).Контракт CAS-релиза —
defer s.updateRunning.Store(false)(autoupdate.go:48) — единственная риск-ветка с НУЛЕВЫМ покрытием. Все trigger-тесты гоняютupdatePass-стаб, не реальный CAS;TestRunUpdatePassCASGuard(стр.179) проверяет только acquire-FAIL (внешне ставит updateRunning=true →runUpdatePassвозвращает false ДО defer). Ни один тест не зовёт реальныйrunUpdatePassна успехе и не проверяет, что лок освобождён. Сломай defer — updateRunning навсегда true после первого прохода: каждый поллинг-тик CAS-fail'ит и молча дропается, каждый webhook-kick крутится в retry-loop (runPassRecoveredне вернёт true) — авто-апдейт заклинен до рестарта, а весь существующий сьют ЗЕЛЁН. Fix: тест — Service на тест-сторе,s.runUpdatePass()один раз →require.True, затемrequire.False(s.updateRunning.Load())ЛИБО немедленный второйs.runUpdatePass()→ тоже true. Падает на пре-фикс-коде только при наличии release-defer, пиннит контракт.F4 [conventions · low] raw
NewError(StatusConflict)вместоhttperror.Conflict-хелпера —api/http/handler/webhooks/webhook_container_automation.go:52.В кодбазе есть
httperror.Conflict(message, err), используемый консистентно для 409 (вкл.webhook_create.goв этом же пакете); остальные возвраты в новом файле уже юзают именованные (NotFound/InternalServerError), так что rawNewError(http.StatusConflict, …)— единственный аутлаер. Fix:httperror.Conflict("Container auto-update is disabled", errors.New("container auto-update is disabled")).⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]low[stability/security] typed-nil:server.ContainerAutomationServiceв интерфейсContainerAutomationTrigger— если конкретный*Serviceкогда-то nil, guard== nilне поймает typed-nil иTriggerUpdateдереф-нетs.kickCh. НоNewServiceвсегда возвращает non-nil (сверено coherence+security), pre-auth недостижимо. DROP.[below-threshold]low[test-coverage] триbaseCtx.Done()-shutdown-ветки без теста/leak-чека — воркер ограничен app-shutdown-контекстом, leaked-горутина на выходе процесса безвредна, ветки — однострочныеreturn. DROP.Сверено (9 аспектов + мои проверки, голова
b6ba9b46): security (const-time, empty-guard от server-state, no public-leak, no route-collision, bounded DoS); stability (6 проб: kick-loss-инвариант, CAS-defer-релиз на панике, clean shutdown, per-iter recover, fresh debounce-timer, datastore Enabled-recheck); поллинг не тронут (regressions:update()→runUpdatePass, тот же интервал/cron.Recover/CAS); токен additive-string, empty=disabled, не в public; F1 сверил (helper использует baseHref стр.46, панель хардкодит origin стр.110); F3 сверил (defer стр.48, единственный real-runUpdatePass-тест — acquire-fail стр.179); архитектура/симплификация/доки чисты. F1/F3 — warning; F2 — coherence-mismatch (UI обещает, сервер 409); F4 — style-нит. Эскалации нет. Ограничение: Go-гейт не прогнан (нет тулчейна) — Go build/vet/test/-race по evidence кодера; фронт-гейт мой (tsc 0, vitest 14).F1 [WARNING] The frontend built the trigger URL from window.location.origin, bypassing the shared webhookHelper whose getBaseUrl() honours the reverse-proxy sub-path (<base href>) and the desktop file:// build. Under a non-root base-href deploy the copied URL would miss the sub-path prefix. Added containerAutomationWebhookUrl(token) to webhookHelper (mirroring dockerWebhookUrl) and used it; the test asserts against the same helper. F2 [WARNING] The panel showed the live URL + 'POST runs a pass' text even when auto-update was disabled — contradicting the server, which 409s a valid token while disabled. WebhookTriggerSection now takes the enabled flag and shows an orange note ('trigger is inactive... a POST returns 409') when a token exists but auto-update is off; the token/Regenerate/Clear stay (the token is kept). Tests cover both the disabled note and its absence when enabled. F3 [WARNING] The CAS-release of the REAL runUpdatePass was untested (only the acquire-FAIL path was). A broken release defer would wedge auto-update forever (poll drops every tick, webhook worker spins) with a green suite. Added TestRunUpdatePassReleasesLock: a test store with auto-update enabled and no endpoints runs a pass to completion and asserts the lock is released (and a second pass re-acquires it). F4 [low] Swapped raw NewError(StatusConflict) for the httperror.Conflict helper, matching the rest of the file and webhook_create.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Round 1 — все 4 закрыты. И прогнал Go-гейт, который у тебя без тулчейна не шёл — подтверждаю объективку.
F1 [WARNING] URL через shared helper. Добавил
containerAutomationWebhookUrl(token)вwebhookHelper.ts(зеркаляdockerWebhookUrl), он используетgetBaseUrl()сbaseHref()— теперь URL корректен под reverse-proxy sub-path и desktopfile://. Панель и тест-ассерт — через хелпер, не через голыйwindow.location.origin.F2 [WARNING] Disabled-state в UI.
WebhookTriggerSectionтеперь принимаетenabled; при токене+выключенном авто-апдейте — оранжевая нота «триггер неактивен, POST вернёт 409, включи авто-апдейт». Токен/Regenerate/Clear остаются (токен сохраняется). Тесты: нота при disabled + её отсутствие при enabled.F3 [WARNING] CAS-release реального
runUpdatePassпод тестом. Ты прав — покрыт был только acquire-FAIL.TestRunUpdatePassReleasesLock: тест-стор с Enabled=true и без endpoint'ов гоняет проход до конца, ассертитupdateRunning==falseпосле + второй проход re-acquire'ит лок. Сломанный release-defer теперь красит тест (а не завешивает авто-апдейт молча).F4 [low]
httperror.Conflictвместо rawNewError(StatusConflict).Объективка (я прогнал Go, голова
400ac058):go build ./api/...0,go vet0,gofmtчисто,go test ./api/containerautomation/... ./api/http/handler/webhooks/...зелёные (вкл. новый CAS-release тест),go test -race ./api/containerautomation/...— без гонок (в прошлом коммите). Фронт:type-check0,vitest AutoUpdatePanel8 passed (+2 на F2). Внутренний цикл: 1 проход.Ревью — #33 (feat(automation): входящий registry-push вебхук для мгновенного автообновления, #32), round 2. Вердикт: PASS ✅
Все 4 round-1 находки закрыты и сверены по коду:
F1 (хардкод
window.location.origin→ неверный URL под sub-path) — добавленcontainerAutomationWebhookUrl(token)вwebhookHelper.ts, использующийbaseUrl(=getBaseUrl()сbaseHref()); панель и тест-ассерт теперь через хелпер, не через origin. Корректно под reverse-proxy sub-path и desktopfile://.F2 (UI не отражал disabled-state) — в
WebhookTriggerSectionпрокинутenabled={autoUpdate.Enabled}, приtoken && !enabledпоказывается нота «The trigger is inactive while container auto-update is disabled — a POST to this URL returns HTTP 409 and runs no pass. Enable…». UI больше не противоречит серверному 409.F3 (CAS-релиз реального
runUpdatePassне покрыт) — добавленTestRunUpdatePassReleasesLock: реальный Service на тест-сторе (enabled, без endpoints → проход бежит до release-defer), зовёт РЕАЛЬНЫЙrunUpdatePass(), ассертитrequire.True(acquire+run),updateRunning.Load()==false(релиз), затем второйrunUpdatePass()→ снова true (доказывает релиз, не только флаг). Не вакуумен: сломай defer — второй вызов вернёт false → тест красный. Сверил компайл-корректность:MustNewTestStore(t,init,secure)и поляService{baseCtx,dataStore}совпадают.F4 —
httperror.NewError(StatusConflict,…)→httperror.Conflict(…)(сверил: хелперConflict(msg,err) *HandlerError=NewError(http.StatusConflict,…), signature-идентичен).Объективка: фронт-гейт прогнал сам (голова
400ac058): client tsc 0, vitest AutoUpdatePanel 16 passed (+2 disabled-state-кейса). Go-гейт у меня по-прежнему без тулчейна — но: (а) я сверил компайл-корректность двух Go-правок по коду (Conflict-сигнатура, MustNewTestStore-сигнатура, Service-поля, F3-тест-логика — всё сходится); (б) round-1 concurrency-ядро (которое stability прогонял адверсариально) в этом раунде НЕ тронуто — только тест + one-line helper-swap; (в) кодер явно подтвердил повторный прогон Go-гейта для этого раунда. Совокупно объективка подтверждена.Замечаний нет.
review/approved.