fix(server): read task storage from the tasks manager, not the request - #426
Conversation
In builtin mode the Vault router resets req.Storage to nil once the request handler returns (the deferred reset in routeCommon, vault/router.go), and the release and publish tasks run later, on the tasks manager worker. Reading req.Storage from the task then dereferences nil: the first storage read in the task, GetTrustedPGPPublicKeys, panics and takes the whole Vault process down with it. The tasks manager already hands the task its own storage, the mount's storage view kept from the first request; use it for every storage read inside the task. An external plugin never saw this, because there req.Storage is a gRPC storage client living in the plugin process, which the core's reset does not reach. The unit tests mocked RunTask and never executed the task at all, so the new suite runs both tasks after setting req.Storage to nil, the way the router does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Verification
Mutation evidenceEach row reverts one of the four changed reads back to
Rounds 1 and 4 were applied together (they hit different scenarios; each test failed on its own site and neither masked the other). The third row is a red test binary rather than a red assertion because the panic happens in a goroutine the task starts; it still fails the package. Not run
|
Field evidence from the Stronghold standStronghold 1.19 (
The second task then fails inside the build on |
Green run on the standSame stand, Stronghold
Every storage read the task makes now goes through the storage the tasks manager provides; the only remaining oddity is that the stored task log ends mid-line ( |
…r config dir out of $HOME Review found that the release scenario proved only that req.Storage is not dereferenced: a task reading a fresh empty storage would pass too. The storage handed to the task now counts List and Get calls and the test requires at least one. The release scenario also reached config.LoadDefaultConfigFile, creating the developer's ~/.docker as a side effect; the docker config dir is pinned to a temp dir for the test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Independent review (Codex, static pass) and what changedAccepted and fixed in
Noted, not addressed here: the tests clone through go-git's file transport, which needs |
…task storage suite Review: the two test doubles lacked the compile-time interface checks CODESTYLE.md asks for, and a host without git failed the suite inside the clone with "unable to clone git repository" instead of naming the missing tool. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Code Review Report (independent pass, repository
|
| Criteria | Inferred? | Met? | Evidence |
|---|---|---|---|
Четыре чтения в замыканиях задач идут через параметр storage |
yes | ✅ | server/path_release.go:133,155,172, server/path_publish.go:153 |
| Чтения на этапе обработчика не тронуты | yes | ✅ | server/path_release.go:83,92,113,118; server/path_publish.go:69,78,92,105,110; router.go@v1.19.0:646,763,791 |
Ни одно другое замыкание задачи в server/ не читает req.Storage |
yes | ✅ | три вызова RunTask; server/periodic.go:74-82,102-117 уже на storage |
| Тест воспроизводит сброс роутера и запускает задачу на хранилище менеджера задач | yes | ✅ | server/task_storage_test.go:80-99; мутации M1/M4 убиты |
| Тест требует хотя бы одно чтение через выданное хранилище | yes | ✅ | server/task_storage_test.go:98; мутации M5/M6 убиты |
| Release-задача доходит до стадии сборки | yes | ✅ | task_storage_test.go:63 ↔ server/pkg/docker/build.go:131 |
Publish-задача доходит до разбора trdl_channels.yaml |
yes | ✅ | task_storage_test.go:75 ↔ server/path_publish.go:170 |
ELF-чтение (path_release.go:155) проверяется тестом на Linux CI |
yes | на macOS GetSettings выходит до чтения (server/pkg/elf_signing/storage.go:39-41); Linux-прогон в ревью недоступен |
|
Тест не создаёт ~/.docker у разработчика |
yes | ✅ | server/task_storage_test.go:48-50 |
| Ворота зелёные | yes | golangci-lint 0 issues.; prettier не выполнился (удалённый docker-хост недоступен); server:test:unit — Test Suite Passed |
Issues
- Critical — нет.
- Major — нет.
- Minor —
server/task_storage_test.go:21-24,101-104: два новых дублёра безvar _ Iface = (*T)(nil)(CODESTYLE.md). Существующие дублёры репозитория такого чека тоже не имеют. - Minor —
server/task_storage_test.go:113: хелперinitGitRepositoryв файле сьюта, AGENTS.md требуетhelpers_test.go; в корневом пакете такого файла нет, моки лежат вbackend_test.go. - Minor —
server/task_storage_test.go:116,125: клон через file-транспорт go-git требуетgitвPATH; без него сценарий падает с «unable to clone git repository», а не с сообщением о недостающем инструменте. - Minor —
server/path_publish.go:197: финальныйstorage.Putpublish-задачи тестом не достигается (мутация M7 выжила). Строка этим PR не менялась.
Risks
| № | Risk | Type | Likelihood | Severity | Location | Circumstances | Consequences | Recommendation |
|---|---|---|---|---|---|---|---|---|
| 1 | Паника внутри горутины сборки не перехватывается WrapTaskFunc |
Operational | Unlikely | High | server/path_release.go:165-185; server/pkg/tasks_manager/actions.go:101-112 |
recover покрывает только горутину задачи; docker.BuildReleaseArtifacts — во вложенной горутине без recover (мутация M3: процесс умирает целиком) |
Любая будущая паника на этом пути в builtin-режиме снова уронит процесс Vault | Вне скоупа PR; follow-up: recover в горутине на :165 с переводом в errCh |
| 2 | ELF-чтение верифицируется только Linux-джобой CI | Technical | Possible | Low | server/path_release.go:155; server/pkg/elf_signing/storage.go:38-41 |
локальный прогон на macOS не отличает storage от req.Storage на этой строке |
регрессия дойдёт до CI, но не будет поймана локально | ничего не менять; unit-джоба CI — единственные ворота для этой строки |
| 3 | Release-сценарий опирается на быстрый отказ на отсутствующем unix-сокете | Technical | Possible | Low | server/task_storage_test.go:59,63; server/pkg/docker/build.go:131 |
retry/ожидание в подключении к buildkitd | медленный или ложно-красный тест | перепроверить сценарий при изменении логики подключения |
Mutation evidence
Каждый раунд: применить → task --yes server:test:unit в отдельной копии → git checkout -- → git diff --quiet HEAD (все раунды чисто). Платформа: macOS/arm64.
| № | Mutation | Test that must fail | Result |
|---|---|---|---|
| M1 | path_release.go:133 → req.Storage |
TestReleaseTaskDoesNotUseRequestStorage (require.NotPanics) |
Killed |
| M2 | path_release.go:155 → req.Storage |
тот же | Survived на macOS — по построению (GetSettings выходит до чтения вне linux/amd64/cgo) |
| M3 | path_release.go:172 → Storage: req.Storage |
паника в горутине сборки | Killed — смерть тестового бинаря, RC=201 |
| M4 | path_publish.go:153 → req.Storage |
TestPublishTaskDoesNotUseRequestStorage |
Killed |
| M5 | release: storage = &logical.InmemStorage{} в начале замыкания |
TestReleaseTask… (require.Positive) |
Killed |
| M6 | publish: то же | TestPublishTask… |
Killed |
| M7 | path_publish.go:197 → req.Storage.Put |
нет теста, достигающего строки | Survived (предсказано; строка PR не менялась) |
Not verified
task server:build(CGO ELF-подпись, только Linux); убийство M2 на linux/amd64 (docker-хост недоступен); шаг prettier; реальная builtin-маршрутизация Vault → trdl (семантика роутера проверена поvault/router.go@v1.19.0, форк Stronghold не сверялся); стендовые замеры из комментариев PR;task e2e:test:e2e.
Author's response
Принято в db8bc63: compile-time проверки интерфейсов у обоих дублёров; exec.LookPath("git") с явным сообщением в начале initGitRepository.
Оставлено: helpers_test.go — в корневом пакете server/ такого файла нет, все моки живут в backend_test.go; выносить один хелпер отдельно от них значит завести второе место для одного и того же, решение за maintainer'ом. path_publish.go:197 — строка PR не менялась и уже читала хранилище задачи; сценарий до CommitStaged требует дублёра publisher.RepositoryInterface, это отдельная работа над MockedPublisher. Риск 1 (recover во вложенной горутине сборки) — существующий код, вне диффа, стоит отдельного PR.
…g dir (#427) ## Summary The BuildKit auth provider persists registry token seeds under the docker config dir and creates that directory on the first token request. A process without a writable home therefore fails every pull, anonymous ones included, with `mkdir /.docker: read-only file system`. A builtin backend runs exactly like that. Read the config file from the default location as before, then move only the seeds to a directory under `os.TempDir()` when the default dir cannot be created. ## Key changes - `server/pkg/docker/buildkit.go`: `buildkitSessionAttachables` reads `config.json` from the default location, then `useWritableDockerConfigDirForTokenSeeds` tries `os.MkdirAll(config.Dir())` and, on failure, creates a directory private to this build with `os.MkdirTemp` (mode 0700) under the memory-backed `/dev/shm` where it exists, else under `os.TempDir()`, and points `config.SetDir` at it for exactly as long as `authprovider.NewDockerAuthProvider` needs to capture `config.Dir()`; the original directory is restored afterwards and a mutex serialises the window. The function now also returns a cleanup that removes the seed directory, which `buildWithBuildkitClient` defers, so nothing outlives the build and mounts sharing a process (several trdl engines, in one namespace or across namespaces) share nothing through it. - `server/pkg/docker/buildkit_config_dir_test.go`: the tests drive the returned provider through `GetTokenAuthority`, the call buildkitd makes on a bearer challenge, and watch the candidate base directories: a writable config dir is kept and used and no seed dir appears; an unwritable one (a path under a regular file) leaves `config.Dir()` untouched after the call, one private 0700 dir with `.token_seed` appears and is gone after cleanup; eight concurrent builds get eight distinct dirs, all removed afterwards (run under `-race`). ## Why Observed on a Stronghold 1.19 stand where trdl is a builtin secrets engine: `readOnlyRootFilesystem: true`, no `HOME`, only `/tmp` mounted writable. With #426 applied the release reaches the build, the kubernetes builder pod starts, and the solve fails on `#1 [internal] load remote build context` with `mkdir /.docker: read-only file system` — `tokenSeeds.getSeed` in `session/auth/authprovider/tokenseed.go` does `os.MkdirAll(config.Dir())`, and `config.Dir()` resolves to `/.docker` there. Both BuildKit client paths (`buildkitd_address` and the kubernetes driver) attach this provider, so both fail the same way; the docker CLI path does not use it. An external plugin never hit this: it runs with a home directory, and the e2e flow runs it on a workstation or a CI runner. ## Review focus / risks - `config.SetDir` is process-global state in `docker/cli`; the redirect is held only across provider construction, under `dockerConfigDirMu`, and restored, so later `LoadDefaultConfigFile` calls (the next build, or any other consumer in a builtin process) keep reading the default location. The first cut of this PR did not restore it and the independent review caught that; see the comments. - Token seeds are per-host random values BuildKit regenerates when missing. A fresh seed per build means that on a shared buildkitd (`buildkitd_address`) the daemon cannot link a new build to the registry tokens a previous build fetched, so tokens are fetched per build; with the kubernetes driver there is a daemon per build and nothing to link. In `/dev/shm` the seed never reaches a disk. The file is neither a token nor a credential (see the code comment). - When even `os.MkdirTemp` fails the redirect is skipped with a log line and BuildKit's original `mkdir` error surfaces unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Vasily Marmer <vasily.marmer@flant.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Summary
The release and publish tasks read
req.Storageafter the request handler has returned. When trdl runs as a builtin backend (Stronghold compiles it in), the Vault router resetsreq.Storagetonilin the deferred block ofrouteCommonas soon as the handler returns, so the first storage read inside the task dereferences nil and the panic takes the whole Vault process down. The task already receives its own storage from the tasks manager; use it for every storage read inside the task.Key changes
server/path_release.go: the task reads trusted PGP keys, ELF signing settings and the build options'Storagefrom thestorageparameter of the task, not from the capturedreq.Storage(three sites).server/path_publish.go: the same for the trusted PGP keys read (one site; thestorage.Putat the end of the task already used the parameter).server/task_storage_test.go: a suite that captures the task fromRunTask, letsHandleRequestreturn, setsreq.Storage = nilthe way the router does, and runs the task on the storage the tasks manager provides. The release task must reach the build stage (it fails on a deliberately absent buildkitd socket), the publish task must reach the channels config parse; either one panicking fails the test.Why
Observed on a Stronghold 1.19 stand, where trdl is a builtin secrets engine:
trdl/releaseanswers with a task UUID, the task clones the repository, and thenpgp.GetTrustedPGPPublicKeys(ctx, {0x0, 0x0})panics atpath_release.go:133.WrapTaskFuncre-panics anything but "send on closed channel", so the process exits with code 2 and the active replica restarts; the task staysRUNNINGin storage forever.publishfails the same way at its PGP read.The defect is latent upstream because both ways the code is exercised keep
req.Storagealive:req.Storageis aGRPCStorageClientbuilt per request in the plugin process (sdk/plugin/grpc_backend_server.go), which the core's reset never reaches — this is whattask server:setup-vault-localand the e2e flow use;RunTaskand never execute the task.In builtin mode the request object is the core's own, and
vault/router.go(upstreamv1.19.0, lines 646/763) attachesre.storageViewbefore routing and setsreq.Storage = nilin the deferred reset. Thestoragethe tasks manager hands to the task ism.Storage, the mount's storage view kept from the first request, which is what the worker's own status writes already use in both modes.Review focus / risks
getConfiguration, git credentials,GetRepository, theRunTaskargument itself) still usereq.Storage, which is valid while the handler runs.gitonPATH(go-git's file transport runsgit-upload-pack); the existing e2e flow already requires git.elf_signing.GetSettingsreads storage only onlinux && amd64 && cgo; on other platforms it returns before touching storage, so the ELF site is exercised by the test only on the Linux CI build (verified on the CI image, see the methodology comment).🤖 Generated with Claude Code