diff --git a/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go b/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go index 3368d03d8..c5147fbbf 100644 --- a/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go +++ b/apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -146,6 +146,23 @@ type RoutingConfig struct { TLSCertificateConfigmapRef *ConfigmapReference `json:"tlsCertificateConfigmapRef,omitempty"` } +// OverrideConfig defines configuration options for controlling which fields are denied +// in `container-overrides` and `pod-overrides` DevWorkspace attributes. +// Entries support value restrictions: "fieldName" denies the field entirely, +// "fieldName=value" denies only the specific value (other values are allowed). +type OverrideConfig struct { + // DeniedContainerOverrideFields defines a list of container-level fields that are denied + // in `container-overrides` attributes. The following fields are always + // restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + // +kubebuilder:validation:Optional + DeniedContainerOverrideFields []string `json:"deniedContainerOverrideFields,omitempty"` + // DeniedPodOverrideFields defines a list of pod-level fields that are denied + // in `pod-overrides` attributes. The following fields are always restricted + // regardless of this setting: `containers`, `initContainers`. + // +kubebuilder:validation:Optional + DeniedPodOverrideFields []string `json:"deniedPodOverrideFields,omitempty"` +} + type WorkspaceConfig struct { // ProjectCloneConfig defines configuration related to the project clone init container // that is used to clone git projects into the DevWorkspace. @@ -264,6 +281,9 @@ type WorkspaceConfig struct { // InitContainers defines a list of Kubernetes init containers that are automatically injected into all workspace pods. // Typical uses cases include injecting organization tools/configs, initializing persistent home, etc. InitContainers []corev1.Container `json:"initContainers,omitempty"` + // Overrides defines configuration options for `container-overrides` and + // `pod-overrides` DevWorkspace attributes. + Overrides *OverrideConfig `json:"overrides,omitempty"` } type WebhookConfig struct { diff --git a/apis/controller/v1alpha1/zz_generated.deepcopy.go b/apis/controller/v1alpha1/zz_generated.deepcopy.go index f795f3d43..af9913546 100644 --- a/apis/controller/v1alpha1/zz_generated.deepcopy.go +++ b/apis/controller/v1alpha1/zz_generated.deepcopy.go @@ -515,6 +515,31 @@ func (in *OrasConfig) DeepCopy() *OrasConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OverrideConfig) DeepCopyInto(out *OverrideConfig) { + *out = *in + if in.DeniedContainerOverrideFields != nil { + in, out := &in.DeniedContainerOverrideFields, &out.DeniedContainerOverrideFields + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.DeniedPodOverrideFields != nil { + in, out := &in.DeniedPodOverrideFields, &out.DeniedPodOverrideFields + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OverrideConfig. +func (in *OverrideConfig) DeepCopy() *OverrideConfig { + if in == nil { + return nil + } + out := new(OverrideConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PersistentHomeConfig) DeepCopyInto(out *PersistentHomeConfig) { *out = *in @@ -939,6 +964,11 @@ func (in *WorkspaceConfig) DeepCopyInto(out *WorkspaceConfig) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Overrides != nil { + in, out := &in.Overrides, &out.Overrides + *out = new(OverrideConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceConfig. diff --git a/controllers/workspace/devworkspace_controller.go b/controllers/workspace/devworkspace_controller.go index 6a25994da..a760ed9a4 100644 --- a/controllers/workspace/devworkspace_controller.go +++ b/controllers/workspace/devworkspace_controller.go @@ -23,6 +23,7 @@ import ( "time" "github.com/devfile/devworkspace-operator/pkg/library/initcontainers" + "github.com/devfile/devworkspace-operator/pkg/library/overrides" "github.com/devfile/devworkspace-operator/pkg/library/ssh" dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" @@ -339,6 +340,7 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request workspace.Config.Workspace.DefaultContainerResources, workspace.Config.Workspace.ContainerResourceCaps, workspace.Config.Workspace.PostStartTimeout, + overrides.GetDeniedContainerOverrideFields(workspace), postStartDebugTrapSleepDuration, ) if err != nil { diff --git a/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml b/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml index 62a2b4b85..76b86314d 100644 --- a/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml +++ b/deploy/bundle/manifests/controller.devfile.io_devworkspaceoperatorconfigs.yaml @@ -4012,6 +4012,28 @@ spec: - name type: object type: array + overrides: + description: |- + Overrides defines configuration options for `container-overrides` and + `pod-overrides` DevWorkspace attributes. + properties: + deniedContainerOverrideFields: + description: |- + DeniedContainerOverrideFields defines a list of container-level fields that are denied + in `container-overrides` attributes. The following fields are always + restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + items: + type: string + type: array + deniedPodOverrideFields: + description: |- + DeniedPodOverrideFields defines a list of pod-level fields that are denied + in `pod-overrides` attributes. The following fields are always restricted + regardless of this setting: `containers`, `initContainers`. + items: + type: string + type: array + type: object persistUserHome: description: |- PersistUserHome defines configuration options for persisting the `/home/user/` diff --git a/deploy/deployment/kubernetes/combined.yaml b/deploy/deployment/kubernetes/combined.yaml index dbaffa512..9ac20874c 100644 --- a/deploy/deployment/kubernetes/combined.yaml +++ b/deploy/deployment/kubernetes/combined.yaml @@ -4213,6 +4213,28 @@ spec: - name type: object type: array + overrides: + description: |- + Overrides defines configuration options for `container-overrides` and + `pod-overrides` DevWorkspace attributes. + properties: + deniedContainerOverrideFields: + description: |- + DeniedContainerOverrideFields defines a list of container-level fields that are denied + in `container-overrides` attributes. The following fields are always + restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + items: + type: string + type: array + deniedPodOverrideFields: + description: |- + DeniedPodOverrideFields defines a list of pod-level fields that are denied + in `pod-overrides` attributes. The following fields are always restricted + regardless of this setting: `containers`, `initContainers`. + items: + type: string + type: array + type: object persistUserHome: description: |- PersistUserHome defines configuration options for persisting the `/home/user/` diff --git a/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml b/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml index c20a7ec63..c66cc3092 100644 --- a/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml +++ b/deploy/deployment/kubernetes/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml @@ -4213,6 +4213,28 @@ spec: - name type: object type: array + overrides: + description: |- + Overrides defines configuration options for `container-overrides` and + `pod-overrides` DevWorkspace attributes. + properties: + deniedContainerOverrideFields: + description: |- + DeniedContainerOverrideFields defines a list of container-level fields that are denied + in `container-overrides` attributes. The following fields are always + restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + items: + type: string + type: array + deniedPodOverrideFields: + description: |- + DeniedPodOverrideFields defines a list of pod-level fields that are denied + in `pod-overrides` attributes. The following fields are always restricted + regardless of this setting: `containers`, `initContainers`. + items: + type: string + type: array + type: object persistUserHome: description: |- PersistUserHome defines configuration options for persisting the `/home/user/` diff --git a/deploy/deployment/openshift/combined.yaml b/deploy/deployment/openshift/combined.yaml index fd0318841..901f877bc 100644 --- a/deploy/deployment/openshift/combined.yaml +++ b/deploy/deployment/openshift/combined.yaml @@ -4213,6 +4213,28 @@ spec: - name type: object type: array + overrides: + description: |- + Overrides defines configuration options for `container-overrides` and + `pod-overrides` DevWorkspace attributes. + properties: + deniedContainerOverrideFields: + description: |- + DeniedContainerOverrideFields defines a list of container-level fields that are denied + in `container-overrides` attributes. The following fields are always + restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + items: + type: string + type: array + deniedPodOverrideFields: + description: |- + DeniedPodOverrideFields defines a list of pod-level fields that are denied + in `pod-overrides` attributes. The following fields are always restricted + regardless of this setting: `containers`, `initContainers`. + items: + type: string + type: array + type: object persistUserHome: description: |- PersistUserHome defines configuration options for persisting the `/home/user/` diff --git a/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml b/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml index c20a7ec63..c66cc3092 100644 --- a/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml +++ b/deploy/deployment/openshift/objects/devworkspaceoperatorconfigs.controller.devfile.io.CustomResourceDefinition.yaml @@ -4213,6 +4213,28 @@ spec: - name type: object type: array + overrides: + description: |- + Overrides defines configuration options for `container-overrides` and + `pod-overrides` DevWorkspace attributes. + properties: + deniedContainerOverrideFields: + description: |- + DeniedContainerOverrideFields defines a list of container-level fields that are denied + in `container-overrides` attributes. The following fields are always + restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + items: + type: string + type: array + deniedPodOverrideFields: + description: |- + DeniedPodOverrideFields defines a list of pod-level fields that are denied + in `pod-overrides` attributes. The following fields are always restricted + regardless of this setting: `containers`, `initContainers`. + items: + type: string + type: array + type: object persistUserHome: description: |- PersistUserHome defines configuration options for persisting the `/home/user/` diff --git a/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml b/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml index f9748de3b..f81c4b186 100644 --- a/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml +++ b/deploy/templates/crd/bases/controller.devfile.io_devworkspaceoperatorconfigs.yaml @@ -4211,6 +4211,28 @@ spec: - name type: object type: array + overrides: + description: |- + Overrides defines configuration options for `container-overrides` and + `pod-overrides` DevWorkspace attributes. + properties: + deniedContainerOverrideFields: + description: |- + DeniedContainerOverrideFields defines a list of container-level fields that are denied + in `container-overrides` attributes. The following fields are always + restricted regardless of this setting: `name`, `image`, `command`, `args`, `ports`, `env`. + items: + type: string + type: array + deniedPodOverrideFields: + description: |- + DeniedPodOverrideFields defines a list of pod-level fields that are denied + in `pod-overrides` attributes. The following fields are always restricted + regardless of this setting: `containers`, `initContainers`. + items: + type: string + type: array + type: object persistUserHome: description: |- PersistUserHome defines configuration options for persisting the `/home/user/` diff --git a/docs/dwo-configuration.md b/docs/dwo-configuration.md index c7c5f8611..f1b5142f7 100644 --- a/docs/dwo-configuration.md +++ b/docs/dwo-configuration.md @@ -310,3 +310,83 @@ config: ### Execution Order Custom init containers are injected after the project-clone init container in the order they are defined in the configuration. The `init-persistent-home` container runs in this sequence along with other custom init containers. + +## Restricting override fields + +The DevWorkspace Operator allows cluster administrators to restrict which fields +can be set via `pod-overrides` and `container-overrides` attributes. +Fields are restricted using a deny list -- by default, all fields are allowed unless explicitly denied. + +The deny list supports two formats: + +- `"fieldName"` -- denies the field entirely, regardless of value +- `"fieldName=value"` -- denies only a specific value for the field + +For nested fields such as securityContext or volumes, use dot notation: `securityContext.privileged=true`. + +On Kubernetes, the operator ships with default denied fields that align +with the Pod Security Standards baseline profile. +On OpenShift, no fields are denied by default since Security Context Constraints (SCC) +already enforce security policies at the admission level. + +**Important:** Configuring `deniedContainerOverrideFields` or `deniedPodOverrideFields` +**replaces** the platform defaults entirely. Admins who want to extend the default +deny list must re-include the default entries alongside any additional restrictions. + +**Limitation for plain boolean fields in pod overrides:** +Some `PodSpec` fields such as `hostNetwork`, `hostPID`, and `hostIPC` are plain `bool` +types in the Kubernetes API (not `*bool` pointers). Because Go zero-initializes +unset `bool` fields to `false`, the operator cannot distinguish between a field that +was explicitly set to `false` and one that was simply omitted. As a result, using the +bare field name format (e.g. `"hostNetwork"`) to deny these fields entirely will reject +**all** pod overrides, including those that never mention the field. To avoid this, +use the value-specific format instead (e.g. `"hostNetwork=true"`). The default denied +fields already follow this pattern. This limitation does not affect `*bool` pointer +fields (e.g. `automountServiceAccountToken`, `shareProcessNamespace`, `hostUsers`) +or non-boolean fields. + +For example, on Kubernetes, to add `volumeMounts` and `lifecycle` restrictions +while keeping the default denied container fields: + +```yaml +apiVersion: controller.devfile.io/v1alpha1 +kind: DevWorkspaceOperatorConfig +metadata: + name: devworkspace-operator-config +config: + workspace: + overrides: + deniedContainerOverrideFields: + # Default Kubernetes denied fields (must be re-listed to retain them) + - "securityContext.privileged=true" + - "securityContext.runAsNonRoot=false" + - "securityContext.runAsUser=0" + - "securityContext.allowPrivilegeEscalation=true" + - "securityContext.procMount=Unmasked" + - "securityContext.capabilities.add" + # Additional restrictions + - "volumeMounts" + - "lifecycle" +``` + +Similarly, to extend the default denied pod override fields on Kubernetes: + +```yaml +apiVersion: controller.devfile.io/v1alpha1 +kind: DevWorkspaceOperatorConfig +metadata: + name: devworkspace-operator-config +config: + workspace: + overrides: + deniedPodOverrideFields: + # Default Kubernetes denied fields (must be re-listed to retain them) + - "hostNetwork=true" + - "hostPID=true" + - "hostIPC=true" + - "securityContext.runAsNonRoot=false" + - "securityContext.runAsUser=0" + - "volumes.hostPath" + # Additional restrictions + - "hostUsers=false" +``` diff --git a/pkg/config/common_test.go b/pkg/config/common_test.go index 0bbbb4550..7a161b074 100644 --- a/pkg/config/common_test.go +++ b/pkg/config/common_test.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -56,6 +56,7 @@ func setupForTest(t *testing.T) { infrastructure.InitializeForTesting(infrastructure.Kubernetes) setDefaultPodSecurityContext() setDefaultContainerSecurityContext() + setDefaultOverrideConfig() configNamespace = testNamespace originalDefaultConfig := defaultConfig.DeepCopy() t.Cleanup(func() { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index bfb31b034..0b1fb6cbb 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -116,7 +116,31 @@ var ( } defaultKubernetesContainerSecurityContext = &corev1.SecurityContext{} defaultOpenShiftPodSecurityContext = &corev1.PodSecurityContext{} - defaultOpenShiftContainerSecurityContext = &corev1.SecurityContext{ + + defaultOpenShiftOverrideConfig = &v1alpha1.OverrideConfig{ + DeniedContainerOverrideFields: []string{}, + DeniedPodOverrideFields: []string{}, + } + defaultKubernetesOverrideConfig = &v1alpha1.OverrideConfig{ + DeniedContainerOverrideFields: []string{ + "securityContext.privileged=true", + "securityContext.runAsNonRoot=false", + "securityContext.runAsUser=0", + "securityContext.allowPrivilegeEscalation=true", + "securityContext.procMount=Unmasked", + "securityContext.capabilities.add", + }, + DeniedPodOverrideFields: []string{ + "hostNetwork=true", + "hostPID=true", + "hostIPC=true", + "securityContext.runAsNonRoot=false", + "securityContext.runAsUser=0", + "volumes.hostPath", + }, + } + + defaultOpenShiftContainerSecurityContext = &corev1.SecurityContext{ ReadOnlyRootFilesystem: pointer.Bool(false), RunAsNonRoot: pointer.Bool(true), AllowPrivilegeEscalation: pointer.Bool(false), @@ -157,3 +181,15 @@ func setDefaultContainerSecurityContext() error { } return nil } + +func setDefaultOverrideConfig() error { + if !infrastructure.IsInitialized() { + return fmt.Errorf("can not set default override config, infrastructure not detected") + } + if infrastructure.IsOpenShift() { + defaultConfig.Workspace.Overrides = defaultOpenShiftOverrideConfig + } else { + defaultConfig.Workspace.Overrides = defaultKubernetesOverrideConfig + } + return nil +} diff --git a/pkg/config/sync.go b/pkg/config/sync.go index bf474e986..32ddafb83 100644 --- a/pkg/config/sync.go +++ b/pkg/config/sync.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -101,6 +101,7 @@ func SetGlobalConfigForTesting(testConfig *controller.OperatorConfiguration) { defer configMutex.Unlock() setDefaultPodSecurityContext() setDefaultContainerSecurityContext() + setDefaultOverrideConfig() internalConfig = defaultConfig.DeepCopy() mergeConfig(testConfig, internalConfig) } @@ -115,6 +116,9 @@ func SetupControllerConfig(client crclient.Client) error { if err := setDefaultContainerSecurityContext(); err != nil { return err } + if err := setDefaultOverrideConfig(); err != nil { + return err + } internalConfig = &controller.OperatorConfiguration{} @@ -506,6 +510,18 @@ func mergeConfig(from, to *controller.OperatorConfiguration) { } to.Workspace.InitContainers = initContainersCopy } + + if from.Workspace.Overrides != nil { + if to.Workspace.Overrides == nil { + to.Workspace.Overrides = &controller.OverrideConfig{} + } + if from.Workspace.Overrides.DeniedContainerOverrideFields != nil { + to.Workspace.Overrides.DeniedContainerOverrideFields = from.Workspace.Overrides.DeniedContainerOverrideFields + } + if from.Workspace.Overrides.DeniedPodOverrideFields != nil { + to.Workspace.Overrides.DeniedPodOverrideFields = from.Workspace.Overrides.DeniedPodOverrideFields + } + } } } @@ -777,6 +793,14 @@ func GetCurrentConfigString(currConfig *controller.OperatorConfiguration) string if workspace.HostUsers != nil { config = append(config, fmt.Sprintf("workspace.hostUsers=%t", *workspace.HostUsers)) } + if workspace.Overrides != nil { + if workspace.Overrides.DeniedContainerOverrideFields != nil { + config = append(config, fmt.Sprintf("workspace.overrides.deniedContainerOverrideFields=[%s]", strings.Join(workspace.Overrides.DeniedContainerOverrideFields, ", "))) + } + if workspace.Overrides.DeniedPodOverrideFields != nil { + config = append(config, fmt.Sprintf("workspace.overrides.deniedPodOverrideFields=[%s]", strings.Join(workspace.Overrides.DeniedPodOverrideFields, ", "))) + } + } if len(workspace.InitContainers) > 0 { initContainerNames := make([]string, len(workspace.InitContainers)) for i, container := range workspace.InitContainers { diff --git a/pkg/library/container/container.go b/pkg/library/container/container.go index 88bfd9854..660f28380 100644 --- a/pkg/library/container/container.go +++ b/pkg/library/container/container.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -53,6 +53,7 @@ func GetKubeContainersFromDevfile( defaultResources *corev1.ResourceRequirements, resourceCaps *corev1.ResourceRequirements, postStartTimeout string, + deniedFields []string, postStartDebugTrapSleepDuration string, ) (*v1alpha1.PodAdditions, error) { if !flatten.DevWorkspaceIsFlattened(workspace, nil) { @@ -77,7 +78,7 @@ func GetKubeContainersFromDevfile( return nil, err } if overrides.NeedsContainerOverride(&component) { - patchedContainer, err := overrides.ApplyContainerOverrides(&component, k8sContainer) + patchedContainer, err := overrides.ApplyContainerOverrides(&component, k8sContainer, deniedFields) if err != nil { return nil, err } @@ -111,7 +112,7 @@ func GetKubeContainersFromDevfile( return nil, err } if overrides.NeedsContainerOverride(&initComponent) { - patchedContainer, err := overrides.ApplyContainerOverrides(&initComponent, k8sContainer) + patchedContainer, err := overrides.ApplyContainerOverrides(&initComponent, k8sContainer, deniedFields) if err != nil { return nil, err } diff --git a/pkg/library/container/container_test.go b/pkg/library/container/container_test.go index 9f1d56e62..9ec58240c 100644 --- a/pkg/library/container/container_test.go +++ b/pkg/library/container/container_test.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -87,7 +87,7 @@ func TestGetKubeContainersFromDevfile(t *testing.T) { t.Run(tt.Name, func(t *testing.T) { // sanity check that file is read correctly. assert.True(t, len(tt.Input.Components) > 0, "Input defines no components") - gotPodAdditions, err := GetKubeContainersFromDevfile(tt.Input, nil, testImagePullPolicy, defaultResources, nil, "", "") + gotPodAdditions, err := GetKubeContainersFromDevfile(tt.Input, nil, testImagePullPolicy, defaultResources, nil, "", nil, "") if tt.Output.ErrRegexp != nil && assert.Error(t, err) { assert.Regexp(t, *tt.Output.ErrRegexp, err.Error(), "Error message should match") } else { diff --git a/pkg/library/overrides/container_restrictions.go b/pkg/library/overrides/container_restrictions.go new file mode 100644 index 000000000..297ecc65c --- /dev/null +++ b/pkg/library/overrides/container_restrictions.go @@ -0,0 +1,278 @@ +// Copyright (c) 2019-2026 Red Hat, Inc. +// 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 overrides + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +func getContainerDeniedErr(msg string) error { + return fmt.Errorf("cannot use container-overrides to override container %s", msg) +} + +func restrictContainerOverride(override *corev1.Container, deniedFields []string) error { + if override.Name != "" { + return getContainerDeniedErr("name") + } + if override.Image != "" { + return getContainerDeniedErr("image") + } + if override.Command != nil { + return getContainerDeniedErr("command") + } + if override.Args != nil { + return getContainerDeniedErr("args") + } + if override.Ports != nil { + return getContainerDeniedErr("ports") + } + if override.Env != nil { + return getContainerDeniedErr("env") + } + + rules := parseDeniedFieldRules(deniedFields, getContainerDeniedErr) + + if err := rules.checkString("workingDir", &override.WorkingDir); err != nil { + return err + } + if err := checkEnvFrom(override.EnvFrom, rules); err != nil { + return err + } + if err := checkResources(&override.Resources, rules); err != nil { + return err + } + if err := rules.checkString("restartPolicy", (*string)(override.RestartPolicy)); err != nil { + return err + } + if len(override.RestartPolicyRules) != 0 { + if err := rules.checkField("restartPolicyRules"); err != nil { + return err + } + } + if len(override.ResizePolicy) != 0 { + if err := rules.checkField("resizePolicy"); err != nil { + return err + } + } + if err := checkVolumeMounts(override.VolumeMounts, rules); err != nil { + return err + } + if err := checkVolumeDevices(override.VolumeDevices, rules); err != nil { + return err + } + if override.Lifecycle != nil { + if err := rules.checkField("lifecycle"); err != nil { + return err + } + } + if override.ReadinessProbe != nil { + if err := rules.checkField("readinessProbe"); err != nil { + return err + } + } + if override.StartupProbe != nil { + if err := rules.checkField("startupProbe"); err != nil { + return err + } + } + if override.LivenessProbe != nil { + if err := rules.checkField("livenessProbe"); err != nil { + return err + } + } + if err := rules.checkString("terminationMessagePath", &override.TerminationMessagePath); err != nil { + return err + } + if err := rules.checkString("terminationMessagePolicy", (*string)(&override.TerminationMessagePolicy)); err != nil { + return err + } + if err := rules.checkString("imagePullPolicy", (*string)(&override.ImagePullPolicy)); err != nil { + return err + } + if err := checkContainerSecurityContext(override.SecurityContext, rules); err != nil { + return err + } + if err := rules.checkBool("stdin", &override.Stdin); err != nil { + return err + } + if err := rules.checkBool("stdinOnce", &override.StdinOnce); err != nil { + return err + } + if err := rules.checkBool("tty", &override.TTY); err != nil { + return err + } + + return nil +} + +func checkEnvFrom(envsFrom []corev1.EnvFromSource, rules *deniedFieldRules) error { + if len(envsFrom) == 0 { + return nil + } + + if err := rules.checkField("envFrom"); err != nil { + return err + } + + for _, envFrom := range envsFrom { + if envFrom.ConfigMapRef != nil { + if err := rules.checkField("envFrom.configMapRef"); err != nil { + return err + } + if err := rules.checkString("envFrom.configMapRef.name", &envFrom.ConfigMapRef.Name); err != nil { + return err + } + } + if envFrom.SecretRef != nil { + if err := rules.checkField("envFrom.secretRef"); err != nil { + return err + } + if err := rules.checkString("envFrom.secretRef.name", &envFrom.SecretRef.Name); err != nil { + return err + } + } + } + + return nil +} + +func checkVolumeMounts(mounts []corev1.VolumeMount, rules *deniedFieldRules) error { + if len(mounts) == 0 { + return nil + } + + if err := rules.checkField("volumeMounts"); err != nil { + return err + } + for _, mount := range mounts { + if err := rules.checkBool("volumeMounts.readOnly", &mount.ReadOnly); err != nil { + return err + } + if err := rules.checkString("volumeMounts.recursiveReadOnly", (*string)(mount.RecursiveReadOnly)); err != nil { + return err + } + if err := rules.checkString("volumeMounts.mountPropagation", (*string)(mount.MountPropagation)); err != nil { + return err + } + if err := rules.checkString("volumeMounts.name", &mount.Name); err != nil { + return err + } + if err := rules.checkString("volumeMounts.mountPath", &mount.MountPath); err != nil { + return err + } + if err := rules.checkString("volumeMounts.subPath", &mount.SubPath); err != nil { + return err + } + if err := rules.checkString("volumeMounts.subPathExpr", &mount.SubPathExpr); err != nil { + return err + } + } + + return nil +} + +func checkVolumeDevices(devices []corev1.VolumeDevice, rules *deniedFieldRules) error { + if len(devices) == 0 { + return nil + } + + if err := rules.checkField("volumeDevices"); err != nil { + return err + } + for _, device := range devices { + if err := rules.checkString("volumeDevices.name", &device.Name); err != nil { + return err + } + if err := rules.checkString("volumeDevices.devicePath", &device.DevicePath); err != nil { + return err + } + } + + return nil +} + +func checkCapabilities(caps *corev1.Capabilities, rules *deniedFieldRules) error { + if caps == nil { + return nil + } + + if err := rules.checkField("securityContext.capabilities"); err != nil { + return err + } + for _, addCapability := range caps.Add { + if err := rules.checkString("securityContext.capabilities.add", (*string)(&addCapability)); err != nil { + return err + } + } + for _, dropCapability := range caps.Drop { + if err := rules.checkString("securityContext.capabilities.drop", (*string)(&dropCapability)); err != nil { + return err + } + } + + return nil +} + +func checkContainerSecurityContext(sc *corev1.SecurityContext, rules *deniedFieldRules) error { + if sc == nil { + return nil + } + + if err := rules.checkField("securityContext"); err != nil { + return err + } + if err := checkCapabilities(sc.Capabilities, rules); err != nil { + return err + } + if err := rules.checkBool("securityContext.privileged", sc.Privileged); err != nil { + return err + } + if sc.SELinuxOptions != nil { + if err := rules.checkField("securityContext.seLinuxOptions"); err != nil { + return err + } + } + if err := rules.checkInt64("securityContext.runAsUser", sc.RunAsUser); err != nil { + return err + } + if err := rules.checkInt64("securityContext.runAsGroup", sc.RunAsGroup); err != nil { + return err + } + if err := rules.checkBool("securityContext.runAsNonRoot", sc.RunAsNonRoot); err != nil { + return err + } + if err := rules.checkBool("securityContext.readOnlyRootFilesystem", sc.ReadOnlyRootFilesystem); err != nil { + return err + } + if err := rules.checkBool("securityContext.allowPrivilegeEscalation", sc.AllowPrivilegeEscalation); err != nil { + return err + } + if err := rules.checkString("securityContext.procMount", (*string)(sc.ProcMount)); err != nil { + return err + } + if sc.SeccompProfile != nil { + if err := rules.checkField("securityContext.seccompProfile"); err != nil { + return err + } + } + if sc.AppArmorProfile != nil { + if err := rules.checkField("securityContext.appArmorProfile"); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/library/overrides/container_restrictions_test.go b/pkg/library/overrides/container_restrictions_test.go new file mode 100644 index 000000000..51e27294c --- /dev/null +++ b/pkg/library/overrides/container_restrictions_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2019-2026 Red Hat, Inc. +// 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 overrides + +import ( + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/api/v2/pkg/attributes" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/json" + + "github.com/devfile/devworkspace-operator/pkg/constants" +) + +func TestRestrictContainerOverride(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + int64Ptr := func(i int64) *int64 { return &i } + + tests := []struct { + Name string + DeniedFields []string + Override corev1.Container + ExpectErr bool + ErrContains string + }{ + { + Name: "no denied fields allows everything", + Override: corev1.Container{WorkingDir: "/test"}, + }, + { + Name: "name always denied", + Override: corev1.Container{Name: "test"}, + ExpectErr: true, + ErrContains: "name", + }, + { + Name: "image always denied", + Override: corev1.Container{Image: "test"}, + ExpectErr: true, + ErrContains: "image", + }, + { + Name: "command always denied", + Override: corev1.Container{Command: []string{"test"}}, + ExpectErr: true, + ErrContains: "command", + }, + { + Name: "args always denied", + Override: corev1.Container{Args: []string{"test"}}, + ExpectErr: true, + ErrContains: "args", + }, + { + Name: "ports always denied", + Override: corev1.Container{Ports: []corev1.ContainerPort{{ContainerPort: 8080}}}, + ExpectErr: true, + ErrContains: "ports", + }, + { + Name: "env always denied", + Override: corev1.Container{Env: []corev1.EnvVar{{Name: "KEY", Value: "val"}}}, + ExpectErr: true, + ErrContains: "env", + }, + { + Name: "deny field entirely blocks", + DeniedFields: []string{"lifecycle"}, + Override: corev1.Container{Lifecycle: &corev1.Lifecycle{ + PostStart: &corev1.LifecycleHandler{Exec: &corev1.ExecAction{Command: []string{"echo"}}}, + }}, + ExpectErr: true, + ErrContains: "lifecycle", + }, + { + Name: "deny specific value blocks matching", + DeniedFields: []string{"securityContext.privileged=true"}, + Override: corev1.Container{SecurityContext: &corev1.SecurityContext{Privileged: boolPtr(true)}}, + ExpectErr: true, + ErrContains: "securityContext.privileged=true", + }, + { + Name: "deny specific value allows non-matching", + DeniedFields: []string{"securityContext.privileged=true"}, + Override: corev1.Container{SecurityContext: &corev1.SecurityContext{Privileged: boolPtr(false)}}, + }, + { + Name: "pointer bool deny-all skips nil", + DeniedFields: []string{"securityContext.privileged"}, + Override: corev1.Container{SecurityContext: &corev1.SecurityContext{RunAsUser: int64Ptr(1000)}}, + }, + { + Name: "pointer bool deny-all blocks set value", + DeniedFields: []string{"securityContext.privileged"}, + Override: corev1.Container{SecurityContext: &corev1.SecurityContext{Privileged: boolPtr(false)}}, + ExpectErr: true, + ErrContains: "securityContext.privileged", + }, + { + Name: "plain bool deny-all rejects zero value (known limitation)", + DeniedFields: []string{"stdin"}, + Override: corev1.Container{}, + ExpectErr: true, + ErrContains: "stdin", + }, + { + Name: "plain bool deny specific value allows zero", + DeniedFields: []string{"stdin=true"}, + Override: corev1.Container{}, + }, + { + Name: "deny volumeMounts blocks", + DeniedFields: []string{"volumeMounts"}, + Override: corev1.Container{VolumeMounts: []corev1.VolumeMount{{Name: "vol", MountPath: "/mnt"}}}, + ExpectErr: true, + ErrContains: "volumeMounts", + }, + { + Name: "deny envFrom blocks", + DeniedFields: []string{"envFrom"}, + Override: corev1.Container{EnvFrom: []corev1.EnvFromSource{{ + ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "cm"}}, + }}}, + ExpectErr: true, + ErrContains: "envFrom", + }, + { + Name: "deny capabilities.add blocks", + DeniedFields: []string{"securityContext.capabilities.add"}, + Override: corev1.Container{SecurityContext: &corev1.SecurityContext{ + Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"NET_ADMIN"}}, + }}, + ExpectErr: true, + ErrContains: "securityContext.capabilities.add", + }, + { + Name: "multiple denied fields catches match", + DeniedFields: []string{"securityContext.privileged=true", "securityContext.allowPrivilegeEscalation=true"}, + Override: corev1.Container{SecurityContext: &corev1.SecurityContext{Privileged: boolPtr(true)}}, + ExpectErr: true, + ErrContains: "securityContext.privileged=true", + }, + { + Name: "multiple denied fields passes when none match", + DeniedFields: []string{"securityContext.privileged=true"}, + Override: corev1.Container{}, + }, + { + Name: "deny readinessProbe blocks", + DeniedFields: []string{"readinessProbe"}, + Override: corev1.Container{ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{Exec: &corev1.ExecAction{Command: []string{"true"}}}, + }}, + ExpectErr: true, + ErrContains: "readinessProbe", + }, + { + Name: "deny resources.limits blocks", + DeniedFields: []string{"resources.limits"}, + Override: corev1.Container{Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")}, + }}, + ExpectErr: true, + ErrContains: "resources.limits", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + err := restrictContainerOverride(&tt.Override, tt.DeniedFields) + if tt.ExpectErr { + if assert.Error(t, err, "Should return error") { + assert.Contains(t, err.Error(), tt.ErrContains, "Error message should contain expected string") + } + } else { + assert.NoError(t, err, "Should not return error") + } + }) + } +} + +func TestApplyContainerOverridesStripsUnknownFields(t *testing.T) { + overrideJSON := `{"workingDir":"/workspace","futureSecurityField":"malicious-value","unknownNested":{"key":"val"}}` + + component := &dw.Component{ + Name: "test-component", + Attributes: attributes.Attributes{ + constants.ContainerOverridesAttribute: apiext.JSON{Raw: []byte(overrideJSON)}, + }, + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{Image: "test-image"}, + }, + }, + } + container := &corev1.Container{ + Name: "test-component", + Image: "test-image", + } + + patched, err := ApplyContainerOverrides(component, container, nil) + if !assert.NoError(t, err, "Should not return error") { + return + } + + assert.Equal(t, "/workspace", patched.WorkingDir, "Known field should be applied") + + patchedBytes, err := json.Marshal(patched) + if !assert.NoError(t, err) { + return + } + assert.NotContains(t, string(patchedBytes), "futureSecurityField", + "Unknown fields should be stripped by Go struct round-trip") + assert.NotContains(t, string(patchedBytes), "unknownNested", + "Unknown nested fields should be stripped by Go struct round-trip") +} diff --git a/pkg/library/overrides/containers.go b/pkg/library/overrides/containers.go index 2e68b26b9..fab5ce356 100644 --- a/pkg/library/overrides/containers.go +++ b/pkg/library/overrides/containers.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -38,13 +38,13 @@ func NeedsContainerOverride(component *dw.Component) bool { return component.Container != nil && component.Attributes.Exists(constants.ContainerOverridesAttribute) } -func ApplyContainerOverrides(component *dw.Component, container *corev1.Container) (*corev1.Container, error) { +func ApplyContainerOverrides(component *dw.Component, container *corev1.Container, deniedFields []string) (*corev1.Container, error) { override := &corev1.Container{} if err := component.Attributes.GetInto(constants.ContainerOverridesAttribute, override); err != nil { return nil, fmt.Errorf("failed to parse %s attribute on component %s: %w", constants.ContainerOverridesAttribute, component.Name, err) } - if err := restrictContainerOverride(override); err != nil { - return nil, fmt.Errorf("failed to parse %s attribute on component %s: %w", constants.ContainerOverridesAttribute, component.Name, err) + if err := restrictContainerOverride(override, deniedFields); err != nil { + return nil, fmt.Errorf("invalid %s attribute on component %s: %w", constants.ContainerOverridesAttribute, component.Name, err) } overrideJSON := component.Attributes[constants.ContainerOverridesAttribute] @@ -74,35 +74,6 @@ func ApplyContainerOverrides(component *dw.Component, container *corev1.Containe return patched, nil } -// restrictContainerOverride unsets fields on a container that should not be -// considered for container overrides. These fields are generally available to -// set as fields on the container component itself. -func restrictContainerOverride(override *corev1.Container) error { - invalidField := "" - if override.Name != "" { - invalidField = "name" - } - if override.Image != "" { - invalidField = "image" - } - if override.Command != nil { - invalidField = "command" - } - if override.Args != nil { - invalidField = "args" - } - if override.Ports != nil { - invalidField = "ports" - } - if override.Env != nil { - invalidField = "env" - } - if invalidField != "" { - return fmt.Errorf("cannot use container-overrides to override container %s", invalidField) - } - return nil -} - // handleDefaultedContainerFields fills partially-filled structs with defaulted fields // in a container. This is required to avoid repeatedly reconciling a container where e.g. // diff --git a/pkg/library/overrides/containers_test.go b/pkg/library/overrides/containers_test.go index 2a86a820e..776f607b4 100644 --- a/pkg/library/overrides/containers_test.go +++ b/pkg/library/overrides/containers_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -27,10 +27,18 @@ import ( ) func TestApplyContainerOverrides(t *testing.T) { + deniedOverrideFields := []string{ + "securityContext.privileged=true", + "securityContext.runAsNonRoot=false", + "securityContext.runAsUser=0", + "securityContext.allowPrivilegeEscalation=true", + "securityContext.procMount=Unmasked", + } + tests := loadAllContainerTestCasesOrPanic(t, "testdata/container-overrides") for _, tt := range tests { t.Run(fmt.Sprintf("%s (%s)", tt.Name, tt.originalFilename), func(t *testing.T) { - outContainer, err := ApplyContainerOverrides(tt.Input.Component, tt.Input.Container) + outContainer, err := ApplyContainerOverrides(tt.Input.Component, tt.Input.Container, deniedOverrideFields) if tt.Output.ErrRegexp != nil && assert.Error(t, err) { assert.Regexp(t, *tt.Output.ErrRegexp, err.Error(), "Error message should match") } else { diff --git a/pkg/library/overrides/pod_restrictions.go b/pkg/library/overrides/pod_restrictions.go new file mode 100644 index 000000000..7f8195faa --- /dev/null +++ b/pkg/library/overrides/pod_restrictions.go @@ -0,0 +1,398 @@ +// Copyright (c) 2019-2026 Red Hat, Inc. +// 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 overrides + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +func getPodDeniedErr(msg string) error { + return fmt.Errorf("cannot use pod-overrides to override pod %s", msg) +} + +func restrictPodOverride(override *corev1.PodSpec, deniedFields []string) error { + + if override.Containers != nil { + return getPodDeniedErr("containers") + } + if override.InitContainers != nil { + return getPodDeniedErr("initContainers") + } + + rules := parseDeniedFieldRules(deniedFields, getPodDeniedErr) + + if err := checkVolumes(override.Volumes, rules); err != nil { + return err + } + if len(override.EphemeralContainers) > 0 { + if err := rules.checkField("ephemeralContainers"); err != nil { + return err + } + } + if err := rules.checkString("restartPolicy", (*string)(&override.RestartPolicy)); err != nil { + return err + } + if err := rules.checkInt64("terminationGracePeriodSeconds", override.TerminationGracePeriodSeconds); err != nil { + return err + } + if err := rules.checkInt64("activeDeadlineSeconds", override.ActiveDeadlineSeconds); err != nil { + return err + } + if err := rules.checkString("dnsPolicy", (*string)(&override.DNSPolicy)); err != nil { + return err + } + if len(override.NodeSelector) > 0 { + if err := rules.checkField("nodeSelector"); err != nil { + return err + } + } + if err := rules.checkString("serviceAccountName", &override.ServiceAccountName); err != nil { + return err + } + if err := rules.checkString("deprecatedServiceAccount", &override.DeprecatedServiceAccount); err != nil { + return err + } + if err := rules.checkBool("automountServiceAccountToken", override.AutomountServiceAccountToken); err != nil { + return err + } + if err := rules.checkString("nodeName", &override.NodeName); err != nil { + return err + } + if err := rules.checkBool("hostIPC", &override.HostIPC); err != nil { + return err + } + if err := rules.checkBool("hostPID", &override.HostPID); err != nil { + return err + } + if err := rules.checkBool("hostNetwork", &override.HostNetwork); err != nil { + return err + } + if err := rules.checkBool("shareProcessNamespace", override.ShareProcessNamespace); err != nil { + return err + } + if err := checkPodSecurityContext(override.SecurityContext, rules); err != nil { + return err + } + if err := checkImagePullSecrets(override.ImagePullSecrets, rules); err != nil { + return err + } + if err := rules.checkString("hostname", &override.Hostname); err != nil { + return err + } + if err := rules.checkString("subdomain", &override.Subdomain); err != nil { + return err + } + if err := rules.checkString("schedulerName", &override.SchedulerName); err != nil { + return err + } + if override.Affinity != nil { + if err := rules.checkField("affinity"); err != nil { + return err + } + } + if len(override.Tolerations) > 0 { + if err := rules.checkField("tolerations"); err != nil { + return err + } + } + if len(override.HostAliases) > 0 { + if err := rules.checkField("hostAliases"); err != nil { + return err + } + } + if err := rules.checkString("priorityClassName", &override.PriorityClassName); err != nil { + return err + } + if err := rules.checkInt32("priority", override.Priority); err != nil { + return err + } + if override.DNSConfig != nil { + if err := rules.checkField("dnsConfig"); err != nil { + return err + } + } + if len(override.ReadinessGates) > 0 { + if err := rules.checkField("readinessGates"); err != nil { + return err + } + } + if err := rules.checkString("runtimeClassName", override.RuntimeClassName); err != nil { + return err + } + if err := rules.checkBool("enableServiceLinks", override.EnableServiceLinks); err != nil { + return err + } + if err := rules.checkString("preemptionPolicy", (*string)(override.PreemptionPolicy)); err != nil { + return err + } + if err := checkOverhead(override.Overhead, rules); err != nil { + return err + } + if len(override.TopologySpreadConstraints) > 0 { + if err := rules.checkField("topologySpreadConstraints"); err != nil { + return err + } + } + if err := rules.checkBool("setHostnameAsFQDN", override.SetHostnameAsFQDN); err != nil { + return err + } + if override.OS != nil { + if err := rules.checkField("os"); err != nil { + return err + } + } + if err := rules.checkBool("hostUsers", override.HostUsers); err != nil { + return err + } + if len(override.SchedulingGates) > 0 { + if err := rules.checkField("schedulingGates"); err != nil { + return err + } + } + if err := checkResourceClaims(override.ResourceClaims, rules); err != nil { + return err + } + if err := checkResources(override.Resources, rules); err != nil { + return err + } + if err := rules.checkString("hostnameOverride", override.HostnameOverride); err != nil { + return err + } + if override.WorkloadRef != nil { + if err := rules.checkField("workloadRef"); err != nil { + return err + } + } + + return nil +} + +func checkVolumes(volumes []corev1.Volume, rules *deniedFieldRules) error { + if len(volumes) == 0 { + return nil + } + + if err := rules.checkField("volumes"); err != nil { + return err + } + + for _, vol := range volumes { + if err := rules.checkString("volumes.name", &vol.Name); err != nil { + return err + } + volType := volumeSourceType(&vol) + if volType != "" { + if err := rules.checkField("volumes." + volType); err != nil { + return err + } + } + } + + return nil +} + +func checkPodSecurityContext(sc *corev1.PodSecurityContext, rules *deniedFieldRules) error { + if sc == nil { + return nil + } + + if err := rules.checkField("securityContext"); err != nil { + return err + } + if sc.SELinuxOptions != nil { + if err := rules.checkField("securityContext.seLinuxOptions"); err != nil { + return err + } + } + if err := rules.checkInt64("securityContext.runAsUser", sc.RunAsUser); err != nil { + return err + } + if err := rules.checkInt64("securityContext.runAsGroup", sc.RunAsGroup); err != nil { + return err + } + if err := rules.checkBool("securityContext.runAsNonRoot", sc.RunAsNonRoot); err != nil { + return err + } + if len(sc.SupplementalGroups) > 0 { + if err := rules.checkField("securityContext.supplementalGroups"); err != nil { + return err + } + for _, gid := range sc.SupplementalGroups { + if err := rules.checkInt64("securityContext.supplementalGroups", &gid); err != nil { + return err + } + } + } + if err := rules.checkString("securityContext.supplementalGroupsPolicy", (*string)(sc.SupplementalGroupsPolicy)); err != nil { + return err + } + if err := rules.checkInt64("securityContext.fsGroup", sc.FSGroup); err != nil { + return err + } + if len(sc.Sysctls) > 0 { + if err := rules.checkField("securityContext.sysctls"); err != nil { + return err + } + for _, sysctl := range sc.Sysctls { + if err := rules.checkString("securityContext.sysctls.name", &sysctl.Name); err != nil { + return err + } + } + } + if err := rules.checkString("securityContext.fsGroupChangePolicy", (*string)(sc.FSGroupChangePolicy)); err != nil { + return err + } + if sc.SeccompProfile != nil { + if err := rules.checkField("securityContext.seccompProfile"); err != nil { + return err + } + } + if sc.AppArmorProfile != nil { + if err := rules.checkField("securityContext.appArmorProfile"); err != nil { + return err + } + } + if err := rules.checkString("securityContext.seLinuxChangePolicy", (*string)(sc.SELinuxChangePolicy)); err != nil { + return err + } + + return nil +} + +func checkResourceClaims(claims []corev1.PodResourceClaim, rules *deniedFieldRules) error { + if len(claims) == 0 { + return nil + } + + if err := rules.checkField("resourceClaims"); err != nil { + return err + } + for _, claim := range claims { + if err := rules.checkString("resourceClaims.name", &claim.Name); err != nil { + return err + } + if err := rules.checkString("resourceClaims.resourceClaimName", claim.ResourceClaimName); err != nil { + return err + } + if err := rules.checkString("resourceClaims.resourceClaimTemplateName", claim.ResourceClaimTemplateName); err != nil { + return err + } + } + + return nil +} + +func checkOverhead(overhead corev1.ResourceList, rules *deniedFieldRules) error { + if len(overhead) == 0 { + return nil + } + + if err := rules.checkField("overhead"); err != nil { + return err + } + for resourceName := range overhead { + if err := rules.checkField("overhead." + string(resourceName)); err != nil { + return err + } + } + + return nil +} + +func checkImagePullSecrets(secrets []corev1.LocalObjectReference, rules *deniedFieldRules) error { + if len(secrets) == 0 { + return nil + } + + if err := rules.checkField("imagePullSecrets"); err != nil { + return err + } + for _, secret := range secrets { + if err := rules.checkString("imagePullSecrets.name", &secret.Name); err != nil { + return err + } + } + + return nil +} + +func volumeSourceType(vol *corev1.Volume) string { + src := vol.VolumeSource + switch { + case src.HostPath != nil: + return "hostPath" + case src.EmptyDir != nil: + return "emptyDir" + case src.Secret != nil: + return "secret" + case src.ConfigMap != nil: + return "configMap" + case src.PersistentVolumeClaim != nil: + return "persistentVolumeClaim" + case src.Projected != nil: + return "projected" + case src.DownwardAPI != nil: + return "downwardAPI" + case src.CSI != nil: + return "csi" + case src.Ephemeral != nil: + return "ephemeral" + case src.NFS != nil: + return "nfs" + case src.FC != nil: + return "fc" + case src.Image != nil: + return "image" + case src.ISCSI != nil: + return "iscsi" + case src.GCEPersistentDisk != nil: + return "gcePersistentDisk" + case src.AWSElasticBlockStore != nil: + return "awsElasticBlockStore" + case src.GitRepo != nil: + return "gitRepo" + case src.Glusterfs != nil: + return "glusterfs" + case src.RBD != nil: + return "rbd" + case src.FlexVolume != nil: + return "flexVolume" + case src.Cinder != nil: + return "cinder" + case src.CephFS != nil: + return "cephfs" + case src.Flocker != nil: + return "flocker" + case src.AzureFile != nil: + return "azureFile" + case src.VsphereVolume != nil: + return "vsphereVolume" + case src.Quobyte != nil: + return "quobyte" + case src.AzureDisk != nil: + return "azureDisk" + case src.PhotonPersistentDisk != nil: + return "photonPersistentDisk" + case src.PortworxVolume != nil: + return "portworxVolume" + case src.ScaleIO != nil: + return "scaleIO" + case src.StorageOS != nil: + return "storageos" + default: + return "" + } +} diff --git a/pkg/library/overrides/pod_restrictions_test.go b/pkg/library/overrides/pod_restrictions_test.go new file mode 100644 index 000000000..2cb6c3225 --- /dev/null +++ b/pkg/library/overrides/pod_restrictions_test.go @@ -0,0 +1,223 @@ +// Copyright (c) 2019-2026 Red Hat, Inc. +// 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 overrides + +import ( + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/api/v2/pkg/attributes" + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/json" + + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" +) + +func TestRestrictPodOverride(t *testing.T) { + boolPtr := func(b bool) *bool { return &b } + int64Ptr := func(i int64) *int64 { return &i } + + tests := []struct { + Name string + DeniedFields []string + Override corev1.PodSpec + ExpectErr bool + ErrContains string + }{ + { + Name: "no denied fields allows everything", + Override: corev1.PodSpec{HostNetwork: true}, + }, + { + Name: "containers always denied", + Override: corev1.PodSpec{Containers: []corev1.Container{{Name: "test"}}}, + ExpectErr: true, + ErrContains: "containers", + }, + { + Name: "initContainers always denied", + Override: corev1.PodSpec{InitContainers: []corev1.Container{{Name: "test"}}}, + ExpectErr: true, + ErrContains: "initContainers", + }, + { + Name: "deny field entirely blocks any value", + DeniedFields: []string{"nodeName"}, + Override: corev1.PodSpec{NodeName: "node1"}, + ExpectErr: true, + ErrContains: "nodeName", + }, + { + Name: "deny specific value blocks matching value", + DeniedFields: []string{"hostNetwork=true"}, + Override: corev1.PodSpec{HostNetwork: true}, + ExpectErr: true, + ErrContains: "hostNetwork=true", + }, + { + Name: "deny specific value allows non-matching", + DeniedFields: []string{"hostNetwork=true"}, + Override: corev1.PodSpec{}, + }, + { + Name: "plain bool deny-all rejects zero value (known limitation)", + DeniedFields: []string{"hostNetwork"}, + Override: corev1.PodSpec{}, + ExpectErr: true, + ErrContains: "hostNetwork", + }, + { + Name: "pointer bool deny-all skips nil", + DeniedFields: []string{"automountServiceAccountToken"}, + Override: corev1.PodSpec{}, + }, + { + Name: "pointer bool deny-all blocks set value", + DeniedFields: []string{"automountServiceAccountToken"}, + Override: corev1.PodSpec{AutomountServiceAccountToken: boolPtr(true)}, + ExpectErr: true, + ErrContains: "automountServiceAccountToken", + }, + { + Name: "nested field deny specific value blocks", + DeniedFields: []string{"securityContext.runAsUser=0"}, + Override: corev1.PodSpec{SecurityContext: &corev1.PodSecurityContext{RunAsUser: int64Ptr(0)}}, + ExpectErr: true, + ErrContains: "securityContext.runAsUser=0", + }, + { + Name: "nested field deny specific value allows other", + DeniedFields: []string{"securityContext.runAsUser=0"}, + Override: corev1.PodSpec{SecurityContext: &corev1.PodSecurityContext{RunAsUser: int64Ptr(1000)}}, + }, + { + Name: "volume type restriction blocks hostPath", + DeniedFields: []string{"volumes.hostPath"}, + Override: corev1.PodSpec{Volumes: []corev1.Volume{{ + Name: "vol", + VolumeSource: corev1.VolumeSource{HostPath: &corev1.HostPathVolumeSource{Path: "/tmp"}}, + }}}, + ExpectErr: true, + ErrContains: "volumes.hostPath", + }, + { + Name: "volume type restriction allows other types", + DeniedFields: []string{"volumes.hostPath"}, + Override: corev1.PodSpec{Volumes: []corev1.Volume{{ + Name: "vol", + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }}}, + }, + { + Name: "deny affinity blocks", + DeniedFields: []string{"affinity"}, + Override: corev1.PodSpec{Affinity: &corev1.Affinity{}}, + ExpectErr: true, + ErrContains: "affinity", + }, + { + Name: "deny tolerations blocks", + DeniedFields: []string{"tolerations"}, + Override: corev1.PodSpec{Tolerations: []corev1.Toleration{{Key: "key1"}}}, + ExpectErr: true, + ErrContains: "tolerations", + }, + { + Name: "multiple denied fields catches first match", + DeniedFields: []string{"hostNetwork=true", "hostPID=true", "volumes.hostPath"}, + Override: corev1.PodSpec{HostPID: true}, + ExpectErr: true, + ErrContains: "hostPID=true", + }, + { + Name: "multiple denied fields passes when none match", + DeniedFields: []string{"hostNetwork=true", "hostPID=true"}, + Override: corev1.PodSpec{}, + }, + { + Name: "deny imagePullSecrets blocks", + DeniedFields: []string{"imagePullSecrets"}, + Override: corev1.PodSpec{ImagePullSecrets: []corev1.LocalObjectReference{{Name: "secret"}}}, + ExpectErr: true, + ErrContains: "imagePullSecrets", + }, + { + Name: "deny securityContext entirely blocks", + DeniedFields: []string{"securityContext"}, + Override: corev1.PodSpec{SecurityContext: &corev1.PodSecurityContext{RunAsUser: int64Ptr(1000)}}, + ExpectErr: true, + ErrContains: "securityContext", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + err := restrictPodOverride(&tt.Override, tt.DeniedFields) + if tt.ExpectErr { + if assert.Error(t, err, "Should return error") { + assert.Contains(t, err.Error(), tt.ErrContains, "Error message should contain expected string") + } + } else { + assert.NoError(t, err, "Should not return error") + } + }) + } +} + +func TestApplyPodOverridesStripsUnknownFields(t *testing.T) { + overrideJSON := `{"spec":{"schedulerName":"custom","futureSecurityField":"malicious-value","unknownNested":{"key":"val"}}}` + + workspace := &common.DevWorkspaceWithConfig{} + workspace.DevWorkspace = &dw.DevWorkspace{} + workspace.Spec.Template = dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Attributes: attributes.Attributes{ + constants.PodOverridesAttribute: apiext.JSON{Raw: []byte(overrideJSON)}, + }, + Components: []dw.Component{{ + Name: "test-component", + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{Image: "test-image"}, + }, + }, + }}, + }, + } + + deployment := &appsv1.Deployment{} + deployment.Spec.Template.Spec.Containers = []corev1.Container{{ + Name: "test-component", + Image: "test-image", + }} + + patched, err := ApplyPodOverrides(workspace, deployment) + if !assert.NoError(t, err, "Should not return error") { + return + } + + assert.Equal(t, "custom", patched.Spec.Template.Spec.SchedulerName, "Known field should be applied") + + patchedBytes, err := json.Marshal(patched.Spec.Template.Spec) + if !assert.NoError(t, err) { + return + } + assert.NotContains(t, string(patchedBytes), "futureSecurityField", + "Unknown fields should be stripped by Go struct round-trip") + assert.NotContains(t, string(patchedBytes), "unknownNested", + "Unknown nested fields should be stripped by Go struct round-trip") +} diff --git a/pkg/library/overrides/pods.go b/pkg/library/overrides/pods.go index c9b826a5c..5ce513dca 100644 --- a/pkg/library/overrides/pods.go +++ b/pkg/library/overrides/pods.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -28,8 +28,7 @@ import ( "github.com/devfile/devworkspace-operator/pkg/constants" ) -// NeedsPodOverrides returns whether the current DevWorkspace defines pod overrides via an attribute -// attribute. +// NeedsPodOverrides returns whether the current DevWorkspace defines pod overrides via an attribute. func NeedsPodOverrides(workspace *common.DevWorkspaceWithConfig) bool { if workspace.Spec.Template.Attributes.Exists(constants.PodOverridesAttribute) { return true @@ -43,7 +42,7 @@ func NeedsPodOverrides(workspace *common.DevWorkspaceWithConfig) bool { } func ApplyPodOverrides(workspace *common.DevWorkspaceWithConfig, deployment *appsv1.Deployment) (*appsv1.Deployment, error) { - overrides, err := getPodOverrides(&workspace.Spec.Template) + overrides, err := getPodOverrides(&workspace.Spec.Template, GetDeniedPodOverrideFields(workspace)) if err != nil { return nil, err } @@ -76,10 +75,10 @@ func ApplyPodOverrides(workspace *common.DevWorkspaceWithConfig, deployment *app return patched, nil } -func GetVolumesFromOverrides(workspace *dw.DevWorkspaceTemplateSpec) (map[string]bool, error) { +func GetVolumesFromOverrides(workspace *dw.DevWorkspaceTemplateSpec, deniedFields []string) (map[string]bool, error) { overrideVolumes := map[string]bool{} - overrides, err := getPodOverrides(workspace) + overrides, err := getPodOverrides(workspace, deniedFields) if err != nil { return nil, err } @@ -103,7 +102,7 @@ func GetVolumesFromOverrides(workspace *dw.DevWorkspaceTemplateSpec) (map[string // present in the DevWorkspace. The order of elements is // 1. Pod overrides defined on Container components, in the order they appear in the DevWorkspace // 2. Pod overrides defined in the global attributes field (.spec.template.attributes) -func getPodOverrides(workspace *dw.DevWorkspaceTemplateSpec) ([]apiext.JSON, error) { +func getPodOverrides(workspace *dw.DevWorkspaceTemplateSpec, deniedFields []string) ([]apiext.JSON, error) { var allOverrides []apiext.JSON for _, component := range workspace.Components { @@ -113,12 +112,8 @@ func getPodOverrides(workspace *dw.DevWorkspaceTemplateSpec) ([]apiext.JSON, err if err := component.Attributes.GetInto(constants.PodOverridesAttribute, &override); err != nil { return nil, fmt.Errorf("failed to parse %s attribute on component %s: %w", constants.PodOverridesAttribute, component.Name, err) } - // Do not allow overriding containers - if override.Spec.Containers != nil { - return nil, fmt.Errorf("cannot use pod-overrides to override pod containers (component %s)", component.Name) - } - if override.Spec.InitContainers != nil { - return nil, fmt.Errorf("cannot use pod-overrides to override pod initContainers (component %s)", component.Name) + if err := restrictPodOverride(&override.Spec, deniedFields); err != nil { + return nil, fmt.Errorf("invalid %s attribute on component %s: %w", constants.PodOverridesAttribute, component.Name, err) } patchData := component.Attributes[constants.PodOverridesAttribute] allOverrides = append(allOverrides, patchData) @@ -130,12 +125,8 @@ func getPodOverrides(workspace *dw.DevWorkspaceTemplateSpec) ([]apiext.JSON, err if err != nil { return nil, fmt.Errorf("failed to parse %s attribute for workspace: %w", constants.PodOverridesAttribute, err) } - // Do not allow overriding containers - if override.Spec.Containers != nil { - return nil, fmt.Errorf("cannot use pod-overrides to override pod containers") - } - if override.Spec.InitContainers != nil { - return nil, fmt.Errorf("cannot use pod-overrides to override pod initContainers") + if err := restrictPodOverride(&override.Spec, deniedFields); err != nil { + return nil, fmt.Errorf("invalid %s attribute for workspace: %w", constants.PodOverridesAttribute, err) } patchData := workspace.Attributes[constants.PodOverridesAttribute] allOverrides = append(allOverrides, patchData) diff --git a/pkg/library/overrides/pods_test.go b/pkg/library/overrides/pods_test.go index 6352fbc2e..35b58babd 100644 --- a/pkg/library/overrides/pods_test.go +++ b/pkg/library/overrides/pods_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 diff --git a/pkg/library/overrides/restrictions.go b/pkg/library/overrides/restrictions.go new file mode 100644 index 000000000..276900b1b --- /dev/null +++ b/pkg/library/overrides/restrictions.go @@ -0,0 +1,222 @@ +// Copyright (c) 2019-2026 Red Hat, Inc. +// 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 overrides + +import ( + "fmt" + "strconv" + "strings" + + "github.com/devfile/devworkspace-operator/pkg/common" + corev1 "k8s.io/api/core/v1" +) + +type denyRule struct { + // Field is completely denied regardless of value. + anyValue bool + // Only values present in the map are denied + values map[string]bool +} + +type deniedFieldRules struct { + rules map[string]*denyRule + getDeniedErr func(msg string) error +} + +// parseDeniedFieldRules parses a list of denied field entries into rules. +// Multiple entries for the same field are cumulative. +// Supported formats: +// - "fieldName" — deny any value +// - "fieldName=value" — deny only this specific value +func parseDeniedFieldRules(fields []string, getDeniedErr func(msg string) error) *deniedFieldRules { + rules := map[string]*denyRule{} + + for _, f := range fields { + name, value, hasValue := strings.Cut(f, "=") + + if name == "" { + continue + } + + rule, exists := rules[name] + if !exists { + rule = &denyRule{ + values: make(map[string]bool), + } + rules[name] = rule + } + + if hasValue { + rule.values[value] = true + } else { + rule.anyValue = true + } + } + + return &deniedFieldRules{ + rules: rules, + getDeniedErr: getDeniedErr, + } +} + +func (r deniedFieldRules) checkField(fieldName string) error { + rule, exists := r.rules[fieldName] + if !exists { + return nil + } + + if rule.anyValue { + return r.getDeniedErr(fieldName) + } + + return nil +} + +func (r deniedFieldRules) checkString(fieldName string, fieldValue *string) error { + if fieldValue == nil || *fieldValue == "" { + return nil + } + + rule, exists := r.rules[fieldName] + if !exists { + return nil + } + + if rule.anyValue { + return r.getDeniedErr(fieldName) + } + + return r.checkStringUnsafe(fieldName, *fieldValue) +} + +func (r deniedFieldRules) checkBool(fieldName string, fieldValue *bool) error { + if fieldValue == nil { + return nil + } + + rule, exists := r.rules[fieldName] + if !exists { + return nil + } + + if rule.anyValue { + return r.getDeniedErr(fieldName) + } + + return r.checkStringUnsafe(fieldName, strconv.FormatBool(*fieldValue)) +} + +func (r deniedFieldRules) checkInt32(fieldName string, fieldValue *int32) error { + if fieldValue == nil { + return nil + } + + rule, exists := r.rules[fieldName] + if !exists { + return nil + } + + if rule.anyValue { + return r.getDeniedErr(fieldName) + } + + return r.checkStringUnsafe(fieldName, strconv.FormatInt(int64(*fieldValue), 10)) +} + +func (r deniedFieldRules) checkInt64(fieldName string, fieldValue *int64) error { + if fieldValue == nil { + return nil + } + + rule, exists := r.rules[fieldName] + if !exists { + return nil + } + + if rule.anyValue { + return r.getDeniedErr(fieldName) + } + + return r.checkStringUnsafe(fieldName, strconv.FormatInt(*fieldValue, 10)) +} + +func (r deniedFieldRules) checkStringUnsafe(fieldName string, fieldValue string) error { + if r.rules[fieldName].values[fieldValue] { + return r.getDeniedErr(fmt.Sprintf("%s=%s", fieldName, fieldValue)) + } + + return nil +} + +func checkResources(resources *corev1.ResourceRequirements, rules *deniedFieldRules) error { + if resources == nil { + return nil + } + + if resources.Limits != nil { + if err := rules.checkField("resources"); err != nil { + return err + } + if err := rules.checkField("resources.limits"); err != nil { + return err + } + for resourceName := range resources.Limits { + if err := rules.checkField("resources.limits." + string(resourceName)); err != nil { + return err + } + } + } + if resources.Requests != nil { + if err := rules.checkField("resources"); err != nil { + return err + } + if err := rules.checkField("resources.requests"); err != nil { + return err + } + for resourceName := range resources.Requests { + if err := rules.checkField("resources.requests." + string(resourceName)); err != nil { + return err + } + } + } + if len(resources.Claims) > 0 { + if err := rules.checkField("resources"); err != nil { + return err + } + if err := rules.checkField("resources.claims"); err != nil { + return err + } + for _, claim := range resources.Claims { + if err := rules.checkString("resources.claims.request", &claim.Request); err != nil { + return err + } + } + } + + return nil +} + +func GetDeniedContainerOverrideFields(workspace *common.DevWorkspaceWithConfig) []string { + if workspace.Config != nil && workspace.Config.Workspace != nil && workspace.Config.Workspace.Overrides != nil { + return workspace.Config.Workspace.Overrides.DeniedContainerOverrideFields + } + return nil +} + +func GetDeniedPodOverrideFields(workspace *common.DevWorkspaceWithConfig) []string { + if workspace.Config != nil && workspace.Config.Workspace != nil && workspace.Config.Workspace.Overrides != nil { + return workspace.Config.Workspace.Overrides.DeniedPodOverrideFields + } + return nil +} diff --git a/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml b/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml index a95725786..e310350d0 100644 --- a/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml +++ b/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml @@ -36,4 +36,4 @@ input: output: - errRegexp: "cannot use container-overrides to override container env" + errRegexp: "cannot use container-overrides to override container image" diff --git a/pkg/provision/storage/commonStorage.go b/pkg/provision/storage/commonStorage.go index 0c1a89f4d..345e521cc 100644 --- a/pkg/provision/storage/commonStorage.go +++ b/pkg/provision/storage/commonStorage.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -81,7 +81,13 @@ func (p *CommonStorageProvisioner) ProvisionStorage(podAdditions *v1alpha1.PodAd pvcName = commonPVC.Name } - if err := p.rewriteContainerVolumeMounts(workspace.Status.DevWorkspaceId, pvcName, podAdditions, &workspace.Spec.Template); err != nil { + if err := p.rewriteContainerVolumeMounts( + workspace.Status.DevWorkspaceId, + pvcName, + podAdditions, + &workspace.Spec.Template, + overrides.GetDeniedPodOverrideFields(workspace), + ); err != nil { return &dwerrors.FailError{ Err: err, Message: "Could not rewrite container volume mounts", @@ -126,7 +132,12 @@ func (p *CommonStorageProvisioner) CleanupWorkspaceStorage(workspace *common.Dev // (i.e. all volume mounts are subpaths into a common PVC used by all workspaces in the namespace). // // Also adds appropriate k8s Volumes to PodAdditions to accomodate the rewritten VolumeMounts. -func (p *CommonStorageProvisioner) rewriteContainerVolumeMounts(workspaceId, pvcName string, podAdditions *v1alpha1.PodAdditions, workspace *dw.DevWorkspaceTemplateSpec) error { +func (p *CommonStorageProvisioner) rewriteContainerVolumeMounts( + workspaceId, pvcName string, + podAdditions *v1alpha1.PodAdditions, + workspace *dw.DevWorkspaceTemplateSpec, + deniedFields []string, +) error { devfileVolumes := map[string]dw.VolumeComponent{} // Construct map of volume name -> volume Component @@ -146,7 +157,7 @@ func (p *CommonStorageProvisioner) rewriteContainerVolumeMounts(workspaceId, pvc } // Containers in podAdditions may reference volumes defined in pod overrides, and this is not an error - overridesVolumes, err := overrides.GetVolumesFromOverrides(workspace) + overridesVolumes, err := overrides.GetVolumesFromOverrides(workspace, deniedFields) if err != nil { return err } diff --git a/pkg/provision/storage/perWorkspaceStorage.go b/pkg/provision/storage/perWorkspaceStorage.go index c1a6ca523..17232761a 100644 --- a/pkg/provision/storage/perWorkspaceStorage.go +++ b/pkg/provision/storage/perWorkspaceStorage.go @@ -1,5 +1,5 @@ // -// Copyright (c) 2019-2025 Red Hat, Inc. +// Copyright (c) 2019-2026 Red Hat, Inc. // 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 @@ -72,7 +72,13 @@ func (p *PerWorkspaceStorageProvisioner) ProvisionStorage(podAdditions *v1alpha1 } // Rewrite container volume mounts - if err := p.rewriteContainerVolumeMounts(workspace.Status.DevWorkspaceId, pvcName, podAdditions, &workspace.Spec.Template); err != nil { + if err := p.rewriteContainerVolumeMounts( + workspace.Status.DevWorkspaceId, + pvcName, + podAdditions, + &workspace.Spec.Template, + overrides.GetDeniedPodOverrideFields(workspace), + ); err != nil { return &dwerrors.FailError{ Err: err, Message: "Could not rewrite container volume mounts", @@ -91,7 +97,12 @@ func (*PerWorkspaceStorageProvisioner) CleanupWorkspaceStorage(workspace *common // (i.e. all volume mounts are subpaths into a PVC used by a single workspace in the namespace). // // Also adds appropriate k8s Volumes to PodAdditions to accomodate the rewritten VolumeMounts. -func (p *PerWorkspaceStorageProvisioner) rewriteContainerVolumeMounts(workspaceId, pvcName string, podAdditions *v1alpha1.PodAdditions, workspace *dw.DevWorkspaceTemplateSpec) error { +func (p *PerWorkspaceStorageProvisioner) rewriteContainerVolumeMounts( + workspaceId, pvcName string, + podAdditions *v1alpha1.PodAdditions, + workspace *dw.DevWorkspaceTemplateSpec, + deniedFields []string, +) error { devfileVolumes := map[string]dw.VolumeComponent{} // Construct map of volume name -> volume Component @@ -111,7 +122,7 @@ func (p *PerWorkspaceStorageProvisioner) rewriteContainerVolumeMounts(workspaceI } // Containers in podAdditions may reference volumes defined in pod overrides, and this is not an error - overridesVolumes, err := overrides.GetVolumesFromOverrides(workspace) + overridesVolumes, err := overrides.GetVolumesFromOverrides(workspace, deniedFields) if err != nil { return err }