diff --git a/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md b/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md index f64db0f39..a1c11b97d 100644 --- a/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md +++ b/docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md @@ -607,6 +607,15 @@ Additional node selector to apply to worker pods. Must be a YAML object, e.g. `{ --- +### BACKEND_K8S_POD_NODE_SELECTOR_ALLOW_FROM_STEP + +- Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR_ALLOW_FROM_STEP` +- Default: `false` + +Determines if the Pod `nodeSelector` can be defined from a step's backend options. Disabled by default, as it would otherwise let any user with push access pin pipeline pods onto chosen nodes. + +--- + ### BACKEND_K8S_SECCTX_NONROOT - Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT` diff --git a/pipeline/backend/kubernetes/flags.go b/pipeline/backend/kubernetes/flags.go index f7e1f4dc4..c72aa1409 100644 --- a/pipeline/backend/kubernetes/flags.go +++ b/pipeline/backend/kubernetes/flags.go @@ -91,6 +91,12 @@ var Flags = []cli.Flag{ Usage: "whether to allow using tolerations from step's backend options", Value: true, }, + &cli.BoolFlag{ + Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR_ALLOW_FROM_STEP"), + Name: "backend-k8s-pod-node-selector-allow-from-step", + Usage: "whether to allow using node selector from step's backend options", + Value: false, + }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_AFFINITY"), Name: "backend-k8s-pod-affinity", diff --git a/pipeline/backend/kubernetes/kubernetes.go b/pipeline/backend/kubernetes/kubernetes.go index 2e920344c..506cc6b54 100644 --- a/pipeline/backend/kubernetes/kubernetes.go +++ b/pipeline/backend/kubernetes/kubernetes.go @@ -69,6 +69,7 @@ type config struct { PodAnnotations map[string]string PodAnnotationsAllowFromStep bool PodNodeSelector map[string]string + PodNodeSelectorAllowFromStep bool PodTolerationsAllowFromStep bool PodTolerations []Toleration PodAffinity *kube_core_v1.Affinity @@ -107,20 +108,21 @@ func configFromCliContext(ctx context.Context) (*config, error) { if ctx != nil { if c, ok := ctx.Value(types.CliCommand).(*cli.Command); ok { config := config{ - Namespace: c.String("backend-k8s-namespace"), - EnableNamespacePerOrg: c.Bool("backend-k8s-namespace-per-org"), - StorageClass: c.String("backend-k8s-storage-class"), - VolumeSize: c.String("backend-k8s-volume-size"), - StorageRwx: c.Bool("backend-k8s-storage-rwx"), - PriorityClassName: c.String("backend-k8s-priority-class"), - PodLabels: make(map[string]string), // just init empty map to prevent nil panic - PodLabelsAllowFromStep: c.Bool("backend-k8s-pod-labels-allow-from-step"), - PodAnnotations: make(map[string]string), // just init empty map to prevent nil panic - PodAnnotationsAllowFromStep: c.Bool("backend-k8s-pod-annotations-allow-from-step"), - PodTolerationsAllowFromStep: c.Bool("backend-k8s-pod-tolerations-allow-from-step"), - PodNodeSelector: make(map[string]string), // just init empty map to prevent nil panic - PodAffinityAllowFromStep: c.Bool("backend-k8s-pod-affinity-allow-from-step"), - ImagePullSecretNames: c.StringSlice("backend-k8s-pod-image-pull-secret-names"), + Namespace: c.String("backend-k8s-namespace"), + EnableNamespacePerOrg: c.Bool("backend-k8s-namespace-per-org"), + StorageClass: c.String("backend-k8s-storage-class"), + VolumeSize: c.String("backend-k8s-volume-size"), + StorageRwx: c.Bool("backend-k8s-storage-rwx"), + PriorityClassName: c.String("backend-k8s-priority-class"), + PodLabels: make(map[string]string), // just init empty map to prevent nil panic + PodLabelsAllowFromStep: c.Bool("backend-k8s-pod-labels-allow-from-step"), + PodAnnotations: make(map[string]string), // just init empty map to prevent nil panic + PodAnnotationsAllowFromStep: c.Bool("backend-k8s-pod-annotations-allow-from-step"), + PodTolerationsAllowFromStep: c.Bool("backend-k8s-pod-tolerations-allow-from-step"), + PodNodeSelectorAllowFromStep: c.Bool("backend-k8s-pod-node-selector-allow-from-step"), + PodNodeSelector: make(map[string]string), // just init empty map to prevent nil panic + PodAffinityAllowFromStep: c.Bool("backend-k8s-pod-affinity-allow-from-step"), + ImagePullSecretNames: c.StringSlice("backend-k8s-pod-image-pull-secret-names"), SecurityContext: SecurityContextConfig{ RunAsNonRoot: c.Bool("backend-k8s-secctx-nonroot"), // cspell:words secctx nonroot FSGroup: newInt64(defaultFSGroup), diff --git a/pipeline/backend/kubernetes/node_selector_allow_from_step_test.go b/pipeline/backend/kubernetes/node_selector_allow_from_step_test.go new file mode 100644 index 000000000..fd7c654bb --- /dev/null +++ b/pipeline/backend/kubernetes/node_selector_allow_from_step_test.go @@ -0,0 +1,50 @@ +// Copyright 2026 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" +) + +func TestNodeSelectorAllowFromStep(t *testing.T) { + step := &types.Step{ + Name: "ns-test", + Image: "alpine", + UUID: "01he8bebctabr3kgk0qj36d2me-0", + } + + // When disabled (default), a step-provided node selector must be ignored. + pod, err := mkPod(step, &config{ + Namespace: "woodpecker", + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ + NodeSelector: map[string]string{"attacker-target": "sensitive-node"}, + }, taskUUID) + assert.NoError(t, err) + assert.NotContains(t, pod.Spec.NodeSelector, "attacker-target") + + // When explicitly enabled by the admin, the step value is honored. + pod, err = mkPod(step, &config{ + Namespace: "woodpecker", + PodNodeSelectorAllowFromStep: true, + }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ + NodeSelector: map[string]string{"attacker-target": "sensitive-node"}, + }, taskUUID) + assert.NoError(t, err) + assert.Equal(t, "sensitive-node", pod.Spec.NodeSelector["attacker-target"]) +} diff --git a/pipeline/backend/kubernetes/pod.go b/pipeline/backend/kubernetes/pod.go index a413411d2..027878c45 100644 --- a/pipeline/backend/kubernetes/pod.go +++ b/pipeline/backend/kubernetes/pod.go @@ -193,7 +193,7 @@ func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativ Hostname: getHostnameOrEmpty(step.Name), Subdomain: subdomain, DNSConfig: dnsConfig(config.GetNamespace(step.OrgID), subdomain), - NodeSelector: nodeSelector(options.NodeSelector, config.PodNodeSelector, step.Environment["CI_SYSTEM_PLATFORM"]), + NodeSelector: nodeSelector(options.NodeSelector, config.PodNodeSelector, config.PodNodeSelectorAllowFromStep, step.Environment["CI_SYSTEM_PLATFORM"]), Tolerations: tolerations(options.Tolerations), Affinity: affinity(options.Affinity, config.PodAffinity, config.PodAffinityAllowFromStep), SecurityContext: podSecurityContext(options.SecurityContext, config.SecurityContext, step.Privileged, options.HostUsers), @@ -512,7 +512,7 @@ func resourceList(resources map[string]string) (kube_core_v1.ResourceList, error return requestResources, nil } -func nodeSelector(backendNodeSelector, configNodeSelector map[string]string, platform string) map[string]string { +func nodeSelector(backendNodeSelector, configNodeSelector map[string]string, allowFromStep bool, platform string) map[string]string { nodeSelector := make(map[string]string) if platform != "" { @@ -527,8 +527,12 @@ func nodeSelector(backendNodeSelector, configNodeSelector map[string]string, pla } if len(backendNodeSelector) > 0 { - log.Trace().Msgf("appending labels to the node selector from the backend options: %v", backendNodeSelector) - maps.Copy(nodeSelector, backendNodeSelector) + if allowFromStep { + log.Trace().Msgf("appending labels to the node selector from the backend options: %v", backendNodeSelector) + maps.Copy(nodeSelector, backendNodeSelector) + } else { + log.Debug().Msg("Step node selector is disallowed by instance configuration, ignoring it") + } } return nodeSelector diff --git a/pipeline/backend/kubernetes/pod_test.go b/pipeline/backend/kubernetes/pod_test.go index dd5ec1c21..99d38368b 100644 --- a/pipeline/backend/kubernetes/pod_test.go +++ b/pipeline/backend/kubernetes/pod_test.go @@ -466,6 +466,7 @@ func TestFullPod(t *testing.T) { PodAnnotationsAllowFromStep: true, PodTolerationsAllowFromStep: true, PodNodeSelector: map[string]string{"topology.kubernetes.io/region": "eu-central-1"}, + PodNodeSelectorAllowFromStep: true, SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, ServiceAccountNameAllowFromStep: true, },