Skip to content

feat(server): build via BuildKit client when buildkitd address is set - #409

Merged
alexey-igrychev merged 11 commits into
mainfrom
feat/server/buildkit-client-builder
Aug 5, 2026
Merged

feat(server): build via BuildKit client when buildkitd address is set#409
alexey-igrychev merged 11 commits into
mainfrom
feat/server/buildkit-client-builder

Conversation

@alexey-igrychev

Copy link
Copy Markdown
Member

Summary

Adds an alternative release-build path that talks to an already running buildkitd through the BuildKit Go client instead of shelling out to docker buildx. The address is set per project via configure (buildkitd_address) or process-wide via TRDL_BUILDKITD_ADDRESS; with no address set, the docker buildx path is unchanged.

Continues #408 by @vmrm — their four commits are taken as-is, with four commits on top that close the findings from reviewing them.

Why

The secret engine is also compiled into host processes shipped in distroless images that contain no docker binary and no docker socket. There exec.CommandContext(ctx, "docker", …) fails with executable file not found before the buildx driver choice from #398 can matter at all. Pointing the plugin at an external buildkitd removes the CLI dependency for unix:///tcp:// while keeping the docker CLI as the default for every existing installation.

The address lives in per-project configure rather than only in the environment because module-based deployments have no way to inject env vars into the Vault pod, and configure is already the per-project channel for s3/git/quorum settings.

Key changes

From #408:

  • server/pkg/docker/buildkit.go — the release build mapped onto a single Solve against an external buildkitd: frontend dockerfile.v0, in-context service Dockerfile via filename, no-cache, image-resolve-mode=pull, the context tar streamed through the session upload provider, build secrets and mac-signing credentials served by secretsprovider.FromMap under the ids the generated Dockerfile mounts, tar exporter writing into the same pipe the buildx path writes to, progress through the existing logger.
  • server/pkg/docker/builder.goNewBuilder returns a BuildKit-mode builder when an address resolves: no buildx create, and Remove is a no-op since nothing is provisioned per build.
  • server/path_configure.go — optional configure field buildkitd_address, validated at configure time against a scheme allowlist (unix, tcp, docker-container, kube-pod), fail-closed like the feat(server): make buildx driver configurable via env #398 driver allowlist. Per-project config wins over the env fallback.
  • Dependencies: github.com/moby/buildkit v0.31.2 direct, golang.org/x/sync promoted from indirect, 46 new modules overall, plugin binary 42.6 MB → 51.5 MB per the measurements in feat(server): build via BuildKit client when buildkitd address is set #408 (not re-measured here). containerd/v2 pinned to v2.2.5 and klauspost/compress to v1.18.7 so the module-level govulncheck advisory set matches main.

