feat(server): build releases in kubernetes without the docker binary - #422
Conversation
Until now a plugin without the docker binary next to it could only build against a buildkitd somebody else keeps running: buildkitd_address removes the binary but not the daemon, so it outlives the build and is shared with whatever else points at it. The buildx kubernetes driver does provision one builder per build, but reaches Kubernetes through the docker CLI, so it is unreachable there. The two properties could not be had together. Add buildkitd_driver and buildkitd_driver_opts to configure. With buildkitd_driver=kubernetes the release creates a buildkitd pod, waits for its readiness probe, streams the build over pods/exec with the BuildKit client, and deletes the pod when the build ends, including when it fails, is canceled, or never becomes ready. No external binary is executed on that path, and the plugin needs no network route to the builder pod. The builder is a bare pod rather than a Deployment: a replacement pod would be a builder the release is not connected to, and apps/v1 carries no activeDeadlineSeconds at all, so a Deployment builder outlives a plugin that dies mid-build forever. The deadline option caps that for the pod. The options are this driver's own vocabulary, validated when the configuration is written rather than by a release that fails later, and the fields are rejected next to buildkitd_address or the buildx pair. They have no environment counterpart deliberately: an environment twin is what makes a value impossible to reject at write time. The pod calls go through a plain REST client instead of the generated typed client, which reaches every API group and its apply configurations. That is the same trim buildx made for its own driver in v0.33.0 and is worth 23 MB of plugin binary here. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
The driver that provisions the builder itself had unit coverage only. Add a second opt-in job to the in-cluster workflow, running the flow_vault suite against a kind cluster with buildkitd_driver supplied through configure. TRDL_BUILDX_DRIVER names a driver that cannot work, so the release passes only while the docker CLI path stays unused: a regression routing back to it fails on that value, the way e2e_buildx_config guards the buildx driver. The job also fails if a builder pod is left in the namespace after the suite, which is the property the cleanup exists for. The flow_vault harness gains TRDL_TEST_BUILDKITD_DRIVER and TRDL_TEST_BUILDKITD_DRIVER_OPTS alongside the two knobs it already has, and e2e/go.mod is tidied because the e2e module consumes the server module through a replace. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
VerificationA first run against a real cluster failed, and the failure was a real defect rather than a flaky job — that run is the reason for the fourth commit.
Review focus
Follow-up
|
Three defects in the previous two commits, all self-inflicted. The section renamed to "Building without the docker CLI" also covers buildkitd_address, and stated that both settings invoke no external binary. The scheme list four paragraphs below says the opposite for two of the four schemes: docker-container:// and kube-pod:// shell out to docker and kubectl. Rename the section after what it actually groups — the two ways to build without the buildx drivers — and say per setting which of them needs a binary. The PodSecurity note gave only the rootless reason for needing a privileged namespace. By default the builder container runs privileged, which baseline forbids on its own; the seccomp/AppArmor pair is what the rootless variant needs instead. Both are now stated. The e2e assertion that no builder pod survives the suite swallowed kubectl's stderr and forced a zero exit, so an unreachable cluster produced an empty pod list and passed the check without running it. Probe the API first and let the query fail the step, and print the pod count as a positive marker that it ran. Also document that deadline is activeDeadlineSeconds and kills a build that is still running when it expires, not only an abandoned one, and that the namespace falls back to `default` when neither the token nor the kubeconfig names one. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
The kubernetes buildkitd driver failed every release against a real cluster with "rpc error: code = Unavailable desc = error reading from server: context canceled", moments after the builder pod came up. The exec stream was scoped to the context gRPC hands a dialer. gRPC scopes that context to a single connection attempt and cancels it once the transport is established, so the stream the connection is made of was torn down immediately after every successful dial. buildx avoids this in the same place by discarding the dialer's context and using the one its Client call was given; do the same. Three further defects found in the same review: A pod the API server had already persisted was left behind when the create response was lost, because the error path returned before the cleanup. Deleting on that path too is best effort and usually a no-op. ParseQuantity accepts signed quantities, so requests.cpu=-1 passed configure and produced a pod the API server rejects only when a release runs. Negative quantities are refused when the configuration is written. activeDeadlineSeconds is whole seconds and must be at least one, so deadline=500ms truncated to 0 and deadline=1500ms silently lost its remainder. Both are now rejected. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
…update path Deleting the two lines that carry buildkitd_driver and its options from the stored configuration into the release build left every required check green: the build falls back to the docker CLI, which works on the runner. The dispatch-only cluster job did not cover it, because it has to be triggered by hand. Add the mirror image of e2e_buildx_config, cluster-free and inverted. The configuration names the kubernetes driver, which cannot come up without a cluster, while the environment names a buildx driver that works, so the release has to fail and to fail with the driver's own error. If the value stops being forwarded, the docker CLI path succeeds and the job refuses that. The specs carry a label so the default suites do not run them, skip themselves when the variable is absent, and the job checks that specs actually ran, so the skip cannot quietly empty it. Every rejection the previous commits added was exercised on the create path only, while the description claimed a rejected write leaves an already stored configuration untouched. Cover all five on the update path and assert the stored document is unchanged. Also state in both QUICKSTART locales what the driver puts in the administrator's hands: write access to a project's configure chooses the namespace, the ServiceAccount and the image of a privileged pod created with the plugin's own credentials, wherever its Role reaches. And drop a dead assignment to opts.timeout overwritten on the next line. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
…ports The guard's first run did its job and failed on its own assertion. Without a kubeconfig the driver stops at client configuration, before it ever names a pod, so expecting "builder pod" was wrong; accept either that or the client-configuration error, both of which only the kubernetes driver produces. The docker CLI path would have succeeded instead. The same run exposed two mistakes of mine in the spec. Its PGP map used invented addresses where the fixtures are key fingerprints, which left gpg deleting keys that had never been imported. And registering a second spec in this suite broke the pre-existing job: the suite's AfterEach runs server:dev:cleanup, whose first line removes the plugin binary that BeforeSuite builds once for the whole suite, so any second spec — even a skipped one, since a runtime Skip still runs AfterEach — leaves the next spec unable to enable the plugin. Register the guard only when the job asks for it, so the default suites are byte for byte what they were. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
1a5550f to
d685af2
Compare
The forwarding guard's suite passed and the job still failed: the step that proves the guard was not empty anchored its grep at the start of the line, and ginkgo colours that line, so it begins with an escape sequence rather than with "Ran". Drop the anchor and match the whole phrase. Reproduced against a log line carrying the real escape codes: the old pattern does not match it, the new one does. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
alexey-igrychev
left a comment
There was a problem hiding this comment.
Code Review Report
Base: main @ 443fdd352d8a43c35ce0c1771fbe5610de6cbbdf
Diff: 21 files, +2115/-76
Verdict
Request changes. Kubernetes driver does not support the documented in-cluster ServiceAccount path; the optional cluster workflow runs incompatible specs; and the required forwarding guard does not prove forwarding of buildkitd_driver_opts.
Issues
-
Major — documented in-cluster ServiceAccount resolution is absent.
server/pkg/docker/kubernetes.go:87-104only loads a kubeconfig throughclientcmd; it never usesrest.InClusterConfig(). A Vault plugin running inside a Pod with only its default ServiceAccount credentials fails withunable to configure the kubernetes client, and cannot default to the ServiceAccount namespace. This contradictsdocs/pages_en/QUICKSTART.md:107,119and the Russian counterpart.
Fix: prefer in-cluster configuration when available, fall back to kubeconfig, derive the namespace from the ServiceAccount namespace file, and add coverage for both sources. -
Major — the Kubernetes dispatch workflow cannot complete its intended suite.
.github/workflows/tests_buildx_kubernetes_driver.yaml:95-96enablesTRDL_TEST_BUILDKITD_DRIVER=kubernetes, which registers the negative forwarding spec ine2e/tests/flow_vault/buildkitd_driver_forwarding_test.go:26-32. The workflow then runs the whole suite (tests_buildx_kubernetes_driver.yaml:144-145). After the first of two specs,AfterEachremoves the plugin binary (e2e/tests/flow_vault/suite_test.go:54-59;server/Taskfile.yaml:182-192) built only once inBeforeSuite, so the second spec cannot enable the plugin.
Fix: run only the complete-cycle spec, e.g.task --yes e2e:test:e2e paths='./tests/flow_vault' labelFilter='flow'; alternatively register the forwarding guard behind a dedicated environment variable used only by its required job. -
Major — required forwarding CI does not test
buildkitd_driver_opts.
It passes onlynamespace=default(.github/workflows/tests.yml:155-158), equal to the normal fallback; no-kubeconfig execution also fails before options are parsed (server/pkg/docker/kubernetes.go:87-101). I removed onlyBuildkitdDriverOpts: cfg.BuildkitdDriverOptsfromserver/path_release.go:177: the required forwarding test still passed.
Fix: pass a non-default namespace via a minimal kubeconfig pointed at an unreachable API server, and require that namespace in thebuilder pod <namespace>/...error. This makes the options observable and kills the mutation. -
Major — ambiguous pod creation can leak a privileged pod without an actionable error.
Inserver/pkg/docker/kubernetes.go:128-135, the create-error path discardsremoveAfterFailure's error. If POST persisted the pod but its response was lost, and DELETE also fails, users see only the POST error. The readiness-error path correctly reports a left-behind pod at:140-143.
Fix: append/log the cleanup error in the create-error branch, matching the readiness branch, and add POST persist-then-fail + DELETE-fail coverage. -
Major — ServiceAccount security guidance gives a false isolation guarantee.
docs/pages_en/QUICKSTART.md:129says one ServiceAccount without its own RoleBinding prevents access to another principal's permissions. ClusterRoleBindings, including bindings throughsystem:serviceaccountsgroups, still grant privileges. The generated pod uses the selected ServiceAccount and does not disable token automount (server/pkg/docker/kubernetes.go:438-447).
Fix: require a dedicated ServiceAccount whose direct, group, namespaced, and cluster-wide bindings have been audited; do not state that omitting a RoleBinding is sufficient. -
Minor — generated configure reference contradicts the new backend.
docs/_includes/reference/vault_plugin/configure.md:12says Docker CLI is used wheneverbuildkitd_addressis unset, ignoringbuildkitd_driver.
Fix: say Docker CLI is used only when neitherbuildkitd_addressnorbuildkitd_driveris set. -
Minor — E2E option transport splits valid comma-valued options.
e2e/tests/flow_vault/utils.go:241-245splitsTRDL_TEST_BUILDKITD_DRIVER_OPTSby comma, although documented options includenodeselector=disktype=ssd,zone=a.
Fix: use indexed variables or an unambiguous separator, preserving option values verbatim.
Verification
task --yes --taskfile Taskfile.dist.yaml server:test:unit paths=.— passed.task --yes --taskfile Taskfile.dist.yaml server:test:unit paths=./pkg/docker— passed.- Linux
task --yes --taskfile Taskfile.dist.yaml server:build— passed. - Real kind-cluster E2E passed: three Kubernetes-driver releases, BuildKit secrets, and no remaining
trdl-builder-*pods. - Mutation checks killed configure validation, cleanup, readiness, dialer-context, negative quantity, rootless, defaults, ServiceAccount, driver/options validation, and configured driver forwarding.
- Disabling
Builder.Removeleft three builder pods, which the workflow leftover-pod assertion detected. - Failed mutation: deleting only
BuildkitdDriverOptsforwarding left the required forwarding guard green.
Not verified
- Real kubelet enforcement of
deadline/activeDeadlineSecondsremains unverified. - GitHub link checks are red because of a pre-existing GnuPG URL already present in
main, not this PR. - External Snyk status reports two failed checks, but its diagnostic detail was not available through GitHub API.
Recommendation
Do not merge before resolving the Major issues above.
Review methodologyThis report covers PR head
Models and skillsModels
The independent passes were used for adversarial inspection, not as proof by assertion. Findings were accepted only after direct source inspection and, where applicable, runtime or mutation evidence. Skills and project rules
Verification
Mutation evidenceEvery mutation was applied to an implementation copy, followed by immediate restoration and a passing rerun of the relevant suite.
CI and review limitations
Review focus
|
The forwarding guard did not guard the options it was written for. It passed namespace=default, which is also the fallback, and with no kubeconfig the driver failed before options were parsed — so deleting BuildkitdDriverOpts from pathRelease left it green, as the review showed. Give the job a kubeconfig pointing at a closed port and a namespace the kubeconfig does not name, so the driver reaches pod creation and the error has to carry the namespace the options supplied. The dispatch workflow could not finish its own suite. It sets TRDL_TEST_BUILDKITD_DRIVER, which registers the negative forwarding spec, and then ran the whole suite; the suite's AfterEach removes the plugin binary BeforeSuite builds once, so the second spec had nothing to enable. Run only the complete-cycle spec there. A pod the API server may have persisted was reported as a create failure alone, because the create path discarded the cleanup error while the readiness path reports it. Both now say when a pod may have been left behind. The E2E option transport split TRDL_TEST_BUILDKITD_DRIVER_OPTS on commas, which breaks documented values such as nodeselector=disktype=ssd,zone=a. Take one option per TRDL_TEST_BUILDKITD_DRIVER_OPTS_<SUFFIX> variable, as the plugin's own TRDL_BUILDX_DRIVER_OPTS_* already do. The ServiceAccount guidance promised an isolation it cannot give: a namespace without RoleBindings still leaves ClusterRoleBindings and group bindings such as system:serviceaccounts in force, and the builder mounts its token. Say what has to be audited instead. The generated configure reference also still claimed the docker CLI is used whenever buildkitd_address is unset, ignoring buildkitd_driver. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
|
Thanks — six of the seven are fixed in
On 1, I think the finding is incorrect, and I would rather say so than quietly rewrite working code. In-cluster ServiceAccount resolution is present — through
So a plugin in a Pod with only its ServiceAccount does get a working client and the ServiceAccount namespace. What your finding does identify correctly is precedence: Two things from your report I am carrying rather than closing: the |
Throwaway, not for upstream. Reproduces the mutation that SURVIVED the guard in review of werf#422, to prove the reworked guard now kills it. The push trigger is widened to probe/** so the required job runs here. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
|
The guard from finding 3 is now verified in both directions, so the Positive control — PR head Negative control — a throwaway branch carrying exactly the mutation that survived your review, The release under mutation reported That run also exercised finding 4 in the wild, which I had only unit-tested: the message now reads Two notes on the environment while getting there, in case they save you time:
|
Review: changes requested
Minor documentation gaps:
|
Review verification
Not run: a real Kubernetes release/exec/cleanup cycle, rootless Kubernetes 1.36+ flow, and lint. The in-cluster workflow is manual-only; the rootless failure and ineffective option wiring above prevent treating it as adequate evidence. |
alexey-igrychev
left a comment
There was a problem hiding this comment.
Review: changes requested
Independent second pass over head 5054e6a (21 files, +2196/-78 vs merge-base 443fdd3). Findings are left as line comments: two Majors (an unpinned, never-refreshed builder image; a ServiceAccount token mounted into the builder), three Minors (readiness-wait fail-fast, dial returning before the exec stream exists, and the pod cleanup having no required-CI coverage), and one note on a silent environment override.
This is a fresh pass, not an adjudication: the earlier findings were re-checked only for whether they still hold at this head. They do — nothing has been pushed since — and they are not renumbered here. Still open: the rootless AppArmor annotation without the securityContext.appArmorProfile field (server/pkg/docker/kubernetes.go:498); the dead TRDL_TEST_BUILDKITD_DRIVER_OPTS in the manual job (.github/workflows/tests_buildx_kubernetes_driver.yaml:96 versus the _<SUFFIX> form e2e/tests/flow_vault/utils.go:247-255 reads); the unverified kind download (:139-140, which copies the pre-existing step at :65-66); and the two documentation items.
One more note that did not warrant a line comment: empty image=, empty map keys, and label/annotation names that are not valid Kubernetes keys all pass parseKubernetesDriverOpts and fail at pod creation instead. Duplicate scalar options silently take the last value; duplicate map options replace rather than merge.
Architecture
Keep the hand-rolled client-go driver. buildx's kubernetes factory cannot be handed a rest.Config — cc ClientConfig is unexported and driver.Register registers a zero value — so importing it means an undocumented endpoint string or upstream work, for 88+ modules against 38, to make four REST calls against one bare Pod. Confirmed independently by a second review pass on a different model family. The three new direct dependencies (k8s.io/api, k8s.io/apimachinery, k8s.io/client-go) are accepted on that basis.
Review methodology
- Resolved the PR through the GitHub API — metadata, description, the existing review thread and the changed-file list. The base was taken as the actual merge-base with
main(443fdd3), not assumed. This is a fork PR, so the head was fetched asrefs/pull/422/headand checked out locally at5054e6abefore anything was read. - Read the complete diff and every changed file — Go, workflows, e2e, documentation — and traced the configuration path end to end:
configure→ storedconfiguration→pathRelease→BuildReleaseArtifacts→NewBuilder→ pod create /pods/exec/ delete. Pod lifecycle, the exec transport, driver-option parsing and backend selection were examined separately. - Two parallel exploration passes on a small model were commissioned for the CI/e2e wiring and for the documentation-versus-code check. Both were discarded — one returned no output, the other reported literal matches as mismatches — and both areas were then re-done by hand. Nothing from those passes reached this report.
- Every finding was rechecked against the source before inclusion, and the two external claims the report leans on (the AppArmor annotation transition and the kind default node image) were verified against upstream sources rather than the PR description.
- Mutations were named rather than executed: this pass was read-only and modified no source. Where a mutation is the evidence, the predicted outcome and the reasoning behind it are stated so it can be run and falsified.
Models and skills
- Primary synthesis: Anthropic Claude Opus, this session.
- Independent adversarial pass: OpenAI GPT-5.6 Sol Pro (high). Used to challenge severity and to hunt for what the primary pass missed, not as proof by assertion — its findings were accepted only after direct source inspection.
- Discarded exploration passes: Anthropic Claude Haiku 4.5.
- Skills applied:
agent-code-review(the diff's author is an agent and it touches tests and CI) andtest-the-tests(falsifiability of the new suites). ProjectAGENTS.mdfortask-only build/test commands, LSP-first navigation and the security constraints on dependencies and user-facing changes. - Active session modes: Ponytail (full) and Caveman (full). Neither changed severity or verification criteria.
Review verification
- Scope: head
5054e6a, merge-base443fdd3— 21 files,+2196/-78;git diff --checkclean. Read every changed Go, workflow and documentation file, and tracedconfigure→ stored configuration →pathRelease→BuildReleaseArtifacts→NewBuilder→ pod create /pods/exec/ delete. task server:test:unit paths="./pkg/docker/..."— Test Suite Passed (Ginkgo, 5.97s).task server:lint—0 issues, Prettier clean.- External claims checked rather than taken from the diff: KEP-24 phase 3 lands in v1.36 and is exactly "API server stops copying annotations to fields", and kind
v0.32.0— the versiontests_buildx_kubernetes_driver.yaml:13pins — defaults to Kubernetesv1.36.1. The rootless AppArmor gap therefore fails on the very cluster this PR's own job provisions, and that job cannot detect it, because it never setsrootless=true. - Mutations were named, not executed: this pass modified no source. The load-bearing one is deleting the
kubernetesBuilderbranch fromBuilder.Remove; the predicted outcome — from the absence of any test constructing thatBuilder, and from the guard job failing insidebootstrap— is that every unit suite and every required job stays green. Worth running before that finding is dismissed. - A second, independently prompted pass on a different model family reviewed the same head. It confirmed the two Majors and two of the Minors, argued the environment-override item down to an observability nit, and contributed the
dialestablishment race. It found no lifetime bug in theexecConn/bkclient.Close()/ deferred-delete ordering, and no stale-field hole inconfigure— each write rebuilds the whole configuration from the request. - Checked and not findings: in-cluster ServiceAccount resolution is present through
clientcmd's deferred-loading in-cluster fallback; cleanup ordering is correct on every path the plugin controls;SetDeadlinereturning nil mirrors buildx's own exec connection; and the forwarding guard discriminates in both directions as claimed. - Not run: any real cluster, the rootless path, the e2e suites, and the full server unit suite (
pkg/gitneedsgit signatures;pkg/publisher's TUF rotator test is date-dependent and red onmaintoo).
(generated by pi-pi)
|
|
||
| const ( | ||
| buildkitContainerName = "buildkitd" | ||
| defaultBuildkitImage = "moby/buildkit:buildx-stable-1" |
There was a problem hiding this comment.
Major — the builder image is unpinned. The default is a mutable tag, and the rootless default is derived from it.
This is the container that executes project-supplied build instructions and receives every build secret — the project secrets and, when mac signing is configured, the signing certificate, its password and the notary key. Meanwhile trdl refuses to build at all unless the project's own build image carries a digest (server/pkg/config/trdl.go:35 → server/pkg/docker/util.go:13, ErrImageNameWithoutRequiredDigest). The plugin now provisions a privileged, secret-bearing image under a weaker rule than the one it enforces on the project one image over.
Honest counterweight: buildx's own kubernetes driver defaults to the same tag, and the existing docker-CLI path already runs a buildkitd image buildx chose. That explains where the value came from; it does not carry over, because buildx is not the component holding the release keys, and until this PR trdl did not write the pod spec.
Pin both defaults by digest — the rootless default needs its own constant, since -rootless cannot be appended to a digest reference.
(generated by pi-pi)
| switch name { | ||
| case "namespace": | ||
| opts.namespace = strings.TrimSpace(value) | ||
| case "image": |
There was a problem hiding this comment.
image= is accepted as an arbitrary string with no validation — an empty value included, which then fails at pod creation instead of at configure.
Require a digest here the way dockerImage is required to carry one (server/pkg/config/trdl.go:35). Early rejection of options the driver cannot honor is the stated point of a separate option vocabulary, and an unpinned or empty builder image is exactly that class of value.
(generated by pi-pi)
| Containers: []corev1.Container{ | ||
| { | ||
| Name: buildkitContainerName, | ||
| Image: opts.image, |
There was a problem hiding this comment.
No imagePullPolicy is set, so Kubernetes applies IfNotPresent for a non-latest tag: a node that pulled buildx-stable-1 once keeps that layer indefinitely, and the builder silently ages into a buildkit release nobody chose.
imagePullPolicy: Always buys freshness but not reproducibility, and does not protect against tag replacement — a digest-pinned default does both.
(generated by pi-pi)
| }, | ||
| Spec: corev1.PodSpec{ | ||
| RestartPolicy: corev1.RestartPolicyNever, | ||
| ServiceAccountName: opts.serviceAccountName, |
There was a problem hiding this comment.
Major — the builder pod mounts a ServiceAccount token it never uses. AutomountServiceAccountToken is left nil, so the pod receives the namespace's default ServiceAccount token — or the token of whatever serviceaccount= names — inside a privileged container running untrusted build instructions.
buildkitd makes no Kubernetes API calls: every API call this driver makes is made by the plugin through its own restClient. The QUICKSTART already identifies the exposure in prose — "the absence of a RoleBinding in the namespace is not by itself an isolation guarantee, and the builder pod does mount its token" — where one field would remove it.
Set automountServiceAccountToken: false by default, including when a ServiceAccount is named: an SA is still needed for its imagePullSecrets and its PodSecurity identity, and neither requires the token to be projected. If a workload-identity case genuinely needs one, make it an explicit opt-in driver option rather than the default.
(generated by pi-pi)
| for { | ||
| pod, err := b.getPod(ctx) | ||
| switch { | ||
| case err != nil && ctx.Err() == nil: |
There was a problem hiding this comment.
Minor — one API-server blip during the readiness wait kills the release. The first failed GET ends the wait as long as the context is alive, so a 429, a 500 or an etcd timeout at any point in a two-minute readiness wait aborts the release and deletes a builder that may have been seconds from ready.
The buildx kubernetes driver keeps the last error and retries until its timeout. Suggested: retry the retryable classes (429, 5xx, transport timeouts) until the readiness timeout expires, and keep the immediate failure for permanent ones (403, 404) — better than a retry-everything loop, which would hide a permanent 403 for the whole timeout.
(generated by pi-pi)
| stdinReader, stdinWriter := io.Pipe() | ||
| stdoutReader, stdoutWriter := io.Pipe() | ||
|
|
||
| go func() { |
There was a problem hiding this comment.
Minor — dial reports success before the exec stream exists. The stream is started in a goroutine and a net.Conn is returned immediately, so a failure to establish the SPDY upgrade — API server to kubelet — never reaches the dialer. It surfaces later as an opaque gRPC read/write error naming no pod and no namespace, and gRPC's dial-level retry has nothing to act on. The same structural race has been reported against buildx's own exec connection.
Suggested: have dial wait for the stream to be established, or for the first error, before returning the connection.
Related gap: nothing tests execConn at all. CloseWrite/CloseRead are exactly what keeps the gRPC transport from waiting on an EOF that never comes, and the only test touching this file asserts that the dialer's context is ignored.
(generated by pi-pi)
| return nil | ||
| } | ||
|
|
||
| if b.kubernetesBuilder != nil { |
There was a problem hiding this comment.
Minor — required CI cannot catch the loss of the pod cleanup. No test constructs a Builder with a non-nil kubernetesBuilder (the only occurrence in the test tree is assert.Nil(t, builder.kubernetesBuilder, ...) in buildkit_test.go:264), so both routing branches — Build at builder.go:301 and this one — execute only in the workflow_dispatch-only cluster job.
Named mutation: delete this branch. Predicted outcome — every unit suite stays green, and so does the required e2e_buildkitd_driver_forwarding job, because that guard is designed to fail inside bootstrap and never reaches a successful build's deferred cleanup (build.go:120-128). "The pod is deleted when the build ends" is the headline safety property of this change and the one thing no required check exercises.
One Builder-level test against the existing fakeAPIServer — construct the Builder, call Remove, assert the DELETE was issued — closes the hole without making the cluster job required.
(generated by pi-pi)
|
|
||
| builderName := fmt.Sprintf("trdl-builder-%s", opts.BuildId) | ||
|
|
||
| if strings.TrimSpace(opts.BuildkitdDriver) != "" { |
There was a problem hiding this comment.
Note, not a change request. With buildkitd_driver configured this branch is taken before buildxCreateArgs, so a process-wide TRDL_BUILDX_DRIVER / TRDL_BUILDX_DRIVER_OPTS_* is ignored without a word, while the mirror case a few lines up logs "the configured … settings are not used".
Configured-over-environment precedence makes the silence defensible; the asymmetry is worth a line of log. (The manual cluster job deliberately relies on the silence with TRDL_BUILDX_DRIVER: this-driver-does-not-exist; a log line would not break it.)
(generated by pi-pi)
Set the AppArmor profile field on the rootless builder container, keeping the deprecated annotation alongside it. The annotation has been deprecated since Kubernetes 1.30 and the field is the supported API, so setting only the annotation leaves the profile to a conversion the cluster is free to stop doing. The manual in-cluster job passed TRDL_TEST_BUILDKITD_DRIVER_OPTS, which nothing reads any more: switching the harness to one option per TRDL_TEST_BUILDKITD_DRIVER_OPTS_<SUFFIX> variable, so that comma-valued options survive, left that job silently falling back to the default namespace. Use the suffixed name there too. Verify kind's checksum before installing it as root, pinned from the release's own kind-linux-amd64.sha256sum, and give the workflow contents: read with no persisted checkout credentials, since it pushes nothing. Two documentation gaps: the buildx exclusion is about the configure fields, while a configured buildkitd_* simply wins over the TRDL_BUILDX_* environment fallbacks, which cannot be rejected per project; and the generated reference now says that the driver options require a driver and that a process-level TRDL_BUILDKITD_ADDRESS wins over a stored driver. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
|
All five are addressed in 1 — fix applied, premise not reproduced. The container now carries I could not reproduce the stated reason, though, and would rather say so than let it stand as established. What I did not do: run this against a live 1.36 cluster, or rule out a feature gate that disables the path. So this is "not reproduced from source", not "disproved" — and if you have the removal reference, I would like it, because it also decides whether the annotation is worth keeping at all. 2 — mine, and a regression I introduced. Fixing the comma-splitting made the harness read only 3 — done. Minor docs — both corrected in the two locales and the regenerated partial. The exclusion is now stated as being about the configure fields, with configured Unchanged from before: rootless has no live-cluster coverage in this PR — the kind job exercises the privileged path — so the AppArmor behaviour above is argued from source on both sides, not from a run. |
Repeating a map-valued driver option silently discarded the earlier pairs: `nodeselector=a=1` followed by `nodeselector=b=2` produced only `b=2`, with no error at configure time or at release time, which puts a privileged builder on nodes the operator excluded. The field's own description asks for one name=value pair per element, so writing the option twice is the natural shape. Merge instead of replacing. A blank `image=` counted as an image being set, suppressing the rootless default and storing a spec whose empty image the API server refuses only when a release runs. Blank means "not set" here as everywhere else. One failed read of the builder pod ended the whole wait, so a single 500, a connection reset while an API server replica rolls, or a 429 from a fairness queue failed a release the two-minute wait exists to ride out. Poll on and report the last error with the timeout. The buildkitd-driver branch ignored `TRDL_BUILDX_DRIVER` without saying so, while the buildkitd-address branch reports exactly that. The guard that proves the forwarding job was not empty could not tell a guard that ran from a guard the label filter skipped: at --vv ginkgo prints a skipped spec's name too, so grepping for it matched either way. Assert on output only the guard itself produces. The comment on execConn justified CloseWrite/CloseRead with a claim about gRPC that is false — gRPC closes the connection outright and never calls either, and buildkit's half-closes happen inside the pod. State what is actually true, including the no-op deadlines. The namespace default was documented in the wrong order: an explicit kubeconfig context namespace wins over the ServiceAccount one. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
The spec matched "builder pod <ns>/", which the readiness log line carries as well. Unreachable while the job has no cluster, but it would become a false positive the moment one is added, so require the create failure specifically, and keep the workflow's post-check marker in step with it. The job comment implied TRDL_BUILDX_DRIVER was load-bearing here; it spells out the fallback the code takes anyway, and now says so. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Review methodologySelf-review of PR head
Models and skills
Two passes converged independently on the same two defects — the blank Verification
Mutation evidence
Two mutations are worth naming rather than counting. Findings, all fixed in this branchEverything below was found by this review and is already fixed; none of it is outstanding.
Not run, and other limitations
|
An `image` option written twice, the second time blank, kept the flag set by the first and so skipped defaulting: configure stored a spec whose empty image the API server refuses at release time. The earlier fix only covered a single blank element; the flag now follows the current value. `deadline=0s` was accepted and meant nothing, while both QUICKSTART files say the minimum is one second. It is now refused, tracked separately from "no deadline given" the way `timeout` already is. A request above its own limit — `requests.cpu=2` with `limits.cpu=1` — was stored and only refused when a release tried to create the pod. Compare the two per resource at write time. Two mutations the required suite could not see are now covered: replacing the exec command with anything other than `buildctl dial-stdio`, and the Kubernetes branch of `Builder.Remove` returning nil instead of deleting. The fake API server records the request URI so the exec command is visible to a test at all. Finally, the documented cleanup promise was too strong: a delete that itself fails leaves a privileged pod behind while the release still reports success. Both locales now say so and point at `deadline`. Signed-off-by: Vasily Marmer <vasily.marmer@flant.com>
Review round 4 — a full-PR pass, and what it caughtThe self-review above covered Three were settled by execution before anything was changed, using a throwaway
Finding 1 deserves naming rather than counting: it is a hole in finding 2 of the round above, fixed earlier the same day. That fix handled a single blank Finding 5 was not fixed in code on purpose. Making a failed cleanup fail the release changes behaviour shared by all three build backends and predates this branch ( Mutation evidence, added to the table above
Both are mutations the previous round listed under Not run: they were reachable only through the opt-in kind workflow. They are now killed by the required unit suite. CI on
|
Review: final pass (kubernetes buildkitd driver)This report covers PR head MAJOR1. The builder pod mounts a ServiceAccount token by default
if opts.serviceAccountName == "" {
pod.Spec.AutomountServiceAccountToken = lo.ToPtr(false)
}Same default as buildx's kubernetes driver, so this is hardening rather than a regression — but the docker-less deployment this PR unlocks was previously out of reach. 2.
|
Review methodologyThis describes how the final-pass review above was produced. It covers PR head
Models and skillsModels
The independent passes were used for adversarial inspection, not as proof by assertion: every accepted finding was re-verified against source before inclusion, and each round re-checked the previous round's edits. Skills and project rules
Verification
Review limitations
|
## Summary Stacked on #422 (base `d7b21fa5ce5b`): the thirteen commits in `d7b21fa..HEAD` are new. They implement four findings from the final review published in #422, plus four findings from a self-review of that work: the privileged builder pod no longer receives a ServiceAccount token it never uses, and its lifetime is always bounded, so a plugin crash mid-build cannot leave a privileged pod running indefinitely. ## What ### Builder pod hardening - Every builder pod gets `automountServiceAccountToken: false`, including when `serviceaccount=` is configured — the configuration the QUICKSTART recommends. Nothing in the pod calls the Kubernetes API: the readiness probe is a local `buildctl`, and the build stream rides `pods/exec` opened with the plugin's own credentials. - Cloud workload-identity keeps working: IRSA (`aws-iam-token`), EKS Pod Identity (`eks-pod-identity-token`) and Azure Workload Identity (`azure-identity-token`) arrive as separate webhook-injected projected volumes, and GKE Workload Identity uses the node metadata server — none is the `kube-api-access-*` volume this field suppresses. - No driver option exposes the old behavior: in-cluster API access from a build has to be asked for explicitly, not inherited from `serviceaccount=`. - Every builder pod carries `activeDeadlineSeconds`: `deadline=` when configured, otherwise the release task's remaining time at pod creation plus a five-minute margin (1h only when the context carries no deadline, unreachable in production). - The cap counts from the moment the pod starts running; a pod that was never scheduled is not bounded by it — stated in the docs rather than silently implied. - The deadline terminates the pod but does not delete the object: a `Failed` pod stays visible until removed by hand or by pod GC — documented. ### Option parsing - `rootless= true`, `deadline= 90m`, `timeout= 10s` (whitespace-padded values) now parse like the other options already did; padded-but-invalid values (`deadline= 90`) still error. - `deadline=0s` is still rejected — the whole-seconds ≥ `1s` validation is untouched. ### Docs - `buildkitd_driver_opts` field description, its generated reference row and both QUICKSTART locales (en/ru) state the unconditional token behavior and give the deadline default as the task's *remaining* time at pod creation, not its full `task_timeout`. - A duplicated doc-comment clause in `builder.go` is dropped — no behavior change. ### Deliberately unchanged - MAJOR 2 from the review (the `buildkitd_driver=kubernetes` value naming) and the remaining MINORs/NITs are not addressed here — maintainers' calls, tracked in the #422 review comment. - The pre-existing `buildx_driver=kubernetes` path orphans a self-healing Deployment on the same crash and has no `deadline` counterpart. Out of scope for this PR. - UNVERIFIED: behavior on a real cluster — the kind e2e job was not run locally; unit tests cover the manifest and parsing, CI covers the rest. ## Why `buildkitPod` suppressed the token only when no `serviceaccount=` was configured, so the documented happy path handed a token carrying that ServiceAccount's full RBAC to a privileged container running untrusted project build instructions. The rationale in the code claimed a configured ServiceAccount must keep the cluster default or IRSA and workload identity would lose their credentials; that is wrong for all four mechanisms, and the built-in ServiceAccount admission plugin runs before mutating webhooks anyway, so `false` cannot strip what a webhook adds afterwards. Separately, `activeDeadlineSeconds` was set only when `deadline=` was configured — with no ownerReferences and all cleanup in-process, a Vault restart or plugin crash mid-build orphaned a privileged, token-carrying pod forever. A constant default deadline was rejected: any fixed value either leaves the orphan alive for hours at the default 30m task timeout or silently kills legitimate builds the moment an operator raises `task_timeout`. Deriving from the task context's remaining time tracks the operator's own knob and guarantees the build context always expires before the pod deadline fires — which is also why the five-minute slack is clock-skew and termination margin, not time for the build to finish. Fixes findings MAJOR 1, MAJOR 3, MINOR 9 and NIT 15 of the review in #422. --------- Signed-off-by: Aleksei Igrychev <aleksei.igrychev@palark.com>
Summary
A project can now build releases with no
dockerbinary next to the plugin and no long-lived BuildKit daemon:buildkitd_driver=kuberneteshas the plugin create a buildkitd Pod for one build, talk to it with the BuildKit client overpods/exec, and delete it afterwards. Until now those two properties were mutually exclusive — the buildx drivers shell out to thedockerCLI, whilebuildkitd_addressneeds a daemon somebody else keeps running and shares between projects and builds.Continues #409 and #419.
What
configureacceptsbuildkitd_driver—kubernetes, empty by default — andbuildkitd_driver_opts, a list ofname=valuepairs.buildkitd_driver=kubernetesa release creates one Podtrdl-builder-<build id>in the target namespace, waits for its readiness probe, streams the build through the API server's exec channel, and deletes the Pod when the build ends. No external binary is executed on any part of that path.create,getanddeleteonpods, pluscreateonpods/exec. A namespaced Role is enough and noappsAPI group is touched. The plugin needs no network route to the builder pod.deadlineoption caps how long an abandoned builder keeps running, viaactiveDeadlineSeconds; it is a whole number of seconds of at least1s, because anything smaller truncates to zero and anything fractional loses its remainder. UNVERIFIED: that the kubelet enforces it as expected here — a dispatch of the opt-in workflow with a shortdeadlinewould settle it.buildkitd_driver_opts=replicas=3, a negative quantity such asrequests.cpu=-1, and a truncatingdeadline=500msall failconfigureand store nothing, instead of failing a release hours later. Accepted names areannotations,deadline,image,labels,limits.cpu,limits.ephemeral-storage,limits.memory,namespace,nodeselector,requests.cpu,requests.ephemeral-storage,requests.memory,rootless,serviceaccountandtimeout.configurerejectsbuildkitd_driverwritten together withbuildkitd_address,buildx_driverorbuildx_driver_opts, and a rejected write leaves an already stored configuration untouched.TRDL_BUILDKITD_ADDRESSset on the Vault process the combination cannot be refused at write time, so the build states that the buildkitd driver settings are unused, in the release log and the plugin log — the treatmentbuildx_driveralready gets.restartPolicy: Never, not a Deployment: a replacement pod would be a builder the release is not connected to, andapps/v1cannot carryactiveDeadlineSecondsat all, so a Deployment builder survives a crashed plugin forever.privilegedby default, because BuildKit needs it;rootless=truetrades that for seccompUnconfinedand theunconfinedAppArmor annotation. Either way the namespace has to admit whatbaselinePodSecurity refuses, and whoever can write a project'sconfigurechooses the namespace, the ServiceAccount and the image of that pod —configurewrite access is pod-create access wherever the plugin's Role reaches. Both QUICKSTART locales state this as the administrator's responsibility.moby/buildkit:buildx-stable-1, or its-rootlessvariant whenrootless=true; readiness wait2m(timeout), the same default buildx uses for the same wait; no deadline unless configured.TRDL_*counterpart, unlike every other build-backend setting. That is deliberate: an environment twin is what makes a value impossible to reject at write time, and rejecting bad options at write time is the point of a separate vocabulary.k8s.io/api,k8s.io/apimachinery,k8s.io/client-go. Measured on this branch as unique modules inserver/go.sumaftergo mod tidyand a successfulgo build ./...: 240 → 278, so 38 new modules, 117 new linked packages, and the plugin binary goes 48.8 MB → 61.1 MB.buildkitd_addressbehave exactly as before, andbuildx_driver/buildx_driver_optskeep meaning what they meant.Why
buildkitd_addresswas added in #409 for a plugin embedded in a distroless image, wheredockercannot exist. It removes the binary but not the daemon: trdl connects to a buildkitd somebody else runs, so that daemon outlives the build and is shared by whatever else points at it. Where the build executes untrusted instructions, a daemon that survives the build and reaches the next project is the thing being designed against — and the buildxkubernetesdriver, which does provision one builder per build, is unreachable there because it goes through thedockerCLI. The ephemeral-builder property and the no-docker property could not be had together.They are separable, because the CLI is not what talks to Kubernetes. The buildx kubernetes driver is pure Go: it creates its objects with
client-goand dials the builder withremotecommandoverpods/exec, executing no external process anywhere on that path. Thedockerrequirement comes only from trdl driving that driver through the CLI wrapper.Which leaves a choice this PR should not make alone.
Import
github.com/docker/buildx/driver/kubernetes. Least new code, and behaviour already proven in the wild. Two things argue against it, both measured rather than estimated. Cost: v0.31.0 adds 88 modules toserver/go.sumand takes the plugin binary to 97.6 MB; v0.36.1 adds 183, forcesgo 1.26.3on the module, and pulls buildkit, docker/cli and containerd up with it. For scale, #409 accepted 46 modules and +8.9 MB — that is a precedent, not a rule, but 88 is twice it. Blocker: buildx's kubernetes factory cannot be handed arest.Config.driver.Register(&factory{})registers a zero value and itscc ClientConfigfield is unexported, so the in-cluster path is reachable only through an undocumentedEndpointAddr = "kubernetes://"with a nilContextStore, and any other endpoint nil-panics insideConfigFromEndpoint.A minimal implementation on
client-go, which is what this PR does: 38 modules, +12.3 MB, and roughly 400 lines. The pod calls go through a plain REST client rather than the generated typed client, which reaches every API group and its apply configurations — the same trim buildx made for itself in v0.33.0 (5a7f7c28, "kubernetes: trim client-go dependency surface"), and worth 23 MB of binary here. The cost is that the Deployment/StatefulSet manifest matrix, the pod chooser and the driver-opt surface are now trdl's to maintain, and the option vocabulary is a documented subset rather than all of buildx's.If the answer is the first one, the conversation is worth having about driver factories generally rather than the kubernetes one alone: buildx's
remotedriver would come with it, and it already implements TLS to an external buildkitd throughcacert/cert/key/servernamedriver-opts — whichbuildkitd_addresshas no answer for today. The honest caveat is that it reads that material from files by absolute path, which a plugin holding its configuration in Vault does not have.On naming:
buildx_driver_mode=in-processreusingbuildx_driver_optswas the first shape tried and was dropped. It keeps the buildx name for a path with no buildx in it; ten of buildx's documented kubernetes driver-opts would be accepted byconfigureand rejected only at release time, which is whatff76158refused to allow for this exact field pair; and a mode field valid for exactly one value of a sibling field encodes three states in four cells. Abuildkitd_*field also follows the precedent already in this surface — an externally managed daemon was not modelled asbuildx_driver=remoteeither.