fix(migrator): heal 2.44.0 stacks with StackFileVersion>0 but missing v{N} dir (#30) #31
Reference in New Issue
Block a user
Delete Branch "fix/30-migration-heal"
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
Чинит #30: миграция 2.44.0 слепо скипала стеки с
StackFileVersion > 0, считая что версионированная раскладкаcompose/{id}/v{N}на диске уже есть. Это неверно для стеков, чьи файлы лежат только в плоском базовом пути (compose/{id}/<entrypoint>) — из-за EE↔CE juggling (--rollback-to-ce), restore БД, старых билдов. Такие стеки пропускались и оставались сломанными:GET /stacks/{id}/file?version=N→ 500 (каталогаv{N}нет), при этом сам стек продолжает работать. closes #30.Заменил слепой guard проверкой РЕАЛЬНОГО состояния диска текущей версии: полный
v{N}→ adopt (идемпотентный no-op, если уже перенаведён); отсутствует/неполный → heal, материализуяv{N}из плоских базовых файлов. Пути fresh (v==0→v1) и heal (v>0-но-отсутствует) вынесены в общийmaterializeStackVersion;seedStackVersionMetadataобобщён на версиюv(стек сStackFileVersion=3лечится вv3, не вv1;Versionsсидится только когда пусто — реальная история не затирается).How verified
go build ./...— 0;go vet ./api/datastore/migrator/...— чисто;go test ./api/datastore/migrator/...— зелёные.HealFlatV1(репро #30:StackFileVersion=1, плоский ProjectPath, base есть,v1/нет → создаётсяv1/docker-compose.ymlс идентичным контентом,ProjectPath→v1, base удалён, идемпотентно на повторе),HealFlatV3(обобщение: heal вv3,v1не создаётся),CompleteExistingVersionNoDoubleWrite(готовыйv{N}+ реальная история не тронуты). СуществующиеMultiFileMove/SkipsGitAndOrphan/MissingAdditionalFile/IncompleteExistingV1сохранены и зелёные.v{N}не адаптируется и не пишется), удаление базовых копий ТОЛЬКО после репойнта ProjectPath (крэш/деплой mid-migration не находит пропавший файл — повторный прогон достраивает из полногоv{N}), идемпотентность, сохранение истории, git/kube skip.Scope
Per issue: одна инстанция форка, её единственный затронутый стек (
timeseries) уже пропатчен руками — self-heal-проход не нужен, достаточно правки guard'а (EE-серверы, которые переедут позже, ещё не гоняли 2.44.0 и подхватят исправленную версию).Checklist
The 2.44.0 stack-file-version migration skipped any stack with StackFileVersion>0, assuming the versioned on-disk layout (compose/{id}/v{N}) already existed. That is false for stacks whose files still live only at the flat base path (from EE<->CE edition juggling, DB restores, older builds): they were skipped and left broken — GET /stacks/{id}/file?version=N returns 500 (the v{N} dir doesn't exist), while the stack keeps running. Replace the blanket StackFileVersion>0 skip with a check on the ACTUAL on-disk state of the current version: complete v-dir -> adopt (idempotent no-op when already repointed); missing/incomplete -> heal by materializing v{N} from the flat base files. Factor the fresh (v==0->v1) and heal (v>0-but-missing) paths into one shared materializeStackVersion helper, and generalize seedStackVersionMetadata to an arbitrary version v (heals a StackFileVersion=3 stack into v3, not v1; seeds Versions only when empty so a real history is never clobbered). Preserves the all-or-nothing guarantee (read every file before writing any; never write a partial v{N}) and the base-copy-removal ordering (base files are removed only after ProjectPath is repointed to the v-dir, so a deploy mid-migration or a crash never finds a missing file — a re-run completes the repoint from the full v{N}). closes #30 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ревью — #31 (heal 2.44.0 stacks with StackFileVersion>0 but missing v{N} dir), round 1, head
54854f263, base develop (merge-base5f6e344fe)Вердикт: CHANGES — фикс #30 корректен и data-safe по чтению (all-or-nothing перед удалением подтверждён, все крэш-окна восстановимы повторным прогоном), но: (F1) главная data-safety-критичная ветка crash-recovery НЕ покрыта тестом, (F2) удаление базовых файлов в новой функции не заклэмплено (traversal-footgun в file-DELETING пути миграции). Оба — в тронутом коде, bounded.
Полный 9-аспектный веер. Go-тулчейна в окружении нет → базис = coder-run (go build/vet/test green по claim) + независимое чтение; 8 аспектов подтверждают чтением, что claim держится (invariants верны). Это МИГРАЦИЯ + удаление файлов → sensitive.
Подтверждено чтением (data-safety держится)
StackFileVersion>0 → skipзаменён проверкой реального диска; flat-стек безv{N}→materializeStackVersionиз базовых файлов. Heal идёт вv{StackFileVersion}(v3 для =3, не хардкод v1).Versions-история сохраняется (сидится только приlen==0). Adopt полногоv{N}— идемпотентный no-op (guardProjectPath==vDir && StackFileVersion==v && len(Versions)>0).:139-148), затем пишутся все вv{N}(:151-155), и ТОЛЬКО потом удаляется база (:162-167). Нет порядка, где база удаляется при частичномv{N}. Ошибка чтения → skip (база цела); ошибка записи → abort (база цела); ошибкаos.Removeбазы → лог, не фатально (v{N} уже полон). Крэш в любом окне восстановим повторным прогоном через adopt-ветку (которая базу НЕ удаляет).GetStackProjectPath(stackID), а не из DBstack.ProjectPath— строго корректнее для DB-restore-кейса (устаревший абсолютный ProjectPath из чужого хоста), а это одна из причин #30.Do — apply these, then re-review
migrate_2_44_0.go:95-98(adopt-ветка: полныйv{N}на диске, но DB-метаданные устаревшие → repoint+persist). Это РОВНО состояние после крэша между материализацией (v{N}записан, база удалена,:162) и DB-персистом (:114): база уже удалена, DB ещё указывает на базу. Вся история crash-safety опирается на то, что повторный прогон это чинит — но НИ ОДИН тест сюда не заходит: все существующие тесты, попадающие в complete-v-dir ветку, имеют уже-консистентные метаданные → садятся на no-opcontinue(:92). Fix: тест — засидить полныйv{N}черезStoreStackFileFromBytesByVersion, создать стек сProjectPath = GetStackProjectPath(id)/ пустымVersions, удалить базовые копии, прогнать миграцию, заассертить:ProjectPathперенаведён наv{N},StackFileVersion==v,Versionsзасижен, И контентv{N}цел. (ХарнессnewStackVersionTestMigratorуже есть.)migrate_2_44_0.go:163-164. Удаление базы:os.Remove(filepath.Join(basePath, fileName)).filepath.Join(в отличие отJoinPaths,filesystem.go:87) НЕ клэмпит traversal, аfileName=stack.EntryPoint/AdditionalFiles. Чтение и запись в ЭТОЙ ЖЕ новой функции идут через заклэмпленныеGetFileContent/StoreStackFileFromBytesByVersion— удаление осталось единственной незаклэмпленной мутацией. Эксплойт контрив (нужен../в fileName + staged-файл в заклэмпленной read-локации; популяция — DB-restore/import стеки), паттерн частично пред-существовал — но это удаление в миграции, и фикс однострочный: удалять черезJoinPaths(basePath, fileName), консистентно с read/write этой же функции. Заклэмпь.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low[stability]stackVersionDirCompleteпроверяет только существование, не контент/размер:v{N}/file, обрезанный мид-io.Copyкрэшем (нет fsync), пройдёт как complete → adopt на усечённый. НО база выживает (adopt её не удаляет) → нет перманентной потери; совпадает с не-атомарной нормой кодовой базы. Опц.-харден: content-hash/atomic temp+rename.[speculative]low[stability] no-fsync power-loss между записьюv{N}и удалением базы может потерять обе копии — пред-существующее (все stack-file-операции portainer), process-crash (частый случай) полностью восстановим. Не этот PR.[below-threshold]info[coherence] частичный пред-существующийv{N}(v>0) НЕ лечится (skip,FileExists(vDir)==trueнавсегда) — узкий вариант #30 остаётся с 500; data-safe (базу не трогает), консервативный scope, docstring честен. Лечить = write-risk, оставить defensible.[out-of-scope]info[architecture] heal живёт в one-shot version-gated миграции — не перезапустится при повторном дрейфе после 2.44.0 (будущий restore pre-2.44 бэкапа → снова 500). Постоянно закрыл бы idempotent repair-on-startup. Отдельный трек, не гейт.[note][conventions] barereturn err(без stack-id контекста) — доминирующий стиль пакета (2_43 — исключение с%w); не дефект.F2 (security): the base-file removal in materializeStackVersion used the unclamped filepath.Join while the read (GetFileContent) and write (StoreStackFileFromBytesByVersion) in the same function are clamped. An attacker-influenceable fileName (stack.EntryPoint/AdditionalFiles via DB restore/import) could escape basePath in this file-deleting path. Use the clamped filesystem.JoinPaths, consistent with the read/write. F1 (test): the adopt branch (complete v{N} on disk but stale DB metadata -> repoint ProjectPath + set StackFileVersion + seed Versions) is exactly the post-crash state the crash-safety guarantee relies on (crash between materialize and DB persist), but no test hit it — existing complete-v-dir tests have consistent metadata and take the no-op short-circuit. Add TestMigrateStackFileVersions_2_44_0_AdoptCompleteVDirStaleMetadata: seed a complete v1 on disk, stack with stale flat ProjectPath + empty Versions, no base copies; assert the migration repoints ProjectPath to v1, sets StackFileVersion=1, seeds Versions, and leaves the v1 files intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>F1/F2 закрыты, коммит
7b5dfeac.F2 (security): удаление базовых файлов в
materializeStackVersionтеперь через заклэмпленныйfilesystem.JoinPaths(basePath, fileName)вместоfilepath.Join— консистентно с read (GetFileContent) и write (StoreStackFileFromBytesByVersion) в той же функции. Для не-traversal fileName путь идентичен старому (basePath= trusted root изGetStackProjectPath), так что поведение не меняется; traversal через../вstack.EntryPoint/AdditionalFiles(популяция — restore/import) больше не выходит за basePath в этом file-deleting пути.path/filepathоставил — он ещё используется вstackVersionDirComplete.F1 (test): добавил
TestMigrateStackFileVersions_2_44_0_AdoptCompleteVDirStaleMetadata— покрывает crash-recovery adopt-ветку (полныйv{N}на диске, но DB-метаданные устаревшие → repoint), ровно то состояние после крэша между материализацией (v{N}записан + база удалена) и DB-персистом. Сетап: сидю полныйv1черезStoreStackFileFromBytesByVersion, стек сProjectPath = GetStackProjectPath(плоский, устаревший) + пустымVersions+StackFileVersion=0, базовых копий нет. Это НЕ садится на no-op short-circuit (ProjectPath != vDirиlen(Versions)==0), а идёт в repoint. Ассерты:ProjectPathперенаведён наv1,StackFileVersion==1,Versionsзасижен (len 1), оба файлаv1целы/не переписаны, плоская база не воссоздана. Упал бы, если бы adopt-ветка не персистила метаданные.Проверка:
go build0,go vetчисто,go test ./api/datastore/migrator/...— зелёные, все 8TestMigrateStackFileVersions_2_44_0_*PASS (вкл. новый). review/needs.Ре-ревью — #31 (heal 2.44.0 stacks, closes #30), round 2, head
7b5dfeac3, base develop (merge-base5f6e344fe)Вердикт: PASS — оба round-1 DO (F1, F2) закрыты, сверено ПО КОДУ. Data-safety цела (all-or-nothing перед удалением, крэш-восстановимо). Do-list пуст, эскалаций нет. Готов к мержу.
Полный 9-аспектный веер (отдельный субагент на аспект) на полном merge-base диффе, спотлайт на round-2-дельте (7 строк .go + новый тест). Go-тулчейна в окружении нет → базис = coder-run (go build 0, go vet чисто,
go test ./api/datastore/migrator/...зелёные, все 8TestMigrateStackFileVersions_2_44_0_*PASS) + независимое чтение; все 9 аспектов подтверждают, что чтение поддерживает claim. Sensitive (миграция + удаление файлов).Закрыто (сверено по коду)
TestMigrateStackFileVersions_2_44_0_AdoptCompleteVDirStaleMetadata(migrate_2_44_0_test.go:351-406) воспроизводит точное состояние после крэша между материализацией и DB-персистом: полныйv1на диске, плоская база «удалена» (не засижена), DB устаревшая (ProjectPath=base,StackFileVersion=0,Versions=nil). Трасса подтверждена:v=0→1,vDir=v1exists+complete → adopt-блок; no-op short-circuit (:93) НЕ срабатывает (ProjectPath != vDir) → repoint+persist (:96-99). Не-вакуозно: три независимых load-bearing ассерта (ProjectPath→v1,StackFileVersion==1,Versionslen 1) упали бы, если adopt-ветка не персистит. Плюс ассерты «файлыv1не переписаны» и «плоская база не воссоздана». Прим.: кодер взялStackFileVersion=0(не=N) — это ВЕРНЕЕ: ровно faithful post-crash-состояние fresh-пути, и 0→1 transition сам заассерчен (сильнее, чем=N). Ветка выбирается существованием v-dir, не версией → :96-98 покрыт.migrate_2_44_0.go:168: удаление теперьfilesystem.JoinPaths(basePath, fileName)вместоfilepath.Join— консистентно с заклэмпленными read (GetFileContent) и write (StoreStackFileFromBytesByVersion) в ЭТОЙ ЖЕ функции.JoinPathsклэмпит (filepath.Join(root, filepath.Join("/", untrusted))→../схлопывается против/до join с root). Для нормального fileName путь байт-идентичен старому (basePath trusted root) → нет изменения поведения / пропущенных удалений; traversal через../в EntryPoint/AdditionalFiles больше не выходит за basePath в file-DELETING пути. Импорты чисты (filesystemиспользуется :168,path/filepathещё нужен вstackVersionDirComplete:182 → нет unused-import build-break). Регрессий нет — наоборот, закрыта пред-существовавшая рассогласованность read(clamped)/delete(unclamped).Do — apply these, then re-review
(нет)
⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[style/linter]info[conventions]migrate_2_44_0_test.go:403строит проверку черезfilesystem.JoinPaths(basePath, "docker-compose.yml"), тогда как соседние тесты — конкатенациейbasePath + "/..."; чище, но единственная стилевая рассогласованность. Косметика.[below-threshold]low[stability, round-1 перенос]stackVersionDirCompleteпроверяет только существование, не контент/размер: усечённый мид-io.Copyкрэшемv{N}/fileпройдёт как complete → adopt. База выживает (adopt её не удаляет) → нет перманентной потери; совпадает с не-атомарной нормой кодовой базы. Опц.-харден.[speculative]low[stability, round-1 перенос] no-fsync power-loss между записью v{N} и удалением базы — пред-существующее, process-crash восстановим.Вне scope — кандидаты на отдельную задачу (NOT part of this verdict)
migrate_2_44_0.go— миграция version-gated; будущий restore pre-2.44 бэкапа → снова 500 без авто-починки. Постоянно закрыл бы idempotent repair-on-startup. Отдельный трек (принятый дизайн version-pinned миграции, не форк).v{N}(v>0) НЕ лечится (skip навсегда) — узкий вариант #30 остаётся с 500; data-safe, консервативный scope; лечить = write-risk. Отдельный трек.