fix(server): keep BuildKit token seeds off an unwritable docker config dir - #427
Conversation
…g dir The BuildKit auth provider persists registry token seeds under the docker config dir and creates that directory on the first token request, so a process without a writable home fails every pull with "mkdir /.docker: read-only file system", anonymous pulls included. A builtin backend runs exactly like that: Stronghold has readOnlyRootFilesystem, no HOME and only /tmp writable. Read the config file from the default location as before, then point config.Dir() at a directory under os.TempDir() when the default one cannot be created, so only the seeds move and credentials in the default config.json keep working. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Verification
Mutation evidenceEach row is applied on top of the fixed tree and the unit suite is rerun; the tree is restored from git and checked clean (
The unwritable directory in the tests is a path under a regular file, so the check fails with Not run
|
Green run on the standStronghold
|
The comment next to the config dir redirect read as if a registry token were written to disk. The file holds a locally generated random seed for the client-side token authority key; it is neither a token nor a credential, and it is never sent anywhere. Say so where the redirect is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
…truction Review of the first cut found two defects. config.SetDir was never restored, so the second build in the same process read config.json from the seed directory and lost the credentials kept at the default location; and the fallback was a guessable path in the shared temp dir that nothing created or validated. The redirect now holds only while NewDockerAuthProvider captures config.Dir(), under a mutex so concurrent builds cannot observe each other's redirect, and the seed directory is created once per process with os.MkdirTemp and a private mode. The tests drive the provider through GetTokenAuthority, the call buildkitd makes on a bearer challenge, instead of asserting on the global variable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Independent review (Codex, static pass over the two-commit diff) and what changedAccepted and fixed in
Noted, out of scope for this PR (pre-existing, not touched by the diff): the tasks manager worker runs on Mutation evidence, reworked implementation
golangci-lint: 0 issues. The repository's prettier step could not run locally (it needs the remote docker host, which was unreachable); the diff is Go only. The green stand run reported above was taken on the first cut ( |
Green run on the reworked implementationSame stand, Stronghold
|
The seed only ever needs to outlive one process, so prefer the memory-backed /dev/shm for its directory and fall back to os.TempDir() where there is none, so nothing lands on a node disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Several trdl mounts, in one namespace or across namespaces, share one Stronghold process, and a per-process seed directory is shared between them. Create the directory per build instead and remove it once the build is over, so mounts share nothing through it and nothing outlives the build. The cost is a fresh seed per build: on a shared buildkitd the daemon cannot link a new build to the tokens a previous one fetched; with a builder pod per build there is nothing to link. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Follow-up from the Stronghold review: seed directory per build (
|
| Mutation | Expected evidence | Result |
|---|---|---|
| cleanup does not remove the dir | …SeedsGoToPrivateDirWhenConfigDirUnwritable (NoDirExists after cleanup) and …EveryBuildGetsItsOwnSeedDir (before == after) fail |
Killed, exactly those two |
| config dir not restored | the same two fail on config.Dir() |
Killed, exactly those two |
one shared dir for all builds instead of MkdirTemp per build |
…EveryBuildGetsItsOwnSeedDir fails (1 dir, not 8) |
Killed, exactly that one |
Not covered by a unit test: the defer removeTokenSeeds() at the call site in buildWithBuildkitClient (it needs a daemon to exercise); the stand runs below are the evidence for that wiring.
Stand, Stronghold release-1.19 with vmrm/trdl@8614031 (this branch at a6576be, per-process /dev/shm dir): release v0.0.1 task b912084d… SUCCEEDED in 3m00s, three replicas, zero restarts, builder pod created and removed. The per-build cut has not been run on the stand yet; the CI unit job on ubuntu exercises the /dev/shm branch, the macOS runs the os.TempDir() fallback.
Stand run on the merged code (per-build seed directory)
That closes the "pending" note above; every cut of this PR has now been run end to end on the stand. |
…solate the tests Review: the defer that removes the seed directory in buildWithBuildkitClient was the one line no test reached, and it is reachable without a daemon, since the BuildKit client dials lazily and a closed local port refuses the solve at once. The tests now also run against a private TMPDIR, an empty PATH and no DOCKER_AUTH_CONFIG, so a developer's credential helpers and the machine's shared temp dir stay out of them, and every cleanup is registered with t.Cleanup so a failing assertion leaves no directory behind. A failed RemoveAll is logged instead of dropped, and the comment sits on the function it describes. 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 |
|---|---|---|---|
Процесс с несоздаваемым config.Dir() не падает; seeds уходят в приватный каталог сборки |
yes | ✅ (unit) | buildkit.go:127–149; мутации b, c, e, g убиты; ветка /dev/shm локально недостижима (macOS) |
config.json по-прежнему читается из штатного каталога |
yes | ✅ | buildkit.go:88 до :89; configfile/file.go:321–390 и credentials/* к config.Dir() не обращаются |
config.Dir() восстанавливается; окно сериализовано |
yes | ✅ | buildkit.go:85–86, :149; мутации b и d убиты (d — 7 каталогов вместо 8 + 5 DATA RACE) |
Каталог seeds — на сборку, 0700, удаляется через defer на месте вызова |
yes | функция очистки и per-build доказаны (a, c); defer в buildWithBuildkitClient (:174) не покрыт — мутация f выжила |
|
| Обе базы недоступны → лог, редирект пропущен, ошибка BuildKit | yes | buildkit.go:139–144 — только чтение | |
| При записываемом штатном каталоге поведение прежнее | yes | ✅ | KeepsWritableConfigDir; мутация g убита |
Issues
- Critical — нет.
- Major —
server/pkg/docker/buildkit.go:173–174: единственная проводка, делающая истинным «ничего не переживает сборку», не имеет теста — мутация «убратьdefer removeTokenSeeds()» проходит набор зелёным. Герметичный тест возможен:bkclient.New(ctx, "tcp://<закрытый порт>")+buildWithBuildkitClient(...)с ненаписываемымconfig.Dir()→connection refused, и «новыхtrdl-docker-config-*не осталось» убивает мутанта (проверено пробным тестом в копии ревьюера). - Minor —
buildkit.go:102: ошибкаos.RemoveAllмолча отбрасывается. - Minor —
buildkit_config_dir_test.go:114–124: тесты наблюдают общий для машины каталог (/dev/shm,os.TempDir()); падение любогоrequireдоcleanup()оставляет каталоги; чужой остаток с совпавшим именем сделал один замер мутации ревьюера недействительным. - Minor —
buildkit_config_dir_test.go:92–106:GetAuthConfig→GetCredentialsStoreможет запуститьdocker-credential-*изPATHи прочитатьDOCKER_AUTH_CONFIG. - Minor —
buildkit.go:106–125: комментарий привязан кvar dockerConfigDirMu, а описывает функцию; часть пересказывает порядок вызовов.
Risks
| № | Risk | Type | Likelihood | Severity | Location | Circumstances | Consequences | Recommendation |
|---|---|---|---|---|---|---|---|---|
| 1 | Каталог seeds переживает процесс | Operational | Unlikely | Low | buildkit.go:102, :174 | SIGKILL/OOM плагина во время сборки во внешнем режиме | 0700-каталог с 16-байтным seed в /tmp; в builtin /dev/shm/emptyDir умирают с подом |
Принять |
| 2 | Окно редиректа видно другому потребителю docker/cli в том же процессе |
Technical | Unlikely | Low | buildkit.go:148–149 | другой плагин в builtin-процессе читает config.Dir() в те же микросекунды |
чужой config.Dir() вернёт seed-каталог |
Принять; в server/ других вызовов нет |
| 3 | Флак и мусор от тестов, наблюдающих общий каталог | Technical | Possible | Low | buildkit_config_dir_test.go:114–124 | два прогона на одной машине; падение require |
ложный красный; каталоги в $TMPDIR |
t.Cleanup(cleanup), t.Setenv("TMPDIR", …) |
| 4 | Повторный запрос токенов на каждую сборку на общем buildkitd |
UX/Product | Likely | Low | buildkit.go:136; authorizer.go:77–87 |
buildkitd_address + аутентифицированный реестр |
лишний token-запрос на сборку | задокументировано в PR |
Mutation table
macOS arm64; /dev/shm отсутствует, работала ветка os.TempDir(). После каждого раунда git checkout -- и git diff --quiet HEAD → чисто.
| Mutation | Test that must fail | Result |
|---|---|---|
(a) os.RemoveAll(seedDir) → no-op |
SeedsGoToPrivateDir…, EveryBuild… |
Killed, ровно эти две |
(b) восстановление config.Dir() убрано |
те же две | Killed, ровно эти две |
(c) один общий каталог вместо MkdirTemp |
EveryBuild… («1, not 8») |
Killed |
| (d) мьютекс убран | EveryBuild… + DATA RACE |
Killed: 7 каталогов вместо 8, 5 DATA RACE |
(e) NewDockerAuthProvider до редиректа |
SeedsGoToPrivateDir…, EveryBuild… |
Killed |
(f) defer removeTokenSeeds() → _ = removeTokenSeeds |
ничего в наборе | Survived |
(g) проба err == nil инвертирована |
KeepsWritableConfigDir |
Killed |
Ворота: golangci-lint v2.11.0 0 issues.; prettier не выполнился (удалённый docker-хост недоступен); task --yes server:test:unit — Test Suite Passed.
Not verified
task server:build(только Linux/CGO); ветка/dev/shm(macOS); ветка «обе базы недоступны»; стендовые прогоны из комментариев PR; prettier;task e2e:test:e2e; другие потребителиconfig.Dir()в builtin-процессе Stronghold вне этого репозитория.
Author's response
Принято в 021b17c:
- Major:
TestBuildWithBuildkitClient_RemovesTokenSeedsAfterTheBuild— слушатель на127.0.0.1:0, закрыт,bkclient.New("tcp://<addr>"),buildWithBuildkitClientс ненаписываемымconfig.Dir()→ ошибка, новых каталогов нет. Мутация (f) теперь убита ровно этим тестом; (a) дополнительно ловится им же. os.RemoveAll— ошибка логируется через logboek.- Тесты изолированы:
t.Setenv("TMPDIR", t.TempDir()),PATH="",DOCKER_AUTH_CONFIG=""; каждыйcleanupзарегистрирован черезt.Cleanup, падениеrequireкаталогов не оставляет. - Комментарий перенесён на функцию, пересказ порядка вызовов убран; у
dockerConfigDirMu— одна строка о том, почему мьютекс нужен.
Риски 1, 2, 4 приняты как описано в отчёте.
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 underos.TempDir()when the default dir cannot be created.Key changes
server/pkg/docker/buildkit.go:buildkitSessionAttachablesreadsconfig.jsonfrom the default location, thenuseWritableDockerConfigDirForTokenSeedstriesos.MkdirAll(config.Dir())and, on failure, creates a directory private to this build withos.MkdirTemp(mode 0700) under the memory-backed/dev/shmwhere it exists, else underos.TempDir(), and pointsconfig.SetDirat it for exactly as long asauthprovider.NewDockerAuthProviderneeds to captureconfig.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, whichbuildWithBuildkitClientdefers, 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 throughGetTokenAuthority, 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) leavesconfig.Dir()untouched after the call, one private 0700 dir with.token_seedappears 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, noHOME, only/tmpmounted writable. With #426 applied the release reaches the build, the kubernetes builder pod starts, and the solve fails on#1 [internal] load remote build contextwithmkdir /.docker: read-only file system—tokenSeeds.getSeedinsession/auth/authprovider/tokenseed.godoesos.MkdirAll(config.Dir()), andconfig.Dir()resolves to/.dockerthere. Both BuildKit client paths (buildkitd_addressand 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.SetDiris process-global state indocker/cli; the redirect is held only across provider construction, underdockerConfigDirMu, and restored, so laterLoadDefaultConfigFilecalls (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.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/shmthe seed never reaches a disk. The file is neither a token nor a credential (see the code comment).os.MkdirTempfails the redirect is skipped with a log line and BuildKit's originalmkdirerror surfaces unchanged.🤖 Generated with Claude Code