From 49046875f6b89df29fcff730455b311c8e1f18e4 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 19 Oct 2026 17:24:38 +0000 Subject: [PATCH] api | charts | docs: label unbind-system privileged for pod security Buildkit, alloy and node-exporter need privileged pods, which clusters that enforce baseline by default reject. A presync hook on their releases labels the namespace on fresh installs, and the API does the same at startup for existing ones. A level already set is kept. UNB-408 --- apps/api/cmd/api/main.go | 3 ++ .../infrastructure/k8s/pod_security.go | 30 +++++++++++ .../infrastructure/k8s/pod_security_test.go | 51 +++++++++++++++++++ .../operations/bring-your-own-kubernetes.mdx | 1 + deploy/charts/README.md | 2 + deploy/charts/helmfile.yaml.gotmpl | 19 +++++++ 6 files changed, 106 insertions(+) create mode 100644 apps/api/internal/infrastructure/k8s/pod_security.go create mode 100644 apps/api/internal/infrastructure/k8s/pod_security_test.go diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 498fdb4c..601a2345 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -200,6 +200,9 @@ func startAPI(cfg *config.Config) { storageService := storage_service.NewStorageService(cfg, repo, kubeClient, promClient, serviceService) go storageService.StoreDatabaseVolumeNames(ctx) emailService := email_service.NewEmailService(cfg, repo, kubeClient, mail.SMTPSender{}) + if err := k8s.EnsurePrivilegedNamespace(ctx, kubeClient.GetInternalClient(), cfg.GetSystemNamespace()); err != nil { + log.Errorf("Failed to set the Pod Security level of the system namespace: %v", err) + } networkPolicyManager := k8s.NewNetworkPolicyManager(repo, kubeClient.GetInternalClient()) if err := networkPolicyManager.Sync(ctx); err != nil { log.Errorf("Failed to sync network policies: %v", err) diff --git a/apps/api/internal/infrastructure/k8s/pod_security.go b/apps/api/internal/infrastructure/k8s/pod_security.go new file mode 100644 index 00000000..2ca64c4c --- /dev/null +++ b/apps/api/internal/infrastructure/k8s/pod_security.go @@ -0,0 +1,30 @@ +package k8s + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" +) + +const PodSecurityEnforceLabel = "pod-security.kubernetes.io/enforce" + +// EnsurePrivilegedNamespace lets the system namespace run buildkit, alloy and node-exporter +// on clusters that enforce a stricter Pod Security level by default. A level already set is kept. +func EnsurePrivilegedNamespace(ctx context.Context, client kubernetes.Interface, name string) error { + namespace, err := client.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get namespace %s: %w", name, err) + } + if _, ok := namespace.Labels[PodSecurityEnforceLabel]; ok { + return nil + } + + patch := fmt.Appendf(nil, `{"metadata":{"labels":{%q:"privileged"}}}`, PodSecurityEnforceLabel) + if _, err := client.CoreV1().Namespaces().Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + return fmt.Errorf("failed to label namespace %s: %w", name, err) + } + return nil +} diff --git a/apps/api/internal/infrastructure/k8s/pod_security_test.go b/apps/api/internal/infrastructure/k8s/pod_security_test.go new file mode 100644 index 00000000..fcf62a51 --- /dev/null +++ b/apps/api/internal/infrastructure/k8s/pod_security_test.go @@ -0,0 +1,51 @@ +package k8s + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestEnsurePrivilegedNamespace(t *testing.T) { + tests := []struct { + name string + labels map[string]string + want map[string]string + }{ + { + name: "unlabeled gets privileged", + labels: map[string]string{"kubernetes.io/metadata.name": "unbind-system"}, + want: map[string]string{"kubernetes.io/metadata.name": "unbind-system", PodSecurityEnforceLabel: "privileged"}, + }, + { + name: "a level set by hand is kept", + labels: map[string]string{PodSecurityEnforceLabel: "baseline"}, + want: map[string]string{PodSecurityEnforceLabel: "baseline"}, + }, + { + name: "no labels at all", + labels: nil, + want: map[string]string{PodSecurityEnforceLabel: "privileged"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := fake.NewClientset(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "unbind-system", Labels: tt.labels}}) + + require.NoError(t, EnsurePrivilegedNamespace(context.Background(), client, "unbind-system")) + + namespace, err := client.CoreV1().Namespaces().Get(context.Background(), "unbind-system", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, tt.want, namespace.Labels) + }) + } +} + +func TestEnsurePrivilegedNamespaceMissing(t *testing.T) { + assert.Error(t, EnsurePrivilegedNamespace(context.Background(), fake.NewClientset(), "unbind-system")) +} diff --git a/apps/docs/content/operations/bring-your-own-kubernetes.mdx b/apps/docs/content/operations/bring-your-own-kubernetes.mdx index 9ddfd0ed..08af5c3c 100644 --- a/apps/docs/content/operations/bring-your-own-kubernetes.mdx +++ b/apps/docs/content/operations/bring-your-own-kubernetes.mdx @@ -41,6 +41,7 @@ Use the latest version from the [releases page](https://github.com/unbindapp/unb - **Redis**: the bundled Redis takes a generated password from the `redis` secret, and a NetworkPolicy limits it to the API. Your cluster's network plugin has to enforce NetworkPolicy for that limit to hold. Bringing your own Redis means setting `overrides.unbind.api.redis.url` and the password secret, see the chart README. - **Network isolation**: Unbind keeps teams apart with NetworkPolicies, which also need a network plugin that enforces them. See [Network Isolation](/operations/network-isolation). - **Build workers** run one per node, so their replica count cannot exceed the number of nodes. +- **Pod Security**: the build workers and the metrics and logging agents need privileged pods, so the install labels `unbind-system` `pod-security.kubernetes.io/enforce: privileged` unless it already has a level. Team namespaces keep the cluster's default. Where that default is `baseline`, as on Talos, templates that need extra capabilities, like WireGuard and Gluetun, cannot start. - **Bringing your own logging or metrics**: you can turn components off, but then Unbind cannot configure them from the UI, and they have to be set up the way Unbind expects. Logs need the `unbind-team`, `unbind-project`, `unbind-environment`, `unbind-service` and `unbind-deployment` labels indexed. Metrics need kube-state-metrics started with `--metric-labels-allowlist=pods=[*]`. ## What you do not get diff --git a/deploy/charts/README.md b/deploy/charts/README.md index 5978d2dd..58806d96 100644 --- a/deploy/charts/README.md +++ b/deploy/charts/README.md @@ -10,6 +10,8 @@ By default, and by the installer (`apps/installer`), all resources are tied to t This gives the unbind service account access to all of its resources (as it has superuser permissions to its own namespace) +Buildkit, alloy and node-exporter need privileged pods, so the presync hook on their releases labels the namespace `pod-security.kubernetes.io/enforce: privileged` when it has no level yet. The API does the same at startup, which is how existing installs get it. + ## Bringing your own cluster **Wild card domain** diff --git a/deploy/charts/helmfile.yaml.gotmpl b/deploy/charts/helmfile.yaml.gotmpl index 90ebffaf..0f51df84 100644 --- a/deploy/charts/helmfile.yaml.gotmpl +++ b/deploy/charts/helmfile.yaml.gotmpl @@ -116,6 +116,21 @@ releases: namespace: {{ .Values.namespace }} chart: vm/victoria-metrics-k8s-stack version: 0.95.2 + # Hardened distros like Talos enforce baseline by default; a level already set is kept + hooks: + - &privilegedNamespace + events: ["presync"] + showlogs: true + command: "/bin/sh" + args: + - "-c" + - | + set -eu + NAMESPACE="{{ .Values.namespace }}" + kubectl create namespace "$NAMESPACE" 2>/dev/null || true + if [ -z "$(kubectl get namespace "$NAMESPACE" -o jsonpath='{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" ]; then + kubectl label --overwrite namespace "$NAMESPACE" pod-security.kubernetes.io/enforce=privileged + fi values: - values/victoria-metrics-k8s-stack.yaml - vmsingle: @@ -134,6 +149,8 @@ releases: namespace: {{ .Values.namespace }} chart: grafana/alloy version: 1.13.1 + hooks: + - *privilegedNamespace values: - alloy: @@ -337,6 +354,8 @@ releases: - name: buildkit namespace: {{ .Values.namespace }} chart: ./charts/buildkitd + hooks: + - *privilegedNamespace values: - resources: {}