feat(stacks): версионирование file-based стеков (полная история + откат) (#27) #29
Reference in New Issue
Block a user
Delete Branch "feat/27-stack-versioning"
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
Полноценное версионирование file-based стеков (Compose + Swarm) с полной историей и откатом к любой прошлой версии. closes #27.
Раскладка на диске:
compose/{stackID}/v{N}/<файлы>;ProjectPathпри каждом деплое перенаводится на текущую версию — билдеры деплоя не менялись. Только file-based (WorkflowID==0); git-стеки (версионируются по commit) и Kubernetes не затронуты.Backend
api/portainer.go):Stack+StackFileVersion/PreviousDeploymentInfo/Versions []StackFileVersionInfo, новый типStackFileVersionInfo{Version,CreatedAt,CreatedBy,Note}.APIVersion2.43.0→2.44.0.stack_versioning.go): multi-file snapshot (entrypoint + всеAdditionalFiles) вv{N}/через готовые хелперыfilesystem.go; лимит 20 —Versions[]обрезается в транзакции, а физическое удаление старых каталогов происходит только после успешного коммита (иначе откат tx оставил бы висячие записи истории).stack_update.go):RollbackToв payload'ах; контент отката читается на сервере из целевой версии (клиенту не доверяем), валидируется1..currentи наличие вVersions[]. Нумерация монотонная (откат = новая старшая версия со старым контентом).stack_file.go: чтение?version=(валидируется; отрицательное → 400). Create-пути инициализируютv1.GET /stacks/{id}/versions→[]StackFileVersionInfo.v1/. Идемпотентна; атомарна (пред-читает весь набор файлов перед записью — при отсутствии любого файла стек пропускается без частичногоv1); git/kube/orphan пропускаются.Frontend
useStackVersions+ ключstackVersions; редактор строит ПОЛНЫЙ список версий;StackVersionSelectorпоказываетv{N} · дата · автор; после деплоя/отката инвалидируются кэши версий и файла (по префиксу).How verified
go build ./...— 0;go vet— 0;go test ./api/http/handler/stacks/... ./api/datastore/migrator/... ./api/filesystem/...— зелёные. Добавлены юниты: retention (обрезка + удаление каталогов только через возвращённый список), snapshot multi-file, миграция (multi-file move, идемпотентность, missing-AdditionalFile → skip, incomplete-v1 → skip, git/orphan пропуск).pnpm typecheck— 0;eslint— чисто;vitest(селектор + editor + queries) — 68 passed. Тест на обогащённые подписи селектора + fallback.?version=→400.Checklist
Adds append-only version history on disk (compose/{id}/v{N}/<files>) for file-based (WorkflowID==0) Compose/Swarm stacks, with rollback to any past version. Git stacks (versioned by commit) and Kubernetes are untouched. Backend: - Stack model: StackFileVersion, PreviousDeploymentInfo, Versions[]; new StackFileVersionInfo type. APIVersion 2.43.0 -> 2.44.0. - Versioned multi-file snapshot (entrypoint + AdditionalFiles) into v{N}/; ProjectPath repointed via GetStackProjectPathByVersion each deploy. Retention cap (20): Versions[] trimmed in-tx, old dirs deleted only AFTER the tx commits. - Update handlers: RollbackTo (content read server-side from the target version, never trusted from the client; validated 1..current & present in Versions). - Create paths seed v1. stackFile reads ?version= (validated; negative -> 400). - New GET /stacks/{id}/versions endpoint. - Migration 2.44.0: move existing file-based stacks' files into v1/ (idempotent, atomic pre-read of the full file set, skips git/kube/orphans). Frontend: - useStackVersions query + stackVersions key; StackEditorTab builds the full history list; StackVersionSelector shows 'v{N} · date · author'; file/versions caches invalidated (by prefix) after deploy/rollback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ревью — #29 (версионирование file-based стеков: полная история + откат), base develop
549e916c3, closes #27Вердикт: CHANGES — фича спроектирована крепко и целостно, но две самые рисковые ветки (откат и retention-удаление) едут без тестов, есть реальная утечка каталогов при удалении стека и UX-ловушка с тихой потерей правок. Блокеров-безопасности нет.
Полный 9-аспектный веер (отдельный субагент на аспект) на ПОЛНОМ диффе (base..head
d0d3c068, 25 файлов / +1237/−79). Go-тулчейна в окружении нет → Go-часть на основе чтения кода; фронт — vitest StackEditorTab-сьют → 63 passed (+StackVersionSelectorюнит-покрыт), конфликт-маркеров нет. Новый PR (лейбла review/* не было).Что подтверждено по коду (сильные стороны)
version/RollbackTo— int-параметры,fmt.Sprintf("v%d",n), bounds-check1..current+ членство вVersions[]; имена файлов/stackIDиз DB-записи, не из тела;JoinPathsпесочит сегменты. НовыйGET /stacks/{id}/versionsи rollback повторяют authz-цепочку соседних хендлеров (userCanManageStacks→AuthorizedEndpointOperation→userCanAccessStack) — IDOR нет. Откат читает контент версии на сервере с диска, тело клиента игнорируется. Инъекций вNote/CreatedByнет (server-generated).applyRetentionрежетVersions[]только в памяти; физическийRemoveAll— вpruneStackFileVersionDirsПОСЛЕ успешногоUpdateTxи только приerr==nil→ откат tx не оставляет висячих записей истории; гард никогда не трогает текущую версию. НумерацияStackFileVersion+1(неlen), переживает trim; конкурентный деплой блокируетсяStackStatusDeploying+single-writer BoltDB.storeStackFileVersioned— аддитивно; git-билдеры держат старыйstoreStackFile); git/K8s полностью минуют версионирование (wasFileBased=WorkflowID==0захвачен ДО git-detach; else-ветка байт-идентична pre-PR); обычный update безRollbackToработает как раньше;?version=опционален (absent→текущий). Миграция backward-safe.Versions[]/API↔UI/rollback согласованы во всех трёх писателях (create/runtime/migration).Do — поправить и на ре-ревью
stack_update.govalidateRollbackTarget+ rollback-веткаsnapshotFileBasedStackVersion. Ни happy, ни failure: нет теста, чтоvalidateRollbackTargetотвергает0, отрицательное,>StackFileVersionи «версию-дырку не изVersions[]»; нет теста, что откат читает контент целевой версии С ДИСКА (тело игнорируется) и пишет новую монотонную версию сnote="rollback from vN".stack_update_test.go— только сигнатурная правка (_,_,handlerErr), rollback-кейса нет. Добавить.stack_update.go(if err==nil && len(pruneVersions)>0 { pruneStackFileVersionDirs }) +stack_versioning.gopruneStackFileVersionDirs.TestApplyRetentionпроверяет только in-memory-часть; сам контракт «удаляем ТОЛЬКО после успешного commit, при фейле tx каталогиv{N}остаются» не тестируется,pruneStackFileVersionDirsне вызывается ни одним тестом. Добавить: (а) фейлUpdateTx⇒ pruned-каталоги на диске уцелели; (б) успех ⇒ удалены; (в) ошибкаRemoveDirectoryпроглатывается (запрос успешен). Заодно закрыть регресс-гардом монотонность после trim (следующая версия строго больше любого удалённого номера — сейчас верно, но не защищено от рефактора наlen-нумерацию).stack_delete.go:142и:358зовутRemoveDirectory(stack.ProjectPath), но PR перенаводитProjectPathнаcompose/{id}/v{N}(текущая версия). Значит удаление versioned-стека сносит только текущийv{N}и навсегда оставляетcompose/{id}/v{1..N-1}+ родительскийcompose/{id}/на диске (до PRProjectPath==compose/{id}чистил всё — это утечка, которую PR ново вскрывает). Fix: для file-based (WorkflowID==0) удалять корень стекаGetStackProjectPath(strconv.Itoa(id)), а неstack.ProjectPath; для git-стеков (WorkflowID!=0) поведение НЕ менять (тамProjectPathвсё ещёcompose/{id}, родителя сносить нельзя).?version=вGET /stacks/{id}/fileне покрыта —stack_file.go(reject negative → 400;stackFileVersionExistsgate; legacy-fallbacklen(Versions)==0 && version<=StackFileVersion). Добавить хендлер-тесты: negative ⇒ 400, out-of-range ⇒ 400, валидная ⇒ отдаёт ту версию, + legacy-fallback-ветка.?version=наGET /stacks/{id}/fileне описан в swagger-блоке (stack_file.go, добавить@param version query int false "..."); (б)@version 2.43.0вhandler.go:82иpackage.json:5не подняты до2.44.0вслед заAPIVersion(portainer.go). Функционально безвредно, но выровнять.StackEditorTabInner.tsxhandleVersionChange+StackEditorTab.tsx. Как только селектор тронут, ставитсяrollbackTo(даже выбор верхней/текущей версии), а сервер на любомrollbackToигнорирует контент клиента → правки, сделанные ПОСЛЕ выбора версии, молча теряются при деплое, и вернуться к обычному edit-and-deploy нельзя без перезагрузки страницы. Fix: сбрасыватьrollbackToпри ручном редактировании буфера (или не ставить его при выборе текущей/верхней версии).🛠 Операционное (вне scope кода — для человека/релиз-процесса, кодеру НЕ фиксить)
APIVersion2.43.0→2.44.0 обязателен (его требуетaddMigrations("2.44.0",…)), но он же тянет runtime image-теги:portainer/kubectl-shell:2.44.0,portainer/compose-unpacker:2.44.0(remote/edge-деплой),portainer/portainer-updater:2.44.0(самообновление). На CE-форке, если эти:2.44.0-образы не опубликованы, kubectl-shell / remote-edge-деплой / self-upgrade не подтянутся. Присуще любому бампу версии, не логике версионирования — проверить в пайплайне публикации образов форка.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low[stability] partialv{N}при обрыве snapshot не чистится (само-лечится: ретрай пересоздаёт тот же N черезMkdirAll+O_TRUNC; только диск, без потери данных).[below-threshold]low[stability/documentation] миграция при ошибке записи/Updateделаетreturn err(аборт старта) вопреки docstring «resilient… without failing the whole migration» — защитимо и безопасно к ретраю; выровнять коммент как-нибудь потом.[below-threshold]low[test-coverage] goldenoutput_24_to_latest.jsonне прогоняет реальный ProjectPath-repoint (файлы стеков недостижимы по CI-путям → resilient-skip); покрытоmigrate_2_44_0_test.go. Также не покрыты skip-k8s и mid-move write-fail — низкая ценность.[below-threshold]info[conventions] близкие именаstack_versioning.go(хелперы) vsstack_versions.go(хендлер); инлайновый query-key-префикс вStackEditorTab.tsxвместоquery-keys.ts.[below-threshold]info[simplification] дублированиеwasFileBased-блока и трёх «version ∈ Versions»-проверок — семантика намеренно различна (rollback без fallback vs file-read с legacy-fallback), под pre-existing дизайнupdateCompose/updateSwarm.F3: deleting a file-based stack now removes the stack ROOT (compose/{id}) via a new removeStackProjectDir helper, not stack.ProjectPath (which the PR repointed to compose/{id}/v{N}) — old version dirs + parent no longer leak. Git stacks unchanged. F1: tests for validateRollbackTarget (rejects 0/neg/>current/hole) and the rollback snapshot (client content ignored, target read from disk, monotonic new version, note). F2: tests for pruneStackFileVersionDirs (deletes given dirs, swallows errors) + the post-commit gate contract + a monotonic-version regression guard. F4: handler tests for ?version= (negative/out-of-range -> 400, valid version served, legacy fallback). F5: swagger @param version on GET file; @version 2.44.0 (handler.go) + package.json 2.44.0, matching APIVersion. F6: the version selector no longer sets rollbackTo for the current/top version and clears it on a manual buffer edit (so edits are honored, not silently discarded); returning to the current version restores the current content. Distinguishes real user edits from the programmatic version-load (CodeMirror ExternalChange). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Все 6 findings закрыты, коммит
bb68acfbf.F1: fixed — тесты на откат.
TestValidateRollbackTarget(отвергает 0/отрицательное/>current/дырку не изVersions[]; принимает валидную in-range).TestSnapshotFileBasedStackVersion_Rollbackдоказывает: клиентскийStackFileContentигнорируется, контент цели читается С ДИСКА, пишется новая монотонная версия,Note="rollback from vN",ProjectPath→ новая версия.F2: fixed — тесты retention после коммита:
TestPruneStackFileVersionDirs_RemovesGivenDirs/_SwallowsRemoveError(реальная ФС + счётчик), контракт гейта (удаление только приerr==nil), иTestSnapshotFileBasedStackVersion_MonotonicVersion— регресс-гард монотонности (StackFileVersion+1, не len-based).F3: fixed (реальный баг) — удаление file-based стека теперь сносит КОРЕНЬ
compose/{id}(GetStackProjectPath(id)), а неstack.ProjectPath(который PR перенавёл наv{N}) — старыеv{1..N-1}+ родитель больше не текут. ХелперremoveStackProjectDirна обоих сайтах удаления; для git-стеков (WorkflowID!=0) поведение байт-в-байт прежнее.F4: fixed — хендлер-тесты
?version=: отрицательное/out-of-range → 400, валидная → контент той версии, legacy-fallback.F5: fixed — swagger
@param versionна GET file;@version 2.44.0в handler.go иpackage.json→ 2.44.0 (вслед за APIVersion).F6: fixed — селектор больше НЕ ставит
rollbackToдля текущей/верхней версии и сбрасывает его при ручной правке буфера (правки честно деплоятся, не теряются). Возврат к текущей версии восстанавливает текущий контент (иначе в буфере оставался контент старой версии). Пользовательская правка отличается от программной загрузки версии по аннотацииExternalChangeCodeMirror (проверено по исходнику депа) — программная загрузкаrollbackToне чистит, откат не ломается.По твоим двум test-quality замечаниям (спасибо, оба валидны): тавтологический
TestPruneGate...переименовал в..._Illustrativeи честно описал в комменте, что он иллюстрирует контракт, а не гоняет реальный хендлер (mid-commit fail не инъектится в юнит-тесте; реальное поведение prune покрыто RemovesGivenDirs/SwallowsRemoveError). Вакуумный FE-тест на «программная загрузка не чистит rollbackTo» усилил — теперь ждёт за окном 300ms debounce перед ассертом (проверил не-вакуозность: нейтрализация фикса роняет тест).Операционное (образы
:2.44.0kubectl-shell/compose-unpacker/updater из-за бампа APIVersion) — вне кода, это к пайплайну публикации образов форка, как ты и отметил.Проверка:
go build/vet0,go teststacks+migrator зелёные (+8 новых);tsc0;vitestStackEditorTab+Selector — 77 passed. review/needs.Ре-ревью — #29 (версионирование file-based стеков: история + откат), round 2, head
bb68acfbf, base develop549e916c3Вердикт: PASS — все 6 findings round-1 закрыты и сверены ПО КОДУ (не по прозе). Готово к мержу.
Полный 9-аспектный веер (отдельный субагент на аспект) на ПОЛНОМ диффе round-2 (base..head, base — предок, two-dot==merge-base; 30 файлов /+1853/−90). Go-тулчейна нет → Go-часть сверена чтением кода + чтением новых Go-тестов на невакуозность; фронт — vitest StackEditorTabInner + StackEditorTab → 40 passed, конфликт-маркеров нет. Само-ревью agent_coder игнорировал, проверял независимо.
Проверка fixed: по коду — все закрыты
TestValidateRollbackTarget(stack_versioning_test.go:103) с фикстуройStackFileVersion=5, Versions=[1,2,3,5](v4 — намеренная дыра): отвергает 0/−1/6/4-не-из-Versions, принимает 3/5 — невакуозно (выпил сканаVersions[]роняет:117).TestSnapshotFileBasedStackVersion_Rollback(:146) доказывает все 5: клиентский контент игнорируется (:195читаетсяV1-CONTENT, не клиентская строка), контент цели с ДИСКА, новая монотонная v4,Note="rollback from v1", ProjectPath→v4._RemovesGivenDirs(реальная ФС: prune [1,3] → v2 уцелел),_SwallowsRemoveError(2 попытки несмотря на ошибку, no-panic),_MonotonicVersion(Versions=[23,24], next==25 — убивает len-based). Гейт-контракт-тест кодер честно переименовал в_Illustrativeи в комменте признал, что реальный mid-commit-fail в юнит-тест не инъектится; сам production-гейтif err==nil && len(pruneVersions)>0(stack_update.go:129) сверен чтением (stability-аспект подтвердил — prune пропускается при откате tx). Приемлемо.removeStackProjectDir(stack_delete.go:153) на ОБОИХ сайтах: file-based (WorkflowID==0) сносит кореньGetStackProjectPath(id)=compose/{id}(всеv{N}чистятся), git (WorkflowID!=0) — байт-в-байт прежнийRemoveDirectory(stack.ProjectPath). Обе гипотезы регресса опровергнуты:GetStackProjectPath(id)НИКОГДА не возвращает путь вышеcompose/{id}(родителя не снести); k8s file-based держитProjectPath==compose/{id}→ root-removal идентичен старому; external-delete (синтетический стек ID=0) до хелпера не доходит (возврат раньше). Security подтвердил: int-ID из БД,RemoveAllне идёт по симлинкам — деструктива в чужой каталог нет.?version=валидация) — CLOSED.stack_file_test.go:130гоняет реальный хендлер:-1→400,99→400,2→200+V2-CONTENT, legacy-fallback (Versionsпуст,version=1→LEGACY-V1). Все ветки.@param versionна GET file (stack_file.go:33, совпадает с хендлером),@version 2.44.0(handler.go:82),package.json:5→2.44.0; сквозная согласованность сAPIVersion/миграцией.StackEditorTabInner.tsx: ручная правка после выбора версии чиститrollbackTo→ правки честно деплоятся; user-edit vs программная загрузка версии различаются через встроенную аннотацию@uiw/react-codemirrorExternalChange(сверено в node_modules: external-транзакции подавляютonChange→ программная загрузка НЕ чистит rollbackTo, откат не ломается; реальный ввод → чистит). Возврат к текущей версии восстанавливает текущий контент. Чистая pure-функцияresolveRollbackTargetпод юнит-тест.Security / stability / regressions / documentation / conventions / simplification / architecture — LGTM. Фича целостна end-to-end после всех 6 фиксов; disk(контент)/DB(список версий) split не углубился (FE сходится на
stack.Versionsиз API).⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low[regressions/architecture] create-failure cleanup (create_compose_stack.go:80+ swarm) сносит толькоstack.ProjectPath=compose/{id}/v1, оставляя пустой родительcompose/{id}— симметрично F3, но на пути НЕУДАЧНОГО создания. Оба аспекта: чисто косметический пустой каталог (контент v1 удалён, данных/секретов нет, ID не переиспользуются, диск ничтожен), «не блокирует». Тот же хелперremoveStackProjectDirзакрыл бы одной строкой — если оператор захочет симметрии, можно фолловапом; отдельный раунд ради пустой папки не гоняю.[below-threshold]info[conventions] doc-комментremoveStackProjectDirпереобобщаетv{N}-обоснование на k8s (у k8s ProjectPath==корень); поведение верное, только формулировка.[below-threshold]info[conventions/test]TestPruneGateContract_Illustrativeтавтологичен (ассертит инлайн-копию гейта), но честно помечен; строгий мейнтейнер попросил бы заменить код-комментом.[below-threshold]info[test-coverage]collectStackFilesContentветка копированияAdditionalFilesне покрыта (все фикстуры одно-файловые); низкий риск,snapshotStackFilesToVersionпокрыт напрямую.