On top of that:

  • fix(server): the Solve session now also attaches the docker-config auth provider — without it no registry credentials reached buildkitd, so with image-resolve-mode=pull and no cache a private base image could not be resolved at all, while the CLI path gets those credentials through buildx. Builder.Build closes the context reader on return: the upload provider closes it only once buildkitd pulls the context, so a Solve failing earlier left the goroutine streaming the context blocked on write forever with its 64 MiB buffer. An address whose scheme carries no endpoint (unix://) is now rejected at configure time instead of failing on the next release. logWriter returns a wait function, so the tail of a build log is not dropped and the exec path stops leaking its scanner goroutine.
  • test(server): the session wiring, the context release and the log drain are asserted against behaviour rather than against the maps the same helpers build. server:test:ai runs everything behind the ai_tests tag, which no task ran before.
  • ci: job ai_server starts moby/buildkit:v0.31.2 and points the smoke test at it, so the only test that exercises the Solve path stops being a no-op.
  • docs: the buildkitd section states that the build context, the build secrets and the mac-signing credentials travel over that connection, that the client neither encrypts tcp:// nor authenticates the daemon, that the address is a trust boundary for whoever can write configure, and that one daemon is shared by every project pointed at it — all of it the administrator's responsibility.

github.com/docker/cli becomes a direct dependency (already in the graph via the connhelpers) and adds docker/docker-credential-helpers as an indirect one; both were already in go.sum.

Verification

  • Live run against buildkitd v0.31.2 over tcp://, using the same recipe the new CI job uses: streamed tar context in, .trdl/Dockerfile picked by filename, a secret mount read back out of the exported artifacts tar (TestAI_BuildkitSmoke, 2s).
  • Mutations run against the new tests, each failing only the test that covers it: dropping the auth provider from the session; removing defer contextReader.Close() (the producer test then reports the producer still blocked); removing <-done from the log wait; removing the empty-endpoint check. Before these tests existed, removing the context uploader from the session — which breaks every build — left the whole suite green.
  • Not run: the quill-stub mac-signing e2e from feat(server): make buildx driver configurable via env #398 against a buildkitd address; the docker-container:// and kube-pod:// transports live (only tcp:// was exercised); binary size after the docker/cli/cli/config addition.

Review focus / risks

  • server/pkg/docker/buildkit.go — the Solve mapping and the session attachables.
  • tcp:// is plaintext and unauthenticated: the client has no TLS options, so an operator who ignores the documented requirement ships build secrets and the mac-signing notary key in the clear. Adding mTLS options is deliberately left to a separate change; the current mitigation is documentation only.
  • Behaviour differences when, and only when, an address is set: no per-build builder lifecycle, so concurrent releases share one buildkitd and its gc/parallelism limits; secrets are no longer exported into the plugin process environment.
  • go.sum churn: MVS bumps of existing indirect deps alongside the new modules.
  • Pre-existing and untouched here: the generated Dockerfile mounts certificate_password unconditionally, so a passwordless mac-signing certificate fails in the signing stage on both paths; a build log line above 64 KB kills the bufio.Scanner in logWriter and blocks the release.

After merge

vmrm and others added 9 commits August 3, 2026 17:10
Release artifacts are built by shelling out to the docker CLI (buildx
create/build/rm), which fails with "executable file not found" when the
plugin runs in an environment without the docker binary, e.g. compiled
into a process shipped in a distroless image. Follow up on the
configurable buildx driver (#398), which made the builder configurable
but still requires the CLI.

Introduce an alternative build path that talks to an already running
buildkitd directly through github.com/moby/buildkit/client: the per-build
buildx builder provisioning and removal disappear, and the build maps to
a single Solve with the dockerfile.v0 frontend (context tar streamed via
the session upload provider, secrets via secretsprovider, tar exporter
into the same pipe the buildx path writes to).

The buildkitd address is set per project via the configure endpoint
(buildkitd_address) or, as a process-wide fallback, via the
TRDL_BUILDKITD_ADDRESS env var; the per-project value wins. Supported
address schemes: unix://, tcp:// (direct gRPC), docker-container://,
kube-pod:// (via docker/kubectl exec). With no address set the docker
CLI path stays byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
govulncheck reports five advisories against the v2.2.4 buildkit pulls
in transitively (GO-2026-5064/5338/5475/5622/5758), all fixed in v2.2.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
The kubernetes-driver notes added in #398 said rootless BuildKit requires
the `baseline` PodSecurity level. It does not fit `baseline` either:
buildx's rootless pod spec sets `seccompProfile: Unconfined` and the
`unconfined` AppArmor annotation (driver/kubernetes/manifest/manifest.go),
and Unconfined is rejected by both the Seccomp and the AppArmor control at
`baseline`. The builder namespace has to be `privileged` or exempt from
PodSecurity admission.

This also removes the contradiction with the external-buildkitd section
added by this PR, which already states that BuildKit needs a relaxed
seccomp/AppArmor profile even when rootless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
v1.18.6, pulled in by buildkit, carries GO-2026-5841 (out-of-bounds read
in the s2 decoder), fixed in v1.18.7. The symbol is not reachable from
trdl, but it was the only module-level advisory this branch added over
main.

With the bump, govulncheck reports exactly the same module-level set as
main -- GO-2026-5932 (x/crypto/openpgp, unmaintained) and GO-2022-0646 /
GO-2022-0635 (aws-sdk-go v1 S3 crypto), none of which have a fixed
version. The plugin binary is byte-identical in size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
… BuildKit mode

The BuildKit client path attached only the context uploader and the secrets
provider to the Solve session, so no registry credentials ever reached
buildkitd: with image-resolve-mode=pull and no cache, a private base image
could not be resolved at all, while the docker CLI path receives those
credentials from the docker config through buildx. Attach the same
docker-config auth provider.

The goroutine streaming the build context blocked on write forever whenever a
build stopped consuming it: the upload provider closes the reader only once
buildkitd pulls the context, so a Solve failing before that leaked the
goroutine and its 64 MiB buffer on every failed release. Close the reader when
the build returns, which covers the exec path too.

Alongside that, fail closed on an address whose scheme carries no endpoint
instead of failing at build time, wait for the build log to drain instead of
dropping its tail and leaking the exec path scanner goroutine, pass a context
to the exported address validation, and rename ContextPath after what it
actually holds.

github.com/docker/cli becomes a direct dependency and adds
docker/docker-credential-helpers as an indirect one; both were already in
go.sum.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
The tests around the BuildKit path asserted the maps the very same helpers had
just built, so removing the context uploader from the Solve session — which
breaks every build — left the whole suite green. Assert instead that the
session serves the build context, the secrets and the registry credentials,
that a failed build unblocks the context producer, and that the log writer
drains before it returns.

Add server:test:ai, since tests behind the ai_tests tag were never executed by
any task.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
Tests behind the ai_tests build tag never ran anywhere, which left the only
test exercising the BuildKit Solve path a no-op. Start buildkitd in a job and
point the smoke test at it.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
The buildkitd address example used a plaintext tcp:// endpoint without saying
that the build context, the build secrets and the mac signing credentials all
travel over that connection, that the client neither encrypts it nor
authenticates the daemon, and that one daemon is shared by every project
pointed at it. Make securing the channel and isolating the daemon the
administrator's stated responsibility.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
The e2e module consumes the server module through `replace … => ../server`, so
the BuildKit dependency chain added to the server module left its go.mod
incomplete: both `task lint` and `task e2e:test:e2e` failed with "updates to
go.mod needed" before compiling a single test. Run go mod tidy, which also
carries over the go directive bump to 1.25.9 that buildkit requires.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
@alexey-igrychev
alexey-igrychev force-pushed the feat/server/buildkit-client-builder branch from f365285 to 72df4a6 Compare August 5, 2026 15:50
Nothing exercised the chain from `release` through the BuildKit client to the
published artifact: the unit smoke test covers a bare Solve, and the e2e suites
all build through the docker CLI. The flow_vault suite already switches build
backends by environment for the buildx driver check, so give it the same hook
for a buildkitd address and run it in CI against a buildkitd container. The
suite's fixture reads two build secrets inside the build, so the run also
proves the secrets reach the build through the session rather than only that
their ids map.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
`ai_server` read as "AI server" rather than as the kind of tests the job runs,
which is what `unit_server` and `unit_client` express. Rename it to
`ai_tests_server`, and add the new BuildKit e2e job to the coverage job's needs
so its artifact is not uploaded in a race with the job producing it.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>

@vmrm vmrm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I read the seven commits on top of my four from #408 (73a6cbf9..0aac05e5) — they close real gaps, and I'd rather this PR go in than mine.

What the delta fixes, and why each one matters:

  • Registry credentials (buildkitSessionAttachables). The buildx path gets them from the docker config through the CLI; the client path had no auth attachable at all, so image-resolve-mode=pull could only reach public registries. Every private base image would have failed at resolve time, and my e2e never covered that because it pulls public images.
  • Draining the build context (defer contextReader.Close() in Builder.Build). A build that fails before the context is fully read left the goroutine feeding the pipe blocked on write forever. This is a leak on the error path of both builders, not just the BuildKit one.
  • Log tail (logWriter returning a wait function). Closing the pipe writer did not guarantee the scanner goroutine had drained it, so the last lines of a build log — including the interesting ones on failure — could be dropped. Both call sites now wait.
  • Empty endpoint after the scheme. tcp:// alone passed the scheme allowlist and then failed deep inside the client. Rejecting it at configure time is the right place, consistent with the #398 driver allowlist.
  • CI against a real buildkitdEnd-to-end tests (BuildKit client) running the full release cycle over tcp://, plus the ai_tests_server smoke job. This is what #408 was missing: everything there was verified against unit tests and my own stand, and there was no positive marker in CI that the BuildKit path had actually built anything. With TRDL_TEST_BUILDKITD_ADDRESS threaded into the e2e configure, the same flow-vault suite now covers both backends.
  • QUICKSTART security section. Correct, and worth stating explicitly: the plugin ships the whole context and every build secret (including the mac-signing certificate, its password and the notary key) over that connection, tcp:// is plaintext and unauthenticated, and write access to <project>/configure therefore becomes equivalent to access to the release secrets. The daemon-is-shared point is the one people will miss.

All checks are green on 0aac05e5, including the two new jobs.

One nit, not blocking: ValidateBuildkitdAddress and resolveBuildkitdAddress now take a context.Context that neither of them uses — it only ripples out into path_configure.go and the tests. Either use it or drop the parameter; if it's there for a planned reachability check against the address, a // TODO naming that would help.

#408 is closed in favour of this PR.

@alexey-igrychev
alexey-igrychev marked this pull request as ready for review August 5, 2026 18:40
@alexey-igrychev
alexey-igrychev merged commit 43912ab into main Aug 5, 2026
24 checks passed
@alexey-igrychev
alexey-igrychev deleted the feat/server/buildkit-client-builder branch August 5, 2026 18:42
alexey-igrychev added a commit that referenced this pull request Aug 5, 2026
… certificate (#416)

## Summary

Two release-build defects that predate #409 and affect both build paths:
an oversized build log line hangs the release task, and a passwordless
mac signing certificate fails the signer stage.

## Why

Both turn an ordinary situation into a broken release, and neither
reports a cause the operator can act on.

`bufio.Scanner` stops at `bufio.ErrTooLong` on a line above 64 KB, after
which the goroutine reading the pipe returns. The build keeps writing
into that pipe, so the next write blocks forever — `cmd.Run` on the
docker CLI path, the progress display on the BuildKit client path — and
the release task hangs with no error, until the plugin process is
restarted.

The generated service Dockerfile mounts `certificate_password`
unconditionally and reads it with `cat` under `set -e -o pipefail`,
while both build paths served that secret only when the stored password
was non-empty. `--mount=type=secret` defaults to `required=false`, so
BuildKit mounts nothing and the `cat` takes the whole `RUN` down. A
passwordless p12 is legitimate.

## Key changes

- `server/pkg/docker/builder.go` — `logWriter` raises the line limit to
1 MiB and drains the pipe to `io.Discard` in a deferred call, so parsing
that stops for any reason can no longer block the writer. An oversized
line now costs the rest of the build log, reported through the logger,
instead of the release.
- `server/pkg/docker/mac_signing.go`, `server/pkg/docker/buildkit.go` —
the password secret is always served, with an empty value when there is
none, so quill receives an empty `QUILL_SIGN_PASSWORD`. Both paths mount
the same ids and are changed together.
- `server/pkg/docker/buildkit_test.go` —
`TestBuildkitSecretsData_NoPasswordNoCredentials` asserted the defect
(`NotContains` the password id); the passwordless half is replaced by
the new test below, and what remains covers the no-credentials case.

## Verification

- Mutations run, each reverted after the run, each killing exactly the
test that covers it:
- removing the drain from `logWriter` →
`TestAI_LogWriter_OversizedLineDoesNotBlockTheBuild` fails after its 30s
timeout, reporting the blocked writer;
- restoring `if Password != ""` in `buildkitSecretsData` →
`TestAI_MacSigningSecrets_PasswordlessCertificateIsStillServed` fails;
- restoring the same condition in `GetMacSigningCommandMounts` → the
same test fails, so both halves of it discriminate.
- Not run: a release with a passwordless certificate through the
mac-signing e2e suite. Its quill stub validates the five `QUILL_*`
variables, so accepting an empty password there is a change to the
fixture rather than a run of the existing one.

## Review focus / risks

- The 1 MiB limit is a judgement call: below it a line is buffered
whole, above it the remaining log is dropped. The hang is gone either
way, and the drop is logged.
- An empty `QUILL_SIGN_PASSWORD` reaches quill for a passwordless
certificate. This is the intended reading of "no password", but it is a
behaviour change for anyone who stored an empty password expecting the
release to fail.

## After merge

- [ ] Close #410 and #411 (linked by `Fixes` in the commits, so a squash
merge closes them automatically — verify it did).

---------

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
alexey-igrychev added a commit that referenced this pull request Aug 5, 2026
`docs/_includes/reference/cli/trdl_use.md` still described the `trdl
use` help text from before #391, so `task docs:gen` produced a diff on a
clean checkout of `main` and every PR touching the generator picked up
this unrelated change. Regenerated on its own, no hand edits.

Noticed while working on #409, where the same diff appeared and was
reverted to keep that PR scoped.

Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
alexey-igrychev added a commit that referenced this pull request Aug 5, 2026
## Summary

A project can now choose its buildx driver and driver options through
`configure`, instead of only through the environment of the Vault
process. That is the only route available when the secret engine is
compiled into a host process whose environment the administrator cannot
set, where the `kubernetes` driver was unreachable until now.

Continues #412 by @vmrm, rebased onto `main` after #409.

## What

- `configure` accepts `buildx_driver` — `docker-container` or
`kubernetes`, empty by default — and it wins over `TRDL_BUILDX_DRIVER`.
- `configure` accepts `buildx_driver_opts` — a list, one `--driver-opt`
per element, empty by default — and it wins over
`TRDL_BUILDX_DRIVER_OPTS_*`. Elements are passed through verbatim, so
`nodeselector=disktype=ssd,zone=a` stays one option.
- Each of the two settings resolves on its own: configuration, then
environment, then the previous default of `docker-container` with no
options. A field omitted or set to a blank value means "not configured"
and falls back to the environment instead of clearing it, so building
with no options while the environment defines some requires unsetting
those variables.
- An unsupported driver is rejected whatever its source: writing
`buildx_driver=docker` fails `configure` and stores nothing, while an
unsupported `TRDL_BUILDX_DRIVER` still fails when the builder is
created. The error names the setting the value came from.
- `configure` rejects `buildx_driver` or `buildx_driver_opts` written
together with `buildkitd_address`, and leaves an already stored
configuration untouched: that address replaces the buildx path entirely,
so the driver settings would have no effect. Blank values count as unset
on both sides of that check.
- When the address comes from `TRDL_BUILDKITD_ADDRESS` instead, the
combination cannot be refused at write time — the variable is
process-wide and can change after a project is configured — so the
release reports `the configured buildx driver settings are not used` in
both the release task log and the plugin log.
- An update that omits the buildx fields clears the stored ones:
`configure` replaces the whole document.
- With neither field set, `docker buildx create` is invoked exactly as
before, down to the argument list, and a configuration stored before
these fields existed reads back with both fields empty and builds as it
did.
- UNVERIFIED: that `buildx_driver_opts` and `buildkitd_address` reach
the release build at all — deleting their assignment in `pathRelease`
leaves every suite green. The driver now has an end-to-end guard; these
two would need one job each, because the value has to break the build
when it is lost.

## Why

Everything else a project needs already lives in `configure` — the Git
repository, the S3 credentials, the signature quorum — because Vault has
no other per-project channel. The build backend was the exception: it
could only be set through the environment of the process that hosts the
plugin, and a host process that ships the engine as a built-in plugin
may expose no way to set one, which left such an installation on the
default driver permanently.

Environment-only was the alternative, and #409 shows why it does not
hold: the same argument produced `buildkitd_address` as a `configure`
field, so keeping the driver out of `configure` would have split one
decision across two mechanisms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants