From 5800009c3f0e1e76a35a3b79bfff45d02c4ee0c8 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 10:22:20 +0100 Subject: [PATCH 01/13] fix(server, docs): stop mounting a kubernetes token in build pods The builder pod runs project-supplied build instructions in a privileged container. With no ServiceAccount of its own it was still given the namespace's default token, so a build could reach the API server with whatever that account carries. Disable the automount in that case. A configured serviceaccount keeps the cluster default instead of an explicit false, because IRSA, EKS Pod Identity and workload identity all deliver their credentials through the mounted token. Signed-off-by: Aleksei Igrychev --- docs/pages_en/QUICKSTART.md | 2 +- docs/pages_ru/QUICKSTART.md | 2 +- server/pkg/docker/kubernetes.go | 7 +++++++ server/pkg/docker/kubernetes_test.go | 5 ++++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index e8b48f6f..ab40f13c 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -126,7 +126,7 @@ Notes on the pod: Two things follow from the plugin creating the pod with its own credentials, and both are the administrator's to weigh: -* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace, and a ServiceAccount carries whatever is bound to it directly, through a namespaced RoleBinding, through a ClusterRoleBinding, and through group bindings such as `system:serviceaccounts`. The absence of a RoleBinding in the namespace is not by itself an isolation guarantee, and the builder pod does mount its token. +* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace, and a ServiceAccount carries whatever is bound to it directly, through a namespaced RoleBinding, through a ClusterRoleBinding, and through group bindings such as `system:serviceaccounts`. The absence of a RoleBinding in the namespace is not by itself an isolation guarantee, and when a `serviceaccount` is configured the builder pod does mount its token; without one the token is not mounted at all (`automountServiceAccountToken: false`). * **The builder container is privileged by default.** BuildKit needs it; `rootless=true` trades it for seccomp `Unconfined` and the `unconfined` AppArmor annotation. Either way the build executes project-supplied instructions in a container the `baseline` PodSecurity level would refuse, so give it a namespace and nodes you are willing to lose, not the ones the signing keys live on. ##### Connecting to an existing buildkitd diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index d520d6fa..bfd9ea46 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -125,7 +125,7 @@ vault write trdl-test-project/configure ... \ Из того, что под создаёт сам плагин своими кредами, следуют две вещи, и обе — предмет решения администратора: -* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace, а ServiceAccount несёт всё, что привязано к нему напрямую, через namespaced RoleBinding, через ClusterRoleBinding и через групповые привязки вроде `system:serviceaccounts`. Отсутствие RoleBinding в namespace само по себе гарантией изоляции не является, и токен под сборщика монтируется. +* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace, а ServiceAccount несёт всё, что привязано к нему напрямую, через namespaced RoleBinding, через ClusterRoleBinding и через групповые привязки вроде `system:serviceaccounts`. Отсутствие RoleBinding в namespace само по себе гарантией изоляции не является, и если `serviceaccount` задан, токен пода сборщика монтируется; без него токен не монтируется вовсе (`automountServiceAccountToken: false`). * **Контейнер сборщика по умолчанию привилегированный.** Этого требует BuildKit; `rootless=true` меняет привилегию на seccomp `Unconfined` и AppArmor-аннотацию `unconfined`. В любом случае сборка исполняет инструкции проекта в контейнере, который уровень PodSecurity `baseline` не пропустил бы, — значит и namespace, и узлы под неё надо давать те, которые не жалко, а не те, где лежат ключи подписи. ##### Подключение к существующему buildkitd diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index e6bdccef..33680bc5 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -504,6 +504,13 @@ func buildkitPod(name string, opts kubernetesBuilderOpts) *corev1.Pod { }, } + // Without a ServiceAccount of its own the pod would still get the namespace + // default one mounted, handing project-supplied build instructions a token in + // a privileged container. A configured ServiceAccount keeps the cluster + // default, because that is how IRSA and workload identity deliver credentials. + if opts.serviceAccountName == "" { + pod.Spec.AutomountServiceAccountToken = lo.ToPtr(false) + } if opts.deadline > 0 { pod.Spec.ActiveDeadlineSeconds = lo.ToPtr(int64(opts.deadline.Seconds())) } diff --git a/server/pkg/docker/kubernetes_test.go b/server/pkg/docker/kubernetes_test.go index 0a1338e0..3294c3c1 100644 --- a/server/pkg/docker/kubernetes_test.go +++ b/server/pkg/docker/kubernetes_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -390,7 +391,8 @@ func TestBuildkitPodDefaults(t *testing.T) { assert.Equal(t, defaultBuildkitImage, container.Image) assert.True(t, *container.SecurityContext.Privileged) assert.Equal(t, []string{"buildctl", "debug", "workers"}, container.ReadinessProbe.Exec.Command) - assert.Nil(t, pod.Spec.ActiveDeadlineSeconds, "no deadline is set unless one is configured") + assert.Nil(t, pod.Spec.ActiveDeadlineSeconds, "buildkitPod only serializes a deadline it is given; bootstrap is what defaults it") + assert.Equal(t, lo.ToPtr(false), pod.Spec.AutomountServiceAccountToken, "a privileged pod running project instructions must not get the namespace's default token") assert.Equal(t, map[string]string{"app": "trdl-builder-42"}, pod.Labels) } @@ -439,6 +441,7 @@ func TestBuildkitPodAppliesResourcesAndScheduling(t *testing.T) { assert.Equal(t, "4Gi", container.Resources.Limits.Memory().String()) assert.Equal(t, map[string]string{"disktype": "ssd", "zone": "a"}, pod.Spec.NodeSelector) assert.Equal(t, "trdl-buildkit", pod.Spec.ServiceAccountName) + assert.Nil(t, pod.Spec.AutomountServiceAccountToken, "a configured ServiceAccount keeps the cluster default, or IRSA and workload identity lose their token") assert.Equal(t, int64(5400), *pod.Spec.ActiveDeadlineSeconds) assert.Equal(t, "delivery", pod.Labels["team"]) assert.Equal(t, "trdl", pod.Annotations["example.com/owner"]) From b5e033f31b8984fdb5c880b4412f3e3b8dbbe91a Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 10:24:46 +0100 Subject: [PATCH 02/13] fix(server, docs): bound the builder pod's lifetime by default Nothing outside the plugin process deletes the builder pod: a crash or a kill between creating it and removing it left a privileged container running project instructions for as long as the cluster would have it, and only an operator who had set deadline= was protected. Derive the pod's activeDeadlineSeconds from the release task's own context deadline plus a five-minute margin whenever no deadline is configured, so the pod dies shortly after the build that owns it. A configured deadline still wins, and a context without one falls back to an hour. Signed-off-by: Aleksei Igrychev --- .../reference/vault_plugin/configure.md | 2 +- docs/pages_en/QUICKSTART.md | 4 +- docs/pages_ru/QUICKSTART.md | 4 +- server/path_configure.go | 2 +- server/pkg/docker/kubernetes.go | 28 +++++++ server/pkg/docker/kubernetes_test.go | 74 +++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/docs/_includes/reference/vault_plugin/configure.md b/docs/_includes/reference/vault_plugin/configure.md index 6be7cff3..39f224fd 100644 --- a/docs/_includes/reference/vault_plugin/configure.md +++ b/docs/_includes/reference/vault_plugin/configure.md @@ -11,7 +11,7 @@ Configure the plugin. * `buildkitd_address` (string, optional) — An address of a running buildkitd (unix://, tcp://, docker-container:// or kube-pod:// scheme) to build release artifacts with the BuildKit client; the docker CLI is used only when neither this nor buildkitd_driver is set. Build secrets are sent to that daemon, and tcp:// is neither encrypted nor authenticated, so securing the channel and isolating the daemon is the administrator's responsibility. * `buildkitd_driver` (string, optional) — Provision an ephemeral buildkitd per build instead of using the docker CLI: kubernetes runs it as a pod and needs no docker binary next to the plugin. Cannot be combined with buildkitd_address, buildx_driver or buildx_driver_opts. A TRDL_BUILDKITD_ADDRESS set on the process wins over a stored driver, and the build reports the driver as unused. -* `buildkitd_driver_opts` (array, optional) — The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. +* `buildkitd_driver_opts` (array, optional) — The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. When deadline is not set, it defaults to the release task's own timeout plus a five-minute margin, so a plugin crash cannot leave the builder pod running indefinitely. * `buildx_driver` (string, optional) — The buildx driver to build release artifacts with: docker-container (used by default) or kubernetes. Takes precedence over the TRDL_BUILDX_DRIVER environment variable, and cannot be combined with buildkitd_address or buildkitd_driver. * `buildx_driver_opts` (array, optional) — The buildx driver options, one --driver-opt per element (e.g. namespace=trdl-build), passed through as is. Take precedence over the TRDL_BUILDX_DRIVER_OPTS_* environment variables, and cannot be combined with buildkitd_address or buildkitd_driver. * `git_repo_url` (string, required) — URL of the Git repository. diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index ab40f13c..be718304 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -112,7 +112,7 @@ The options are `name=value` pairs, one per list element and passed through as i | `requests.cpu`, `requests.memory`, `requests.ephemeral-storage` | pod resource requests | | `limits.cpu`, `limits.memory`, `limits.ephemeral-storage` | pod resource limits | | `timeout` | how long to wait for the builder to become ready, e.g. `5m`; `2m` by default | -| `deadline` | a hard lifetime cap for the builder pod (`activeDeadlineSeconds`), e.g. `2h`; unset by default, and a whole number of seconds of at least `1s`. It is not a grace period: a build still running when it expires is killed too, so set it above the longest release this project takes | +| `deadline` | a hard lifetime cap for the builder pod (`activeDeadlineSeconds`), e.g. `2h`; defaults to the release task's timeout ([`task_timeout`](/reference/vault_plugin/task/configure.html), `30m` unless configured) plus a five-minute margin; a whole number of seconds of at least `1s`. It is not a grace period: a build still running when it expires is killed too, so set it above the longest release this project takes | The option names are the buildx kubernetes driver's own wherever the two overlap, but the vocabulary is this driver's, not buildx's: options buildx accepts and this driver does not — `replicas`, `loadbalance`, `tolerations`, `schedulername`, `qemu.*`, the persistent-volume options — are rejected, and `deadline` has no buildx counterpart. @@ -121,7 +121,7 @@ What the plugin needs in the target namespace is `create`, `get` and `delete` on Notes on the pod: * the namespace must exist and must admit the builder pod. By default the container runs `privileged`; with `rootless=true` it runs unprivileged but needs seccomp `Unconfined` and the `unconfined` AppArmor annotation instead. Either way the `baseline` PodSecurity level forbids it, so the namespace has to be labelled `privileged` or be exempt from PodSecurity admission — the same requirement the buildx `kubernetes` driver has. Unlike the buildx path, the rejection arrives directly from the `create` call rather than as a readiness timeout; -* the pod is removed when the build ends, including when it fails or is canceled, and when the builder never becomes ready. It is not removed if the plugin's own process dies outright, and not if the delete itself fails — a lost API connection, a withdrawn `delete` permission. That failure is reported in the release log and the plugin log, but it does not fail the release, so a privileged pod can outlive a build that reported success. `deadline` is what bounds both cases, at the cost of also capping a legitimate build; +* the pod is removed when the build ends, including when it fails or is canceled, and when the builder never becomes ready. It is not removed if the plugin's own process dies outright, and not if the delete itself fails — a lost API connection, a withdrawn `delete` permission. That failure is reported in the release log and the plugin log, but it does not fail the release, so a privileged pod can outlive a build that reported success. Both cases are bounded by `activeDeadlineSeconds`, which is always set — `deadline` overrides the default. Note it terminates the pod but does not delete the object: a Failed pod remains visible until removed by hand or by the cluster's pod garbage collection. It is also counted from the moment the pod starts running, so a pod that was never scheduled — no node, a quota rejection — is not capped by it; * the builder is a bare Pod with `restartPolicy: Never`, deliberately: nothing may replace it mid-build, because the replacement would be a builder the release is not connected to. Two things follow from the plugin creating the pod with its own credentials, and both are the administrator's to weigh: diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index bfd9ea46..298c28c0 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -111,7 +111,7 @@ vault write trdl-test-project/configure ... \ | `requests.cpu`, `requests.memory`, `requests.ephemeral-storage` | requests пода | | `limits.cpu`, `limits.memory`, `limits.ephemeral-storage` | limits пода | | `timeout` | сколько ждать готовности сборщика, например `5m`; по умолчанию `2m` | -| `deadline` | жёсткий предел времени жизни пода сборщика (`activeDeadlineSeconds`), например `2h`; по умолчанию не задан, задаётся целым числом секунд не меньше `1s`. Это не льготный период: сборка, которая к этому моменту ещё идёт, тоже будет убита, поэтому значение берут заведомо больше самого долгого релиза проекта | +| `deadline` | жёсткий предел времени жизни пода сборщика (`activeDeadlineSeconds`), например `2h`; по умолчанию — таймаут самой релизной задачи ([`task_timeout`](/reference/vault_plugin/task/configure.html), `30m`, если не настроен иначе) плюс пять минут запаса, задаётся целым числом секунд не меньше `1s`. Это не льготный период: сборка, которая к этому моменту ещё идёт, тоже будет убита, поэтому значение берут заведомо больше самого долгого релиза проекта | Имена опций совпадают с опциями buildx-драйвера `kubernetes` там, где опции пересекаются, но набор здесь свой, а не buildx: опции, которые принимает buildx и не принимает этот драйвер — `replicas`, `loadbalance`, `tolerations`, `schedulername`, `qemu.*`, опции постоянного тома, — отвергаются, а у `deadline` соответствия в buildx нет вовсе. @@ -120,7 +120,7 @@ vault write trdl-test-project/configure ... \ Особенности пода: * namespace должен существовать и должен пропускать под сборщика. По умолчанию контейнер запускается `privileged`; при `rootless=true` он непривилегированный, но требует seccomp `Unconfined` и AppArmor-аннотации `unconfined`. И то и другое запрещено на уровне PodSecurity `baseline`, поэтому namespace должен быть помечен как `privileged` либо исключён из PodSecurity-admission — ровно то же требование, что и у buildx-драйвера `kubernetes`. В отличие от buildx-пути, отказ приходит прямо из вызова `create`, а не в виде таймаута готовности; -* под удаляется по окончании сборки, в том числе при её падении и отмене, а также если сборщик так и не стал готов. Он не удаляется, если сам процесс плагина умер целиком, и если само удаление не прошло — оборвалась связь с API, отозвали право `delete`. Об этом сообщают лог релиза и лог плагина, но релиз при этом не падает, поэтому привилегированный под может пережить сборку, отчитавшуюся успехом. Оба случая ограничивает `deadline` — ценой такого же ограничения для нормальной сборки; +* под удаляется по окончании сборки, в том числе при её падении и отмене, а также если сборщик так и не стал готов. Он не удаляется, если сам процесс плагина умер целиком, и если само удаление не прошло — оборвалась связь с API, отозвали право `delete`. Об этом сообщают лог релиза и лог плагина, но релиз при этом не падает, поэтому привилегированный под может пережить сборку, отчитавшуюся успехом. Оба случая ограничивает `activeDeadlineSeconds`, который выставляется всегда, — `deadline` лишь переопределяет значение по умолчанию. Учтите, что он завершает под, но не удаляет объект: под в фазе `Failed` остаётся виден, пока его не удалят вручную или сборщик мусора подов кластера. Отсчёт идёт с момента запуска пода, поэтому под, который так и не был запланирован — нет узла, отказ по квоте, — им не ограничен; * сборщик — именно отдельный Pod с `restartPolicy: Never`, и это осознанно: его нельзя подменять посреди сборки, потому что замена окажется сборщиком, с которым релиз не связан. Из того, что под создаёт сам плагин своими кредами, следуют две вещи, и обе — предмет решения администратора: diff --git a/server/path_configure.go b/server/path_configure.go index 954f5e0d..3b847215 100644 --- a/server/path_configure.go +++ b/server/path_configure.go @@ -140,7 +140,7 @@ func configurePath(b *Backend) *framework.Path { }, fieldNameBuildkitdDriverOpts: { Type: framework.TypeStringSlice, - Description: "The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected", + Description: "The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. When deadline is not set, it defaults to the release task's own timeout plus a five-minute margin, so a plugin crash cannot leave the builder pod running indefinitely", Required: false, }, }, diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index 33680bc5..9249e7db 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -32,6 +32,13 @@ const ( defaultBuildkitPodTimeout = 2 * time.Minute buildkitPodPollInterval = time.Second buildkitPodCleanupTimeout = 30 * time.Second + + // Long enough for the build to finish and the plugin to delete the pod itself, + // so the deadline only ever fires when the plugin is no longer around to. + buildkitPodDeadlineSlack = 5 * time.Minute + // Fallback for a context carrying no deadline. The release task always sets + // one, so this is what keeps the guarantee for any other caller. + defaultBuildkitPodDeadline = time.Hour ) // supportedKubernetesDriverOpts is this driver's own vocabulary. The names match @@ -123,6 +130,8 @@ func newKubernetesBuilder(ctx context.Context, builderName, driver string, drive // bootstrap removes whatever it created before returning an error, so a failure // between creating the pod and it becoming ready cannot leave a builder running. func (b *kubernetesBuilder) bootstrap(ctx context.Context, opts kubernetesBuilderOpts) error { + opts.deadline = resolvePodDeadline(ctx, opts.deadline) + pod := buildkitPod(b.podName, opts) if err := b.createPod(ctx, pod); err != nil { @@ -459,6 +468,25 @@ func splitKeyValues(value string) (map[string]string, error) { return result, nil } +// resolvePodDeadline bounds the pod's lifetime by the build's own, because +// nothing outside the plugin process deletes the builder: a crash between +// creating the pod and removing it would otherwise leave it running forever. +// The floor keeps an already-expired context from producing a deadline the API +// server rejects, and the whole-second rounding matches what the option itself +// is validated against. +func resolvePodDeadline(ctx context.Context, configured time.Duration) time.Duration { + if configured > 0 { + return configured + } + + deadline, ok := ctx.Deadline() + if !ok { + return defaultBuildkitPodDeadline + } + + return max(time.Until(deadline).Round(time.Second), time.Minute) + buildkitPodDeadlineSlack +} + func buildkitPod(name string, opts kubernetesBuilderOpts) *corev1.Pod { labels := map[string]string{"app": name} for k, v := range opts.labels { diff --git a/server/pkg/docker/kubernetes_test.go b/server/pkg/docker/kubernetes_test.go index 3294c3c1..45c40c75 100644 --- a/server/pkg/docker/kubernetes_test.go +++ b/server/pkg/docker/kubernetes_test.go @@ -147,6 +147,13 @@ func (f *fakeAPIServer) recordedCalls() []string { return append([]string(nil), f.calls...) } +func (f *fakeAPIServer) createdPod() *corev1.Pod { + f.mu.Lock() + defer f.mu.Unlock() + + return f.created.DeepCopy() +} + func (f *fakeAPIServer) podDeleted() bool { f.mu.Lock() defer f.mu.Unlock() @@ -184,6 +191,15 @@ func testContext() context.Context { return logboek.NewContext(context.Background(), logboek.DefaultLogger()) } +func contextWithDeadlineIn(t *testing.T, parent context.Context, d time.Duration) context.Context { + t.Helper() + + ctx, cancel := context.WithDeadline(parent, time.Now().Add(d)) + t.Cleanup(cancel) + + return ctx +} + func testBuilderOpts() kubernetesBuilderOpts { opts, err := parseKubernetesDriverOpts([]string{"namespace=trdl-build"}) if err != nil { @@ -381,6 +397,64 @@ func TestBuilderRemoveDeletesTheProvisionedPod(t *testing.T) { assert.True(t, f.podDeleted(), "removing the builder must delete the pod it provisioned") } +// A pod outliving the plugin process is the case this covers: nothing deletes +// the builder if the plugin is killed mid-build, so every pod must carry a +// deadline even when the operator configured none. +func TestResolvePodDeadline(t *testing.T) { + for name, tc := range map[string]struct { + ctx context.Context + configured time.Duration + expected time.Duration + }{ + "a configured deadline wins over the context": { + ctx: contextWithDeadlineIn(t, context.Background(), 10*time.Minute), + configured: 90 * time.Minute, + expected: 90 * time.Minute, + }, + "the remaining task time plus the slack": { + ctx: contextWithDeadlineIn(t, context.Background(), 30*time.Minute), + expected: 30*time.Minute + buildkitPodDeadlineSlack, + }, + "an expired context still yields a usable deadline": { + ctx: contextWithDeadlineIn(t, context.Background(), -time.Hour), + expected: time.Minute + buildkitPodDeadlineSlack, + }, + "a context without a deadline falls back": { + ctx: context.Background(), + expected: defaultBuildkitPodDeadline, + }, + } { + t.Run(name, func(t *testing.T) { + resolved := resolvePodDeadline(tc.ctx, tc.configured) + + // A deadline derived from the clock loses up to a second to rounding + // between the context being built and this call, so the tolerance is the + // rounding, not the property: the whole-second assertion below is what + // catches a value that skipped rounding altogether. + assert.InDelta(t, tc.expected, resolved, float64(2*time.Second)) + assert.Zero(t, resolved%time.Second, "activeDeadlineSeconds is whole seconds") + assert.GreaterOrEqual(t, resolved, time.Second, "activeDeadlineSeconds must be at least 1s") + }) + } +} + +// The manifest is read back from the fake API server rather than from +// buildkitPod, because buildkitPod has no context and only serializes the +// deadline it is handed: dropping the resolve call would leave it nil here. +func TestKubernetesBuilderBootstrapAlwaysSetsAPodDeadline(t *testing.T) { + f := newFakeAPIServer(t) + f.readyPod = true + + b := newTestBuilder(t, f, time.Minute) + ctx := contextWithDeadlineIn(t, testContext(), 30*time.Minute) + + require.NoError(t, b.bootstrap(ctx, testBuilderOpts())) + + deadline := f.createdPod().Spec.ActiveDeadlineSeconds + require.NotNil(t, deadline, "bootstrap must give every builder pod a deadline") + assert.InDelta(t, int64((30*time.Minute + buildkitPodDeadlineSlack).Seconds()), *deadline, 5) +} + func TestBuildkitPodDefaults(t *testing.T) { pod := buildkitPod("trdl-builder-42", testBuilderOpts()) From a562b01234245763ae20786d63ea8f4364977bbd Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 10:25:30 +0100 Subject: [PATCH 03/13] fix(server): accept padded buildkitd driver option values The namespace, image and serviceaccount options trim their values, but rootless, deadline and timeout handed the raw text to ParseBool and ParseDuration, so a pair written as "deadline= 90m" was rejected with a parse error the operator cannot spot in their own configuration. Trim those three the same way the sibling options already do. Signed-off-by: Aleksei Igrychev --- server/pkg/docker/kubernetes.go | 6 +++--- server/pkg/docker/kubernetes_test.go | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index 9249e7db..014e00c0 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -371,13 +371,13 @@ func parseKubernetesDriverOpts(driverOpts []string) (kubernetesBuilderOpts, erro case "serviceaccount": opts.serviceAccountName = strings.TrimSpace(value) case "rootless": - opts.rootless, err = strconv.ParseBool(value) + opts.rootless, err = strconv.ParseBool(strings.TrimSpace(value)) case "deadline": deadlineSet = true - opts.deadline, err = time.ParseDuration(value) + opts.deadline, err = time.ParseDuration(strings.TrimSpace(value)) case "timeout": timeoutSet = true - opts.timeout, err = time.ParseDuration(value) + opts.timeout, err = time.ParseDuration(strings.TrimSpace(value)) case "nodeselector": err = mergeKeyValues(opts.nodeSelector, value) case "labels": diff --git a/server/pkg/docker/kubernetes_test.go b/server/pkg/docker/kubernetes_test.go index 45c40c75..c30843d1 100644 --- a/server/pkg/docker/kubernetes_test.go +++ b/server/pkg/docker/kubernetes_test.go @@ -556,6 +556,7 @@ func TestParseKubernetesDriverOptsRejections(t *testing.T) { "negative deadline": {"deadline=-1m"}, "sub-second deadline": {"deadline=500ms"}, "truncating deadline": {"deadline=1500ms"}, + "padded bad duration": {"deadline= 90"}, "negative cpu request": {"requests.cpu=-1"}, "negative memory limit": {"limits.memory=-500Mi"}, "non-positive timeout": {"timeout=0s"}, @@ -568,6 +569,18 @@ func TestParseKubernetesDriverOptsRejections(t *testing.T) { } } +// The sibling arms of the same switch trim their values, so a padded pair is a +// shape the parser already accepts elsewhere — rejecting it here only for these +// three options would be an error the operator cannot see in their own config. +func TestParseKubernetesDriverOptsTrimsPaddedValues(t *testing.T) { + opts, err := parseKubernetesDriverOpts([]string{"rootless= true", "deadline= 90m", "timeout= 10s"}) + + require.NoError(t, err) + assert.True(t, opts.rootless) + assert.Equal(t, 90*time.Minute, opts.deadline) + assert.Equal(t, 10*time.Second, opts.timeout) +} + func TestParseKubernetesDriverOptsTimeoutDefaults(t *testing.T) { opts, err := parseKubernetesDriverOpts(nil) From 9408b43441f14c03af0d75b2cc953c7741c1a086 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 10:25:45 +0100 Subject: [PATCH 04/13] refactor(server): drop a duplicated comment clause The doc comment on unusedBuilderSettings ended with the same clause as the inline comment in NewBuilder that explains the environment/configure asymmetry. Keep the explanation in one place. Signed-off-by: Aleksei Igrychev --- server/pkg/docker/builder.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/pkg/docker/builder.go b/server/pkg/docker/builder.go index 980d37db..e8758bc7 100644 --- a/server/pkg/docker/builder.go +++ b/server/pkg/docker/builder.go @@ -145,8 +145,7 @@ func NewBuilder(ctx context.Context, opts *NewBuilderOpts) (*Builder, error) { } // unusedBuilderSettings names the settings a buildkitd address makes unreachable, -// so the build log says which knob is being ignored rather than leaving it to be -// discovered from a builder that never appears. +// so the build log says which knob is being ignored. func unusedBuilderSettings(opts *NewBuilderOpts) []string { var unused []string if strings.TrimSpace(opts.BuildxDriver) != "" || len(trimDriverOpts(opts.BuildxDriverOpts)) > 0 { From 6a7cb920022514d69b742d1de0bdf27f62ee2af8 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:26:11 +0100 Subject: [PATCH 05/13] fix(server): never mount a kubernetes token, even with a serviceaccount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder pod suppressed the API token only when no `serviceaccount=` was configured, so the configuration the QUICKSTART recommends handed a token with that ServiceAccount's full RBAC to a privileged container running project-supplied build instructions. 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. The removed rationale claimed a configured ServiceAccount must keep the cluster default or IRSA and workload identity lose their credentials. That is wrong for all four mechanisms: IRSA injects `aws-iam-token`, EKS Pod Identity injects `eks-pod-identity-token` and Azure Workload Identity injects `azure-identity-token` as separate webhook-mounted projected volumes, while GKE Workload Identity uses the node metadata server — none is the `kube-api-access-*` volume this field suppresses. Signed-off-by: Aleksei Igrychev --- server/pkg/docker/kubernetes.go | 14 ++++---------- server/pkg/docker/kubernetes_test.go | 2 +- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index 014e00c0..33474b1f 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -506,9 +506,10 @@ func buildkitPod(name string, opts kubernetesBuilderOpts) *corev1.Pod { Annotations: annotations, }, Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyNever, - ServiceAccountName: opts.serviceAccountName, - NodeSelector: opts.nodeSelector, + RestartPolicy: corev1.RestartPolicyNever, + ServiceAccountName: opts.serviceAccountName, + AutomountServiceAccountToken: lo.ToPtr(false), + NodeSelector: opts.nodeSelector, Containers: []corev1.Container{ { Name: buildkitContainerName, @@ -532,13 +533,6 @@ func buildkitPod(name string, opts kubernetesBuilderOpts) *corev1.Pod { }, } - // Without a ServiceAccount of its own the pod would still get the namespace - // default one mounted, handing project-supplied build instructions a token in - // a privileged container. A configured ServiceAccount keeps the cluster - // default, because that is how IRSA and workload identity deliver credentials. - if opts.serviceAccountName == "" { - pod.Spec.AutomountServiceAccountToken = lo.ToPtr(false) - } if opts.deadline > 0 { pod.Spec.ActiveDeadlineSeconds = lo.ToPtr(int64(opts.deadline.Seconds())) } diff --git a/server/pkg/docker/kubernetes_test.go b/server/pkg/docker/kubernetes_test.go index c30843d1..fa45525b 100644 --- a/server/pkg/docker/kubernetes_test.go +++ b/server/pkg/docker/kubernetes_test.go @@ -515,7 +515,7 @@ func TestBuildkitPodAppliesResourcesAndScheduling(t *testing.T) { assert.Equal(t, "4Gi", container.Resources.Limits.Memory().String()) assert.Equal(t, map[string]string{"disktype": "ssd", "zone": "a"}, pod.Spec.NodeSelector) assert.Equal(t, "trdl-buildkit", pod.Spec.ServiceAccountName) - assert.Nil(t, pod.Spec.AutomountServiceAccountToken, "a configured ServiceAccount keeps the cluster default, or IRSA and workload identity lose their token") + assert.Equal(t, lo.ToPtr(false), pod.Spec.AutomountServiceAccountToken, "a configured ServiceAccount must not hand its token to a privileged build container") assert.Equal(t, int64(5400), *pod.Spec.ActiveDeadlineSeconds) assert.Equal(t, "delivery", pod.Labels["team"]) assert.Equal(t, "trdl", pod.Annotations["example.com/owner"]) From 940ccb4e1de81aae77f1e330787e779b18b3a0ed Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:27:45 +0100 Subject: [PATCH 06/13] docs: state that the builder pod never mounts a serviceaccount token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token bullet in both QUICKSTART locales still described the old conditional behavior — a token mounted whenever `serviceaccount` was configured. It now states the unconditional `automountServiceAccountToken: false` and scopes the claim to the mount that field actually suppresses, noting that cloud workload-identity mechanisms are unaffected because they deliver credentials through their own volumes or the node metadata server. Signed-off-by: Aleksei Igrychev --- docs/pages_en/QUICKSTART.md | 2 +- docs/pages_ru/QUICKSTART.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index be718304..c5e8eff2 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -126,7 +126,7 @@ Notes on the pod: Two things follow from the plugin creating the pod with its own credentials, and both are the administrator's to weigh: -* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace, and a ServiceAccount carries whatever is bound to it directly, through a namespaced RoleBinding, through a ClusterRoleBinding, and through group bindings such as `system:serviceaccounts`. The absence of a RoleBinding in the namespace is not by itself an isolation guarantee, and when a `serviceaccount` is configured the builder pod does mount its token; without one the token is not mounted at all (`automountServiceAccountToken: false`). +* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace, and a ServiceAccount carries whatever is bound to it directly, through a namespaced RoleBinding, through a ClusterRoleBinding, and through group bindings such as `system:serviceaccounts`. The absence of a RoleBinding in the namespace is not by itself an isolation guarantee. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured; cloud workload-identity mechanisms are unaffected, because they inject their own volumes or use the node metadata server rather than the mount this suppresses. * **The builder container is privileged by default.** BuildKit needs it; `rootless=true` trades it for seccomp `Unconfined` and the `unconfined` AppArmor annotation. Either way the build executes project-supplied instructions in a container the `baseline` PodSecurity level would refuse, so give it a namespace and nodes you are willing to lose, not the ones the signing keys live on. ##### Connecting to an existing buildkitd diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index 298c28c0..a2b84fbd 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -125,7 +125,7 @@ vault write trdl-test-project/configure ... \ Из того, что под создаёт сам плагин своими кредами, следуют две вещи, и обе — предмет решения администратора: -* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace, а ServiceAccount несёт всё, что привязано к нему напрямую, через namespaced RoleBinding, через ClusterRoleBinding и через групповые привязки вроде `system:serviceaccounts`. Отсутствие RoleBinding в namespace само по себе гарантией изоляции не является, и если `serviceaccount` задан, токен пода сборщика монтируется; без него токен не монтируется вовсе (`automountServiceAccountToken: false`). +* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace, а ServiceAccount несёт всё, что привязано к нему напрямую, через namespaced RoleBinding, через ClusterRoleBinding и через групповые привязки вроде `system:serviceaccounts`. Отсутствие RoleBinding в namespace само по себе гарантией изоляции не является. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет; облачные механизмы workload identity от этого не страдают, потому что доставляют креды собственными томами или через metadata-сервер узла, а не через тот моунт, который здесь отключается. * **Контейнер сборщика по умолчанию привилегированный.** Этого требует BuildKit; `rootless=true` меняет привилегию на seccomp `Unconfined` и AppArmor-аннотацию `unconfined`. В любом случае сборка исполняет инструкции проекта в контейнере, который уровень PodSecurity `baseline` не пропустил бы, — значит и namespace, и узлы под неё надо давать те, которые не жалко, а не те, где лежат ключи подписи. ##### Подключение к существующему buildkitd From d37e6f3e57bfc0ee28cd2d69739a523e8a42a52b Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:28:14 +0100 Subject: [PATCH 07/13] fix(server, docs): document the deadline default as remaining task time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field description and both QUICKSTART rows promised the release task's own timeout plus a five-minute margin, but resolvePodDeadline derives the cap from the time the task context has left when the pod is created — clone, tag verification and context tar have already been spent by then. An operator sizing task_timeout for their longest release got a cap tighter than documented. Signed-off-by: Aleksei Igrychev --- docs/pages_en/QUICKSTART.md | 2 +- docs/pages_ru/QUICKSTART.md | 2 +- server/path_configure.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index c5e8eff2..db6d9e4a 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -112,7 +112,7 @@ The options are `name=value` pairs, one per list element and passed through as i | `requests.cpu`, `requests.memory`, `requests.ephemeral-storage` | pod resource requests | | `limits.cpu`, `limits.memory`, `limits.ephemeral-storage` | pod resource limits | | `timeout` | how long to wait for the builder to become ready, e.g. `5m`; `2m` by default | -| `deadline` | a hard lifetime cap for the builder pod (`activeDeadlineSeconds`), e.g. `2h`; defaults to the release task's timeout ([`task_timeout`](/reference/vault_plugin/task/configure.html), `30m` unless configured) plus a five-minute margin; a whole number of seconds of at least `1s`. It is not a grace period: a build still running when it expires is killed too, so set it above the longest release this project takes | +| `deadline` | a hard lifetime cap for the builder pod (`activeDeadlineSeconds`), e.g. `2h`; defaults to the release task's remaining time at pod creation ([`task_timeout`](/reference/vault_plugin/task/configure.html), `30m` unless configured, minus whatever the release has already spent) plus a five-minute margin; a whole number of seconds of at least `1s`. It is not a grace period: a build still running when it expires is killed too, so set it above the longest release this project takes | The option names are the buildx kubernetes driver's own wherever the two overlap, but the vocabulary is this driver's, not buildx's: options buildx accepts and this driver does not — `replicas`, `loadbalance`, `tolerations`, `schedulername`, `qemu.*`, the persistent-volume options — are rejected, and `deadline` has no buildx counterpart. diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index a2b84fbd..ed1625b0 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -111,7 +111,7 @@ vault write trdl-test-project/configure ... \ | `requests.cpu`, `requests.memory`, `requests.ephemeral-storage` | requests пода | | `limits.cpu`, `limits.memory`, `limits.ephemeral-storage` | limits пода | | `timeout` | сколько ждать готовности сборщика, например `5m`; по умолчанию `2m` | -| `deadline` | жёсткий предел времени жизни пода сборщика (`activeDeadlineSeconds`), например `2h`; по умолчанию — таймаут самой релизной задачи ([`task_timeout`](/reference/vault_plugin/task/configure.html), `30m`, если не настроен иначе) плюс пять минут запаса, задаётся целым числом секунд не меньше `1s`. Это не льготный период: сборка, которая к этому моменту ещё идёт, тоже будет убита, поэтому значение берут заведомо больше самого долгого релиза проекта | +| `deadline` | жёсткий предел времени жизни пода сборщика (`activeDeadlineSeconds`), например `2h`; по умолчанию — оставшееся время релизной задачи на момент создания пода ([`task_timeout`](/reference/vault_plugin/task/configure.html), `30m`, если не настроен иначе, минус уже потраченное релизом) плюс пять минут запаса, задаётся целым числом секунд не меньше `1s`. Это не льготный период: сборка, которая к этому моменту ещё идёт, тоже будет убита, поэтому значение берут заведомо больше самого долгого релиза проекта | Имена опций совпадают с опциями buildx-драйвера `kubernetes` там, где опции пересекаются, но набор здесь свой, а не buildx: опции, которые принимает buildx и не принимает этот драйвер — `replicas`, `loadbalance`, `tolerations`, `schedulername`, `qemu.*`, опции постоянного тома, — отвергаются, а у `deadline` соответствия в buildx нет вовсе. diff --git a/server/path_configure.go b/server/path_configure.go index 3b847215..9f79484c 100644 --- a/server/path_configure.go +++ b/server/path_configure.go @@ -140,7 +140,7 @@ func configurePath(b *Backend) *framework.Path { }, fieldNameBuildkitdDriverOpts: { Type: framework.TypeStringSlice, - Description: "The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. When deadline is not set, it defaults to the release task's own timeout plus a five-minute margin, so a plugin crash cannot leave the builder pod running indefinitely", + Description: "The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. When deadline is not set, it defaults to the release task's remaining time at pod creation plus a five-minute margin, so a plugin crash cannot leave the builder pod running indefinitely", Required: false, }, }, From a8541e2826c71a01921e6ba57ce1b2c733792016 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:28:38 +0100 Subject: [PATCH 08/13] docs: regenerate the vault plugin configure reference Picks up the corrected buildkitd_driver_opts deadline wording from path_configure.go. Generated by `task docs:gen`; no other reference page moved. Signed-off-by: Aleksei Igrychev --- docs/_includes/reference/vault_plugin/configure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_includes/reference/vault_plugin/configure.md b/docs/_includes/reference/vault_plugin/configure.md index 39f224fd..3147185c 100644 --- a/docs/_includes/reference/vault_plugin/configure.md +++ b/docs/_includes/reference/vault_plugin/configure.md @@ -11,7 +11,7 @@ Configure the plugin. * `buildkitd_address` (string, optional) — An address of a running buildkitd (unix://, tcp://, docker-container:// or kube-pod:// scheme) to build release artifacts with the BuildKit client; the docker CLI is used only when neither this nor buildkitd_driver is set. Build secrets are sent to that daemon, and tcp:// is neither encrypted nor authenticated, so securing the channel and isolating the daemon is the administrator's responsibility. * `buildkitd_driver` (string, optional) — Provision an ephemeral buildkitd per build instead of using the docker CLI: kubernetes runs it as a pod and needs no docker binary next to the plugin. Cannot be combined with buildkitd_address, buildx_driver or buildx_driver_opts. A TRDL_BUILDKITD_ADDRESS set on the process wins over a stored driver, and the build reports the driver as unused. -* `buildkitd_driver_opts` (array, optional) — The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. When deadline is not set, it defaults to the release task's own timeout plus a five-minute margin, so a plugin crash cannot leave the builder pod running indefinitely. +* `buildkitd_driver_opts` (array, optional) — The buildkitd driver options, one name=value pair per element (e.g. namespace=trdl-build); they require buildkitd_driver to be set. The kubernetes driver accepts annotations, deadline, image, labels, limits.cpu, limits.ephemeral-storage, limits.memory, namespace, nodeselector, requests.cpu, requests.ephemeral-storage, requests.memory, rootless, serviceaccount and timeout; anything else is rejected. When deadline is not set, it defaults to the release task's remaining time at pod creation plus a five-minute margin, so a plugin crash cannot leave the builder pod running indefinitely. * `buildx_driver` (string, optional) — The buildx driver to build release artifacts with: docker-container (used by default) or kubernetes. Takes precedence over the TRDL_BUILDX_DRIVER environment variable, and cannot be combined with buildkitd_address or buildkitd_driver. * `buildx_driver_opts` (array, optional) — The buildx driver options, one --driver-opt per element (e.g. namespace=trdl-build), passed through as is. Take precedence over the TRDL_BUILDX_DRIVER_OPTS_* environment variables, and cannot be combined with buildkitd_address or buildkitd_driver. * `git_repo_url` (string, required) — URL of the Git repository. From 94e8a074df6b80dc6d7761184e14c8c7f588d400 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:29:09 +0100 Subject: [PATCH 09/13] refactor(server): fix the truncated slack comment and its reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentence ended mid-clause ("no longer around to.") and gave the wrong reason: the five minutes are not time for the build to finish. The pod deadline is anchored at the pod's StartTime, at or after bootstrap, while the build context expires at bootstrap plus the same remaining time — so the context always fires first and the slack is clock-skew and termination margin. Signed-off-by: Aleksei Igrychev --- server/pkg/docker/kubernetes.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index 33474b1f..d6902d59 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -33,8 +33,10 @@ const ( buildkitPodPollInterval = time.Second buildkitPodCleanupTimeout = 30 * time.Second - // Long enough for the build to finish and the plugin to delete the pod itself, - // so the deadline only ever fires when the plugin is no longer around to. + // Clock-skew and pod-termination margin. The build context expires at + // bootstrap plus the same remaining time the deadline is derived from, while + // the deadline is anchored at the pod's later StartTime, so the context always + // fires first and this only reaps a pod the plugin has already abandoned. buildkitPodDeadlineSlack = 5 * time.Minute // Fallback for a context carrying no deadline. The release task always sets // one, so this is what keeps the guarantee for any other caller. From 2224f1421eb2fc5b2859a6a6ac9eed0d6b5bc929 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:29:52 +0100 Subject: [PATCH 10/13] refactor(server): floor the derived pod deadline at zero, not a minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The time.Minute floor only ever applied to an already-expired context, where createPod fails on that same context before any pod exists — so the minute was arbitrary. The floor still carries a real invariant, which is why it stays: a negative remaining would make `opts.deadline > 0` false in buildkitPod and silently drop activeDeadlineSeconds. Zero holds that just as well, and the test case now names the invariant instead of promising a production guarantee. Signed-off-by: Aleksei Igrychev --- server/pkg/docker/kubernetes.go | 7 +++---- server/pkg/docker/kubernetes_test.go | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index d6902d59..1509f49e 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -473,9 +473,8 @@ func splitKeyValues(value string) (map[string]string, error) { // resolvePodDeadline bounds the pod's lifetime by the build's own, because // nothing outside the plugin process deletes the builder: a crash between // creating the pod and removing it would otherwise leave it running forever. -// The floor keeps an already-expired context from producing a deadline the API -// server rejects, and the whole-second rounding matches what the option itself -// is validated against. +// The result stays positive whatever the context has left, so buildkitPod can +// never silently omit activeDeadlineSeconds. func resolvePodDeadline(ctx context.Context, configured time.Duration) time.Duration { if configured > 0 { return configured @@ -486,7 +485,7 @@ func resolvePodDeadline(ctx context.Context, configured time.Duration) time.Dura return defaultBuildkitPodDeadline } - return max(time.Until(deadline).Round(time.Second), time.Minute) + buildkitPodDeadlineSlack + return max(time.Until(deadline).Round(time.Second), 0) + buildkitPodDeadlineSlack } func buildkitPod(name string, opts kubernetesBuilderOpts) *corev1.Pod { diff --git a/server/pkg/docker/kubernetes_test.go b/server/pkg/docker/kubernetes_test.go index fa45525b..cd5b3396 100644 --- a/server/pkg/docker/kubernetes_test.go +++ b/server/pkg/docker/kubernetes_test.go @@ -415,9 +415,9 @@ func TestResolvePodDeadline(t *testing.T) { ctx: contextWithDeadlineIn(t, context.Background(), 30*time.Minute), expected: 30*time.Minute + buildkitPodDeadlineSlack, }, - "an expired context still yields a usable deadline": { + "an expired context cannot produce a negative deadline": { ctx: contextWithDeadlineIn(t, context.Background(), -time.Hour), - expected: time.Minute + buildkitPodDeadlineSlack, + expected: buildkitPodDeadlineSlack, }, "a context without a deadline falls back": { ctx: context.Background(), From 6289d64e92e8fb916ccddb272a5c8aed68de7a7c Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:45:47 +0100 Subject: [PATCH 11/13] docs: resolve the serviceaccount bullet's own contradiction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet told operators to audit RBAC bound to the builder's ServiceAccount through RoleBindings, ClusterRoleBindings and group bindings, and then said the pod never receives an API token — but Kubernetes RBAC is exercisable from a pod only through a mounted ServiceAccount credential, so that enumeration named privileges the build cannot reach. It now states the unreachability as the consequence of the missing mount. The same sentence framed surviving cloud workload identity as reassurance inside a threat-model bullet, when it is the residual leak: a privileged container running project-supplied instructions still reaches whatever cloud role is bound to the chosen ServiceAccount, so the audit advice is re-scoped to that, `imagePullSecrets` and admission policy. The RU translation also dropped a transliterated «моунт» and «не страдают», neither of which the file uses elsewhere. The constant comment now records that the user-facing description deliberately omits the unreachable 1h fallback. Signed-off-by: Aleksei Igrychev --- docs/pages_en/QUICKSTART.md | 2 +- docs/pages_ru/QUICKSTART.md | 2 +- server/pkg/docker/kubernetes.go | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index db6d9e4a..95546383 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -126,7 +126,7 @@ Notes on the pod: Two things follow from the plugin creating the pod with its own credentials, and both are the administrator's to weigh: -* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace, and a ServiceAccount carries whatever is bound to it directly, through a namespaced RoleBinding, through a ClusterRoleBinding, and through group bindings such as `system:serviceaccounts`. The absence of a RoleBinding in the namespace is not by itself an isolation guarantee. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured; cloud workload-identity mechanisms are unaffected, because they inject their own volumes or use the node metadata server rather than the mount this suppresses. +* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured, so Kubernetes RBAC bound to that ServiceAccount — directly, through a namespaced RoleBinding, through a ClusterRoleBinding or through a group binding such as `system:serviceaccounts` — is not reachable from inside the build. What the ServiceAccount still confers is cloud workload identity, `imagePullSecrets` and any admission policy keyed on it: those mechanisms deliver credentials through their own volumes or the node metadata server, not through the mount this suppresses, so the build reaches whatever cloud role is bound to the ServiceAccount you pick. * **The builder container is privileged by default.** BuildKit needs it; `rootless=true` trades it for seccomp `Unconfined` and the `unconfined` AppArmor annotation. Either way the build executes project-supplied instructions in a container the `baseline` PodSecurity level would refuse, so give it a namespace and nodes you are willing to lose, not the ones the signing keys live on. ##### Connecting to an existing buildkitd diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index ed1625b0..9f1c787c 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -125,7 +125,7 @@ vault write trdl-test-project/configure ... \ Из того, что под создаёт сам плагин своими кредами, следуют две вещи, и обе — предмет решения администратора: -* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace, а ServiceAccount несёт всё, что привязано к нему напрямую, через namespaced RoleBinding, через ClusterRoleBinding и через групповые привязки вроде `system:serviceaccounts`. Отсутствие RoleBinding в namespace само по себе гарантией изоляции не является. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет; облачные механизмы workload identity от этого не страдают, потому что доставляют креды собственными томами или через metadata-сервер узла, а не через тот моунт, который здесь отключается. +* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет, — поэтому Kubernetes RBAC, привязанный к этому ServiceAccount напрямую, через namespaced RoleBinding, через ClusterRoleBinding или через групповые привязки вроде `system:serviceaccounts`, изнутри сборки недостижим. Чем ServiceAccount остаётся значим: облачная workload identity, `imagePullSecrets` и любые политики допуска, завязанные на него, — эти механизмы доставляют креды собственными томами или через metadata-сервер узла, а не через то монтирование, которое здесь отключается, так что сборка дотягивается до любой облачной роли, привязанной к выбранному вами ServiceAccount. * **Контейнер сборщика по умолчанию привилегированный.** Этого требует BuildKit; `rootless=true` меняет привилегию на seccomp `Unconfined` и AppArmor-аннотацию `unconfined`. В любом случае сборка исполняет инструкции проекта в контейнере, который уровень PodSecurity `baseline` не пропустил бы, — значит и namespace, и узлы под неё надо давать те, которые не жалко, а не те, где лежат ключи подписи. ##### Подключение к существующему buildkitd diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index 1509f49e..e1cc3274 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -39,7 +39,8 @@ const ( // fires first and this only reaps a pod the plugin has already abandoned. buildkitPodDeadlineSlack = 5 * time.Minute // Fallback for a context carrying no deadline. The release task always sets - // one, so this is what keeps the guarantee for any other caller. + // one, so this is what keeps the guarantee for any other caller — and why the + // user-facing description names only the derived default, not this one. defaultBuildkitPodDeadline = time.Hour ) From 8c4be1a289fe2a9d451e134605a181fb7db10358 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 12:54:18 +0100 Subject: [PATCH 12/13] docs: scope the token claim to the credential, not to reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet said RBAC bound to the builder's ServiceAccount "is not reachable from inside the build", but the next bullet states the container is privileged by default — and a privileged container reaches the node filesystem, so other pods' projected tokens and the kubelet's own credentials are a container escape away. The accurate claim is narrower: the build is not handed a token for that ServiceAccount and cannot act as it against the API. The bullet now says so and names the residual node-level exposure instead of implying isolation. The RU twin also translated admission policy as «политики допуска», while the file keeps «PodSecurity-admission» untranslated twice, and opened the list with a phrase that did not agree with it. Separately, the `defaultBuildkitPodDeadline` comment loses the clause about what the user-facing description omits: it described another artifact and would rot the moment that description changed. Signed-off-by: Aleksei Igrychev --- docs/pages_en/QUICKSTART.md | 2 +- docs/pages_ru/QUICKSTART.md | 2 +- server/pkg/docker/kubernetes.go | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index 95546383..10236f75 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -126,7 +126,7 @@ Notes on the pod: Two things follow from the plugin creating the pod with its own credentials, and both are the administrator's to weigh: -* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured, so Kubernetes RBAC bound to that ServiceAccount — directly, through a namespaced RoleBinding, through a ClusterRoleBinding or through a group binding such as `system:serviceaccounts` — is not reachable from inside the build. What the ServiceAccount still confers is cloud workload identity, `imagePullSecrets` and any admission policy keyed on it: those mechanisms deliver credentials through their own volumes or the node metadata server, not through the mount this suppresses, so the build reaches whatever cloud role is bound to the ServiceAccount you pick. +* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured. The build is therefore not handed a token for that ServiceAccount and cannot act as it against the API — not through a namespaced RoleBinding, not through a ClusterRoleBinding, not through a group binding such as `system:serviceaccounts`. That is a withheld credential, not isolation: the privileged container described below still reaches node-level credentials. What the ServiceAccount still confers is cloud workload identity, `imagePullSecrets` and any admission policy keyed on it — those mechanisms deliver credentials through their own volumes or the node metadata server, not through the mount this suppresses, so the build reaches whatever cloud role is bound to the ServiceAccount you pick. * **The builder container is privileged by default.** BuildKit needs it; `rootless=true` trades it for seccomp `Unconfined` and the `unconfined` AppArmor annotation. Either way the build executes project-supplied instructions in a container the `baseline` PodSecurity level would refuse, so give it a namespace and nodes you are willing to lose, not the ones the signing keys live on. ##### Connecting to an existing buildkitd diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index 9f1c787c..57a9dc9b 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -125,7 +125,7 @@ vault write trdl-test-project/configure ... \ Из того, что под создаёт сам плагин своими кредами, следуют две вещи, и обе — предмет решения администратора: -* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет, — поэтому Kubernetes RBAC, привязанный к этому ServiceAccount напрямую, через namespaced RoleBinding, через ClusterRoleBinding или через групповые привязки вроде `system:serviceaccounts`, изнутри сборки недостижим. Чем ServiceAccount остаётся значим: облачная workload identity, `imagePullSecrets` и любые политики допуска, завязанные на него, — эти механизмы доставляют креды собственными томами или через metadata-сервер узла, а не через то монтирование, которое здесь отключается, так что сборка дотягивается до любой облачной роли, привязанной к выбранному вами ServiceAccount. +* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет. Значит, сборке не выдаётся токен этого ServiceAccount и она не может действовать от его имени против API: ни через namespaced RoleBinding, ни через ClusterRoleBinding, ни через групповые привязки вроде `system:serviceaccounts`. Это невыданный токен, а не изоляция: привилегированный контейнер из следующего пункта по-прежнему дотягивается до кредов уровня узла. Что ServiceAccount всё же даёт: облачную workload identity, `imagePullSecrets` и любые admission-политики, завязанные на него, — эти механизмы доставляют креды собственными томами или через metadata-сервер узла, а не через то монтирование, которое здесь отключается, так что сборка дотягивается до любой облачной роли, привязанной к выбранному вами ServiceAccount. * **Контейнер сборщика по умолчанию привилегированный.** Этого требует BuildKit; `rootless=true` меняет привилегию на seccomp `Unconfined` и AppArmor-аннотацию `unconfined`. В любом случае сборка исполняет инструкции проекта в контейнере, который уровень PodSecurity `baseline` не пропустил бы, — значит и namespace, и узлы под неё надо давать те, которые не жалко, а не те, где лежат ключи подписи. ##### Подключение к существующему buildkitd diff --git a/server/pkg/docker/kubernetes.go b/server/pkg/docker/kubernetes.go index e1cc3274..1509f49e 100644 --- a/server/pkg/docker/kubernetes.go +++ b/server/pkg/docker/kubernetes.go @@ -39,8 +39,7 @@ const ( // fires first and this only reaps a pod the plugin has already abandoned. buildkitPodDeadlineSlack = 5 * time.Minute // Fallback for a context carrying no deadline. The release task always sets - // one, so this is what keeps the guarantee for any other caller — and why the - // user-facing description names only the derived default, not this one. + // one, so this is what keeps the guarantee for any other caller. defaultBuildkitPodDeadline = time.Hour ) From ecc4f7e8d76589bd25f3f804d69a29c1e5f157fc Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Wed, 2 Sep 2026 13:02:24 +0100 Subject: [PATCH 13/13] docs: drop the unexercised cloud-credential mechanism claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bullet explained how cloud workload identity delivers credentials — through its own volumes or the node metadata server — which is a generic claim about four vendor mechanisms this repo's CI never exercises, and it made an already long security bullet longer. The operative statement is unchanged: those mechanisms are not covered by the suppressed mount, so the build reaches whatever cloud role is bound to the chosen ServiceAccount. The RU twin also carried a calque of "act as it against the API"; it now reads «обращаться к API от его имени». Signed-off-by: Aleksei Igrychev --- docs/pages_en/QUICKSTART.md | 2 +- docs/pages_ru/QUICKSTART.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages_en/QUICKSTART.md b/docs/pages_en/QUICKSTART.md index 10236f75..2bde2076 100644 --- a/docs/pages_en/QUICKSTART.md +++ b/docs/pages_en/QUICKSTART.md @@ -126,7 +126,7 @@ Notes on the pod: Two things follow from the plugin creating the pod with its own credentials, and both are the administrator's to weigh: -* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured. The build is therefore not handed a token for that ServiceAccount and cannot act as it against the API — not through a namespaced RoleBinding, not through a ClusterRoleBinding, not through a group binding such as `system:serviceaccounts`. That is a withheld credential, not isolation: the privileged container described below still reaches node-level credentials. What the ServiceAccount still confers is cloud workload identity, `imagePullSecrets` and any admission policy keyed on it — those mechanisms deliver credentials through their own volumes or the node metadata server, not through the mount this suppresses, so the build reaches whatever cloud role is bound to the ServiceAccount you pick. +* **`configure` write access becomes pod-create access.** Whoever can write a project's configuration chooses the `namespace` the builder runs in, the `serviceaccount` it runs as and the `image` it runs — anywhere the plugin's own Role reaches. Restrict `/configure` to the same people who are trusted with the release keys, and keep the plugin's Role to namespaces that hold nothing else worth taking. Give the builder a ServiceAccount whose privileges you have audited in full: creating a workload lets it run as any ServiceAccount of that namespace. The builder pod never gets the automatically mounted ServiceAccount API-token volume (`automountServiceAccountToken: false`), whether or not a `serviceaccount` is configured. The build is therefore not handed a token for that ServiceAccount and cannot act as it against the API — not through a namespaced RoleBinding, not through a ClusterRoleBinding, not through a group binding such as `system:serviceaccounts`. That is a withheld credential, not isolation: the privileged container described below still reaches node-level credentials. What the ServiceAccount still confers is cloud workload identity, `imagePullSecrets` and any admission policy keyed on it — none of which the suppressed mount covers, so the build reaches whatever cloud role is bound to the ServiceAccount you pick. * **The builder container is privileged by default.** BuildKit needs it; `rootless=true` trades it for seccomp `Unconfined` and the `unconfined` AppArmor annotation. Either way the build executes project-supplied instructions in a container the `baseline` PodSecurity level would refuse, so give it a namespace and nodes you are willing to lose, not the ones the signing keys live on. ##### Connecting to an existing buildkitd diff --git a/docs/pages_ru/QUICKSTART.md b/docs/pages_ru/QUICKSTART.md index 57a9dc9b..a31bd77f 100644 --- a/docs/pages_ru/QUICKSTART.md +++ b/docs/pages_ru/QUICKSTART.md @@ -125,7 +125,7 @@ vault write trdl-test-project/configure ... \ Из того, что под создаёт сам плагин своими кредами, следуют две вещи, и обе — предмет решения администратора: -* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет. Значит, сборке не выдаётся токен этого ServiceAccount и она не может действовать от его имени против API: ни через namespaced RoleBinding, ни через ClusterRoleBinding, ни через групповые привязки вроде `system:serviceaccounts`. Это невыданный токен, а не изоляция: привилегированный контейнер из следующего пункта по-прежнему дотягивается до кредов уровня узла. Что ServiceAccount всё же даёт: облачную workload identity, `imagePullSecrets` и любые admission-политики, завязанные на него, — эти механизмы доставляют креды собственными томами или через metadata-сервер узла, а не через то монтирование, которое здесь отключается, так что сборка дотягивается до любой облачной роли, привязанной к выбранному вами ServiceAccount. +* **Право записи в `configure` становится правом создавать поды.** Кто может писать конфигурацию проекта, тот выбирает `namespace`, в котором поднимется сборщик, `serviceaccount`, под которым он побежит, и `image`, который в нём запустится, — везде, куда достаёт Role самого плагина. Ограничьте доступ на запись в `/configure` тем же кругом, которому доверены релизные ключи, а Role плагина — namespace'ами, в которых больше нечего брать. Сборщику давайте ServiceAccount, права которого проверены целиком: создание workload'а позволяет запуститься от имени любого ServiceAccount этого namespace. Под сборщика никогда не получает автоматически монтируемый том с API-токеном ServiceAccount (`automountServiceAccountToken: false`) — независимо от того, задан `serviceaccount` или нет. Значит, сборке не выдаётся токен этого ServiceAccount и она не может обращаться к API от его имени: ни через namespaced RoleBinding, ни через ClusterRoleBinding, ни через групповые привязки вроде `system:serviceaccounts`. Это невыданный токен, а не изоляция: привилегированный контейнер из следующего пункта по-прежнему дотягивается до кредов уровня узла. Что ServiceAccount всё же даёт: облачную workload identity, `imagePullSecrets` и любые admission-политики, завязанные на него, — ничего из этого отключённое монтирование не покрывает, так что сборка дотягивается до любой облачной роли, привязанной к выбранному вами ServiceAccount. * **Контейнер сборщика по умолчанию привилегированный.** Этого требует BuildKit; `rootless=true` меняет привилегию на seccomp `Unconfined` и AppArmor-аннотацию `unconfined`. В любом случае сборка исполняет инструкции проекта в контейнере, который уровень PodSecurity `baseline` не пропустил бы, — значит и namespace, и узлы под неё надо давать те, которые не жалко, а не те, где лежат ключи подписи. ##### Подключение к существующему buildkitd