diff --git a/pkg/constants/attributes.go b/pkg/constants/attributes.go index d3378fd68..72879c982 100644 --- a/pkg/constants/attributes.go +++ b/pkg/constants/attributes.go @@ -103,4 +103,42 @@ const ( // MergedContributionsAttribute is applied as an attribute onto a component to list the components from the unflattened // DevWorkspace that have been merged into the current component. The contributions are listed in a comma-separated list. MergedContributionsAttribute = "controller.devfile.io/merged-contributions" + + // PodOverridesAttribute is an attribute applied to a container component or in global attributes to specify overrides + // for the pod spec used in the main workspace deployment. The format of the field is the same as the Kubernetes + // PodSpec API. Overrides are applied over the default pod template spec used via strategic merge patch. + // + // If this attribute is used multiple times, all overrides are applied in the order they are defined in the DevWorkspace, + // with later values overriding previous ones. Overrides defined in the top-level attributes field are applied last and + // override any overrides from container components. + // + // Example: + // kind: DevWorkspace + // apiVersion: workspace.devfile.io/v1alpha2 + // spec: + // template: + // attributes: + // pod-overrides: + // metadata: + // annotations: + // io.openshift.userns: "true" + // io.kubernetes.cri-o.userns-mode: "auto:size=65536;map-to-root=true" # <-- user namespace + // openshift.io/scc: container-build + // spec: + // runtimeClassName: kata + // schedulerName: stork + PodOverridesAttribute = "pod-overrides" + + // ContainerOverridesAttribute is an attribute applied to a container component to specify arbitrary fields in that + // container. This attribute should only be used to set fields that are not configurable in the container component + // itself. Any values specified in the overrides attribute overwrite fields on the container. + // + // Example: + // components: + // - name: go + // attributes: + // container-overrides: {"resources":{"limits":{"nvidia.com/gpu": "1"}}} + // container: + // image: ... + ContainerOverridesAttribute = "container-overrides" ) diff --git a/pkg/library/container/container.go b/pkg/library/container/container.go index 34440783e..68127e645 100644 --- a/pkg/library/container/container.go +++ b/pkg/library/container/container.go @@ -17,16 +17,18 @@ // components // // TODO: -// - Devfile API spec is unclear on how mountSources should be handled -- mountPath is assumed to be /projects -// and volume name is assumed to be "projects" -// see issues: -// - https://github.com/devfile/api/issues/290 -// - https://github.com/devfile/api/issues/291 +// - Devfile API spec is unclear on how mountSources should be handled -- mountPath is assumed to be /projects +// and volume name is assumed to be "projects" +// see issues: +// - https://github.com/devfile/api/issues/290 +// - https://github.com/devfile/api/issues/291 package container import ( "fmt" + "github.com/devfile/devworkspace-operator/pkg/library/overrides" + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1" "github.com/devfile/devworkspace-operator/pkg/library/flatten" @@ -49,7 +51,7 @@ func GetKubeContainersFromDevfile(workspace *dw.DevWorkspaceTemplateSpec, pullPo } podAdditions := &v1alpha1.PodAdditions{} - initContainers, mainComponents, err := lifecycle.GetInitContainers(workspace.DevWorkspaceTemplateSpecContent) + initComponents, mainComponents, err := lifecycle.GetInitContainers(workspace.DevWorkspaceTemplateSpecContent) if err != nil { return nil, err } @@ -63,6 +65,13 @@ func GetKubeContainersFromDevfile(workspace *dw.DevWorkspaceTemplateSpec, pullPo return nil, err } handleMountSources(k8sContainer, component.Container, workspace.Projects) + if overrides.NeedsContainerOverride(&component) { + patchedContainer, err := overrides.ApplyContainerOverrides(&component, k8sContainer) + if err != nil { + return nil, err + } + k8sContainer = patchedContainer + } podAdditions.Containers = append(podAdditions.Containers, *k8sContainer) } @@ -70,12 +79,19 @@ func GetKubeContainersFromDevfile(workspace *dw.DevWorkspaceTemplateSpec, pullPo return nil, err } - for _, container := range initContainers { - k8sContainer, err := convertContainerToK8s(container, pullPolicy) + for _, initComponent := range initComponents { + k8sContainer, err := convertContainerToK8s(initComponent, pullPolicy) if err != nil { return nil, err } - handleMountSources(k8sContainer, container.Container, workspace.Projects) + handleMountSources(k8sContainer, initComponent.Container, workspace.Projects) + if overrides.NeedsContainerOverride(&initComponent) { + patchedContainer, err := overrides.ApplyContainerOverrides(&initComponent, k8sContainer) + if err != nil { + return nil, err + } + k8sContainer = patchedContainer + } podAdditions.InitContainers = append(podAdditions.InitContainers, *k8sContainer) } diff --git a/pkg/library/overrides/containers.go b/pkg/library/overrides/containers.go new file mode 100644 index 000000000..40ad0d3ca --- /dev/null +++ b/pkg/library/overrides/containers.go @@ -0,0 +1,77 @@ +// Copyright (c) 2019-2022 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" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/strategicpatch" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/devworkspace-operator/pkg/constants" +) + +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) { + 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) + } + override = restrictContainerOverride(override) + + overrideBytes, err := json.Marshal(override) + if err != nil { + return nil, fmt.Errorf("error applying container overrides: %w", err) + } + + originalBytes, err := json.Marshal(container) + if err != nil { + return nil, fmt.Errorf("failed to marshal container to yaml: %w", err) + } + + patchedBytes, err := strategicpatch.StrategicMergePatch(originalBytes, overrideBytes, &corev1.Container{}) + if err != nil { + return nil, fmt.Errorf("failed to apply container overrides: %w", err) + } + + patched := &corev1.Container{} + if err := json.Unmarshal(patchedBytes, patched); err != nil { + return nil, fmt.Errorf("error applying container overrides: %w", err) + } + // Applying the patch will overwrite the container's name as corev1.Container.Name + // does not have the omitempty json tag. + patched.Name = container.Name + 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) *corev1.Container { + result := override.DeepCopy() + result.Name = "" + result.Image = "" + result.Command = nil + result.Args = nil + result.Ports = nil + result.VolumeMounts = nil + result.Env = nil + + return result +} diff --git a/pkg/library/overrides/containers_test.go b/pkg/library/overrides/containers_test.go new file mode 100644 index 000000000..6e6957f58 --- /dev/null +++ b/pkg/library/overrides/containers_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2019-2022 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" + "os" + "path/filepath" + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/yaml" +) + +func TestApplyContainerOverrides(t *testing.T) { + 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) + if tt.Output.ErrRegexp != nil && assert.Error(t, err) { + assert.Regexp(t, *tt.Output.ErrRegexp, err.Error(), "Error message should match") + } else { + if !assert.NoError(t, err, "Should not return error") { + return + } + assert.Truef(t, cmp.Equal(tt.Output.Container, outContainer), + "Container should match expected output:\n%s", + cmp.Diff(tt.Output.Container, outContainer)) + } + }) + } +} + +type containerTestCase struct { + Name string `json:"name,omitempty"` + Input *containerTestInput `json:"input,omitempty"` + Output *containerTestOutput `json:"output,omitempty"` + originalFilename string +} + +type containerTestInput struct { + Component *dw.Component `json:"component,omitempty"` + Container *corev1.Container `json:"container,omitempty"` +} + +type containerTestOutput struct { + Container *corev1.Container `json:"container,omitempty"` + ErrRegexp *string `json:"errRegexp,omitempty"` +} + +func loadAllContainerTestCasesOrPanic(t *testing.T, fromDir string) []containerTestCase { + files, err := os.ReadDir(fromDir) + if err != nil { + t.Fatal(err) + } + var tests []containerTestCase + for _, file := range files { + if file.IsDir() { + tests = append(tests, loadAllContainerTestCasesOrPanic(t, filepath.Join(fromDir, file.Name()))...) + } else { + tests = append(tests, loadContainerTestCaseOrPanic(t, filepath.Join(fromDir, file.Name()))) + } + } + return tests +} + +func loadContainerTestCaseOrPanic(t *testing.T, testPath string) containerTestCase { + bytes, err := os.ReadFile(testPath) + if err != nil { + t.Fatal(err) + } + var test containerTestCase + if err := yaml.Unmarshal(bytes, &test); err != nil { + t.Fatal(err) + } + test.originalFilename = testPath + return test +} diff --git a/pkg/library/overrides/pods.go b/pkg/library/overrides/pods.go new file mode 100644 index 000000000..09701435f --- /dev/null +++ b/pkg/library/overrides/pods.go @@ -0,0 +1,112 @@ +// Copyright (c) 2019-2022 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" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/strategicpatch" + + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" +) + +// NeedsPodOverrides returns whether the current DevWorkspace defines pod overrides via an attribute +// attribute. +func NeedsPodOverrides(workspace *common.DevWorkspaceWithConfig) bool { + if workspace.Spec.Template.Attributes.Exists(constants.PodOverridesAttribute) { + return true + } + for _, component := range workspace.Spec.Template.Components { + if component.Attributes.Exists(constants.PodOverridesAttribute) { + return true + } + } + return false +} + +func ApplyPodOverrides(workspace *common.DevWorkspaceWithConfig, deployment *appsv1.Deployment) (*appsv1.Deployment, error) { + overrides, err := getPodOverrides(workspace) + if err != nil { + return nil, err + } + + patched := deployment.DeepCopy() + // Workaround: the definition for corev1.PodSpec does not make containers optional, so even a nil list + // will be interpreted as "delete all containers" as the serialized patch will include "containers": null. + // To avoid this, save the original containers and reset them at the end. + originalContainers := patched.Spec.Template.Spec.Containers + patchedTemplateBytes, err := json.Marshal(patched.Spec.Template) + if err != nil { + return nil, fmt.Errorf("failed to marshal deployment to yaml: %w", err) + } + for _, override := range overrides { + patchBytes, err := json.Marshal(override) + if err != nil { + return nil, fmt.Errorf("error applying pod overrides: %w", err) + } + + patchedTemplateBytes, err = strategicpatch.StrategicMergePatch(patchedTemplateBytes, patchBytes, &corev1.PodTemplateSpec{}) + if err != nil { + return nil, fmt.Errorf("error applying pod overrides: %w", err) + } + } + + patchedPodSpecTemplate := corev1.PodTemplateSpec{} + if err := json.Unmarshal(patchedTemplateBytes, &patchedPodSpecTemplate); err != nil { + return nil, fmt.Errorf("error applying pod overrides: %w", err) + } + patched.Spec.Template = patchedPodSpecTemplate + patched.Spec.Template.Spec.Containers = originalContainers + return patched, nil +} + +// getPodOverrides returns PodTemplateSpecOverrides for every instance of the pod overrides attribute +// 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 *common.DevWorkspaceWithConfig) ([]corev1.PodTemplateSpec, error) { + var allOverrides []corev1.PodTemplateSpec + + for _, component := range workspace.Spec.Template.Components { + if component.Attributes.Exists(constants.PodOverridesAttribute) { + override := corev1.PodTemplateSpec{} + err := component.Attributes.GetInto(constants.PodOverridesAttribute, &override) + if 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 + override.Spec.Containers = nil + override.Spec.InitContainers = nil + override.Spec.Volumes = nil + allOverrides = append(allOverrides, override) + } + } + if workspace.Spec.Template.Attributes.Exists(constants.PodOverridesAttribute) { + override := corev1.PodTemplateSpec{} + err := workspace.Spec.Template.Attributes.GetInto(constants.PodOverridesAttribute, &override) + if err != nil { + return nil, fmt.Errorf("failed to parse %s attribute for workspace: %w", constants.PodOverridesAttribute, err) + } + // Do not allow overriding containers or volumes + override.Spec.Containers = nil + override.Spec.InitContainers = nil + override.Spec.Volumes = nil + allOverrides = append(allOverrides, override) + } + return allOverrides, nil +} diff --git a/pkg/library/overrides/pods_test.go b/pkg/library/overrides/pods_test.go new file mode 100644 index 000000000..d43250115 --- /dev/null +++ b/pkg/library/overrides/pods_test.go @@ -0,0 +1,220 @@ +// Copyright (c) 2019-2022 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" + "os" + "path/filepath" + "testing" + + dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2" + "github.com/devfile/api/v2/pkg/attributes" + "github.com/devfile/devworkspace-operator/pkg/common" + "github.com/devfile/devworkspace-operator/pkg/constants" + "github.com/google/go-cmp/cmp" + "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" + "sigs.k8s.io/yaml" +) + +func TestApplyPodOverrides(t *testing.T) { + tests := loadAllPodTestCasesOrPanic(t, "testdata/pod-overrides") + for _, tt := range tests { + t.Run(fmt.Sprintf("%s (%s)", tt.Name, tt.originalFilename), func(t *testing.T) { + workspace := &common.DevWorkspaceWithConfig{} + workspace.DevWorkspace = &dw.DevWorkspace{} + workspace.Spec.Template = *tt.Input.Workspace + deploy := &appsv1.Deployment{} + deploy.Spec.Template = *tt.Input.PodTemplateSpec + actualDeploy, err := ApplyPodOverrides(workspace, deploy) + if tt.Output.ErrRegexp != nil && assert.Error(t, err) { + assert.Regexp(t, *tt.Output.ErrRegexp, err.Error(), "Error message should match") + } else { + if !assert.NoError(t, err, "Should not return error") { + return + } + assert.Truef(t, cmp.Equal(tt.Output.PodTemplateSpec, &actualDeploy.Spec.Template), + "Deployment should match expected output:\n%s", + cmp.Diff(tt.Output.PodTemplateSpec, &actualDeploy.Spec.Template)) + } + }) + } +} + +func TestNeedsPodOverride(t *testing.T) { + jsonPodOverrides := apiext.JSON{ + Raw: []byte(`{"spec":{"runtimeClassName":"kata"}}`), + } + tests := []struct { + Name string + Input dw.DevWorkspaceTemplateSpec + Expected bool + }{ + { + Name: "Empty workspace does not need override", + Input: dw.DevWorkspaceTemplateSpec{}, + Expected: false, + }, + { + Name: "Workspace with no overrides", + Input: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Components: []dw.Component{ + { + Name: "test-component", + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{ + Image: "test-image", + }, + }, + }, + }, + }, + }, + }, + Expected: false, + }, + { + Name: "Workspace with overrides in container", + Input: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Components: []dw.Component{ + { + Name: "test-component", + Attributes: attributes.Attributes{ + constants.PodOverridesAttribute: jsonPodOverrides, + }, + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{ + Image: "test-image", + }, + }, + }, + }, + }, + }, + }, + Expected: true, + }, + { + Name: "Workspace with overrides in template", + Input: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Attributes: attributes.Attributes{ + constants.PodOverridesAttribute: jsonPodOverrides, + }, + Components: []dw.Component{ + { + Name: "test-component", + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{ + Image: "test-image", + }, + }, + }, + }, + }, + }, + }, + Expected: true, + }, + { + Name: "Workspace with overrides in template and container component", + Input: dw.DevWorkspaceTemplateSpec{ + DevWorkspaceTemplateSpecContent: dw.DevWorkspaceTemplateSpecContent{ + Attributes: attributes.Attributes{ + constants.PodOverridesAttribute: jsonPodOverrides, + }, + Components: []dw.Component{ + { + Name: "test-component", + Attributes: attributes.Attributes{ + constants.PodOverridesAttribute: jsonPodOverrides, + }, + ComponentUnion: dw.ComponentUnion{ + Container: &dw.ContainerComponent{ + Container: dw.Container{ + Image: "test-image", + }, + }, + }, + }, + }, + }, + }, + Expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + workspace := &common.DevWorkspaceWithConfig{} + workspace.DevWorkspace = &dw.DevWorkspace{} + workspace.Spec.Template = tt.Input + actual := NeedsPodOverrides(workspace) + assert.Equal(t, tt.Expected, actual) + }) + } +} + +type podTestCase struct { + Name string `json:"name,omitempty"` + Input *podTestInput `json:"input,omitempty"` + Output *podTestOutput `json:"output,omitempty"` + originalFilename string +} + +type podTestInput struct { + Workspace *dw.DevWorkspaceTemplateSpec `json:"workspace,omitempty"` + PodTemplateSpec *corev1.PodTemplateSpec `json:"podTemplateSpec,omitempty"` +} + +type podTestOutput struct { + PodTemplateSpec *corev1.PodTemplateSpec `json:"podTemplateSpec,omitempty"` + ErrRegexp *string `json:"errRegexp,omitempty"` +} + +func loadAllPodTestCasesOrPanic(t *testing.T, fromDir string) []podTestCase { + files, err := os.ReadDir(fromDir) + if err != nil { + t.Fatal(err) + } + var tests []podTestCase + for _, file := range files { + if file.IsDir() { + tests = append(tests, loadAllPodTestCasesOrPanic(t, filepath.Join(fromDir, file.Name()))...) + } else { + tests = append(tests, loadPodTestCaseOrPanic(t, filepath.Join(fromDir, file.Name()))) + } + } + return tests +} + +func loadPodTestCaseOrPanic(t *testing.T, testPath string) podTestCase { + bytes, err := os.ReadFile(testPath) + if err != nil { + t.Fatal(err) + } + var test podTestCase + if err := yaml.Unmarshal(bytes, &test); err != nil { + t.Fatal(err) + } + test.originalFilename = testPath + return test +} 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 new file mode 100644 index 000000000..ac308c2df --- /dev/null +++ b/pkg/library/overrides/testdata/container-overrides/container-cannot-set-restricted-fields.yaml @@ -0,0 +1,52 @@ +name: "Container overrides cannot override container component fields" + +input: + component: + name: test-component + attributes: + container-overrides: + image: override-image + command: ["test"] + args: ["test"] + ports: + - name: test-port + containerPort: 9999 + volumeMounts: + - name: test-volume + mountPath: test-mountPath + env: + - name: test_env + value: test_val + container: + image: test-image + container: + name: test-component + image: test-image + command: ["original"] + args: ["original"] + ports: + - name: original-port + containerPort: 8080 + volumeMounts: + - name: original-volume + mountPath: original-mountPath + env: + - name: original_env + value: original_val + + +output: + container: + name: test-component + image: test-image + command: ["original"] + args: ["original"] + ports: + - name: original-port + containerPort: 8080 + volumeMounts: + - name: original-volume + mountPath: original-mountPath + env: + - name: original_env + value: original_val diff --git a/pkg/library/overrides/testdata/container-overrides/container-defines-overrides-json.yaml b/pkg/library/overrides/testdata/container-overrides/container-defines-overrides-json.yaml new file mode 100644 index 000000000..b54ee7d98 --- /dev/null +++ b/pkg/library/overrides/testdata/container-overrides/container-defines-overrides-json.yaml @@ -0,0 +1,20 @@ +name: "Applies overrides from container-overrides attribute as json" + +input: + component: + name: test-component + attributes: + container-overrides: {"resources":{"limits":{"nvidia.com/gpu":"1"}}} + container: + image: test-image + container: + name: test-component + image: test-image + +output: + container: + name: test-component + image: test-image + resources: + limits: + nvidia.com/gpu: "1" diff --git a/pkg/library/overrides/testdata/container-overrides/container-defines-overrides.yaml b/pkg/library/overrides/testdata/container-overrides/container-defines-overrides.yaml new file mode 100644 index 000000000..3cb8f353f --- /dev/null +++ b/pkg/library/overrides/testdata/container-overrides/container-defines-overrides.yaml @@ -0,0 +1,41 @@ +name: "Applies overrides from container-overrides attribute" + +input: + component: + name: test-component + attributes: + container-overrides: + resources: + limits: + nvidia.com/gpu: "1" + requests: + nvidia.com/gpu: "1" + readinessProbe: + exec: + command: ["echo", "hello"] + securityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 2000 + container: + image: test-image + container: + name: test-component + image: test-image + +output: + container: + name: test-component + image: test-image + resources: + limits: + nvidia.com/gpu: "1" + requests: + nvidia.com/gpu: "1" + readinessProbe: + exec: + command: ["echo", "hello"] + securityContext: + runAsUser: 1000 + runAsGroup: 3000 + fsGroup: 2000 diff --git a/pkg/library/overrides/testdata/container-overrides/container-overridden-resources-merge.yaml b/pkg/library/overrides/testdata/container-overrides/container-overridden-resources-merge.yaml new file mode 100644 index 000000000..33e9b24e1 --- /dev/null +++ b/pkg/library/overrides/testdata/container-overrides/container-overridden-resources-merge.yaml @@ -0,0 +1,42 @@ +name: "Resources from overrides are merged with container-defined resources" + +input: + component: + name: test-component + attributes: + container-overrides: + resources: + limits: + nvidia.com/gpu: "1" + requests: + nvidia.com/gpu: "1" + container: + image: test-image + memoryLimit: 1Gi + memoryRequest: 256Mi + cpuLimit: 1000m + cpuRequest: 500m + container: + name: test-component + image: test-image + resources: + limits: + memory: 1Gi + cpu: 1000m + requests: + memory: 256Mi + cpu: 500m + +output: + container: + name: test-component + image: test-image + resources: + limits: + nvidia.com/gpu: "1" + memory: 1Gi + cpu: 1000m + requests: + nvidia.com/gpu: "1" + memory: 256Mi + cpu: 500m diff --git a/pkg/library/overrides/testdata/container-overrides/error_cannot-parse-override.yaml b/pkg/library/overrides/testdata/container-overrides/error_cannot-parse-override.yaml new file mode 100644 index 000000000..c26f97590 --- /dev/null +++ b/pkg/library/overrides/testdata/container-overrides/error_cannot-parse-override.yaml @@ -0,0 +1,15 @@ +name: "Returns an error when container-override attribute cannot be parsed" + +input: + component: + name: test-component + attributes: + container-overrides: 123 + container: + image: test-image + container: + name: test-component + image: test-image + +output: + errRegexp: "failed to parse .* attribute on component test-component.*" diff --git a/pkg/library/overrides/testdata/pod-overrides/error_cannot-parse-component-attribute.yaml b/pkg/library/overrides/testdata/pod-overrides/error_cannot-parse-component-attribute.yaml new file mode 100644 index 000000000..a4b36b1bf --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/error_cannot-parse-component-attribute.yaml @@ -0,0 +1,22 @@ +name: "Returns error when cannot parse component attribute" + +input: + workspace: + components: + - name: test-component + attributes: + pod-overrides: 123 + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + errRegexp: "failed to parse pod-overrides attribute on component test-component: .*" \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/error_cannot-parse-global-attribute.yaml b/pkg/library/overrides/testdata/pod-overrides/error_cannot-parse-global-attribute.yaml new file mode 100644 index 000000000..3a783538a --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/error_cannot-parse-global-attribute.yaml @@ -0,0 +1,22 @@ +name: "Returns error when cannot parse global attribute" + +input: + workspace: + attributes: + pod-overrides: 123 + components: + - name: test-component + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + errRegexp: "failed to parse pod-overrides attribute for workspace: .*" \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-component-attribute.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-component-attribute.yaml new file mode 100644 index 000000000..c0ed25c24 --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-component-attribute.yaml @@ -0,0 +1,33 @@ +name: "Workspace defines pod overrides in component attribute" + +input: + workspace: + components: + - name: test-component + attributes: + pod-overrides: + metadata: + labels: + test-label: test-value + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: test-value + spec: + containers: + - name: test-component + image: test-image \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-defines-attribute-in-non-container-component.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-defines-attribute-in-non-container-component.yaml new file mode 100644 index 000000000..34333d279 --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-defines-attribute-in-non-container-component.yaml @@ -0,0 +1,35 @@ +name: "Workspace defines attribute in non-container components" + +input: + workspace: + components: + - name: test-component + container: + image: test-image + - name: test-volume + attributes: + pod-overrides: + metadata: + labels: + test-label: test-value + volume: {} + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: test-value + spec: + containers: + - name: test-component + image: test-image \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-full-example-json.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-full-example-json.yaml new file mode 100644 index 000000000..d21ab66cb --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-full-example-json.yaml @@ -0,0 +1,41 @@ +name: "Test various overridden fields defined in json" + +input: + workspace: + attributes: + pod-overrides: {"metadata":{"annotations":{"io.openshift.userns":"true","io.kubernetes.cri-o.userns-mode":"auto:size=65536;map-to-root=true","openshift.io/scc":"container-build"}},"spec":{"runtimeClassName":"kata","schedulerName":"stork"}} + components: + - name: test-component + attributes: + pod-overrides: + metadata: + labels: + test-label: component-label + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + annotations: + io.openshift.userns: "true" + io.kubernetes.cri-o.userns-mode: "auto:size=65536;map-to-root=true" # <-- user namespace + openshift.io/scc: container-build + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: component-label + spec: + runtimeClassName: kata + schedulerName: stork + containers: + - name: test-component + image: test-image diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-full-example.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-full-example.yaml new file mode 100644 index 000000000..dd15a006d --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-full-example.yaml @@ -0,0 +1,49 @@ +name: "Test various overridden fields" + +input: + workspace: + attributes: + pod-overrides: + metadata: + annotations: + io.openshift.userns: "true" + io.kubernetes.cri-o.userns-mode: "auto:size=65536;map-to-root=true" # <-- user namespace + openshift.io/scc: container-build + spec: + runtimeClassName: kata + schedulerName: stork + components: + - name: test-component + attributes: + pod-overrides: + metadata: + labels: + test-label: component-label + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + annotations: + io.openshift.userns: "true" + io.kubernetes.cri-o.userns-mode: "auto:size=65536;map-to-root=true" # <-- user namespace + openshift.io/scc: container-build + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: component-label + spec: + runtimeClassName: kata + schedulerName: stork + containers: + - name: test-component + image: test-image diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-global-attribute-as-json.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-global-attribute-as-json.yaml new file mode 100644 index 000000000..2d6f74bd6 --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-global-attribute-as-json.yaml @@ -0,0 +1,30 @@ +name: "Workspace defines pod overrides in global attribute specified as json" + +input: + workspace: + attributes: + pod-overrides: {"metadata": {"labels": {"test-label": "test-value"}}} + components: + - name: test-component + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: test-value + spec: + containers: + - name: test-component + image: test-image \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-global-attribute.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-global-attribute.yaml new file mode 100644 index 000000000..f1401d4a2 --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-global-attribute.yaml @@ -0,0 +1,33 @@ +name: "Workspace defines pod overrides in global attribute" + +input: + workspace: + attributes: + pod-overrides: + metadata: + labels: + test-label: test-value + components: + - name: test-component + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: test-value + spec: + containers: + - name: test-component + image: test-image \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-multiple-attributes.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-multiple-attributes.yaml new file mode 100644 index 000000000..ab2502bf8 --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-multiple-attributes.yaml @@ -0,0 +1,38 @@ +name: "Workspace global attributes overrides component attributes" + +input: + workspace: + attributes: + pod-overrides: + metadata: + labels: + test-label: global-label + components: + - name: test-component + attributes: + pod-overrides: + metadata: + labels: + test-label: component-label + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + test-label: global-label + spec: + containers: + - name: test-component + image: test-image \ No newline at end of file diff --git a/pkg/library/overrides/testdata/pod-overrides/workspace-multiple-component-attributes-precedence.yaml b/pkg/library/overrides/testdata/pod-overrides/workspace-multiple-component-attributes-precedence.yaml new file mode 100644 index 000000000..6b5ddc9ef --- /dev/null +++ b/pkg/library/overrides/testdata/pod-overrides/workspace-multiple-component-attributes-precedence.yaml @@ -0,0 +1,47 @@ +name: "Overrides take precedence in order of appearance" + +input: + workspace: + attributes: + pod-overrides: + metadata: + labels: + global-label: global-label + components: + - name: test-component-1 + attributes: + pod-overrides: + metadata: + labels: + test-label: component-1-label + container: + image: test-image + - name: test-component-2 + attributes: + pod-overrides: + metadata: + labels: + test-label: component-2-label + container: + image: test-image + + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + spec: + containers: + - name: test-component + image: test-image + +output: + podTemplateSpec: + metadata: + labels: + controller.devfile.io/devworkspace-id: test-id + global-label: global-label + test-label: component-2-label + spec: + containers: + - name: test-component + image: test-image \ No newline at end of file diff --git a/pkg/provision/workspace/deployment.go b/pkg/provision/workspace/deployment.go index 96a5a065e..32c6285a5 100644 --- a/pkg/provision/workspace/deployment.go +++ b/pkg/provision/workspace/deployment.go @@ -20,6 +20,8 @@ import ( "errors" "fmt" + "github.com/devfile/devworkspace-operator/pkg/library/overrides" + "github.com/devfile/devworkspace-operator/pkg/infrastructure" "github.com/devfile/devworkspace-operator/pkg/library/status" nsconfig "github.com/devfile/devworkspace-operator/pkg/provision/config" @@ -246,6 +248,14 @@ func getSpecDeployment( }, } + if overrides.NeedsPodOverrides(workspace) { + patchedDeployment, err := overrides.ApplyPodOverrides(workspace, deployment) + if err != nil { + return nil, err + } + deployment = patchedDeployment + } + if podTolerations != nil && len(podTolerations) > 0 { deployment.Spec.Template.Spec.Tolerations = podTolerations } diff --git a/samples/container-overrides.yaml b/samples/container-overrides.yaml new file mode 100644 index 000000000..94e4d1c2d --- /dev/null +++ b/samples/container-overrides.yaml @@ -0,0 +1,29 @@ +kind: DevWorkspace +apiVersion: workspace.devfile.io/v1alpha2 +metadata: + name: theia-next +spec: + started: true + + template: + attributes: + controller.devfile.io/storage-type: ephemeral + projects: + - name: web-nodejs-sample + git: + remotes: + origin: "https://github.com/che-samples/web-nodejs-sample.git" + components: + - name: web-terminal + attributes: + container-overrides: {"resources":{"limits":{"nvidia.com/gpu":"1"}}} + container: + image: quay.io/wto/web-terminal-tooling:next + args: + - tail + - '-f' + - /dev/null + cpuLimit: 400m + cpuRequest: 100m + memoryLimit: 256Mi + memoryRequest: 128Mi diff --git a/samples/pod-overrides.yaml b/samples/pod-overrides.yaml new file mode 100644 index 000000000..5cc3f9d71 --- /dev/null +++ b/samples/pod-overrides.yaml @@ -0,0 +1,38 @@ +kind: DevWorkspace +apiVersion: workspace.devfile.io/v1alpha2 +metadata: + name: theia-next +spec: + started: true + template: + attributes: + pod-overrides: + metadata: + annotations: + io.openshift.userns: "true" + io.kubernetes.cri-o.userns-mode: "auto:size=65536;map-to-root=true" # <-- user namespace + openshift.io/scc: container-build + spec: + runtimeClassName: kata + schedulerName: stork + projects: + - name: web-nodejs-sample + git: + remotes: + origin: "https://github.com/che-samples/web-nodejs-sample.git" + components: + - name: theia + plugin: + uri: https://che-plugin-registry-main.surge.sh/v3/plugins/eclipse/che-theia/next/devfile.yaml + components: + - name: theia-ide + container: + env: + - name: THEIA_HOST + value: 0.0.0.0 + commands: + - id: say-hello + exec: + component: theia-ide + commandLine: echo "Hello from $(pwd)" + workingDir: ${PROJECTS_ROOT}/project/app \ No newline at end of file