Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/additional-configuration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ The `controller.devfile.io/devworkspace-config` attribute takes two string field

* `name`: the `metadata.name` of the alternate DevWorkspaceOperatorConfig.
* `namespace`: the `metadata.namespace` of the alternate DevWorkspaceOperatorConfig.
Must be the DevWorkspace's own namespace or the namespace where the DevWorkspace Operator is installed.

[source,yaml]
----
Expand All @@ -360,6 +361,11 @@ spec:
merged with the default DevWorkspaceOperatorConfig, overriding
fields in the default configuration. Fields unset in the overridden
configuration will use the global values.
If the referenced DevWorkspaceOperatorConfig is not in the operator
namespace, pod-level fields (`podSecurityContext`,
`containerSecurityContext`, `serviceAccount`, `runtimeClassName`,
`defaultTemplate`, `podAnnotations`, `schedulerName`, `initContainers`,
`hostUsers`, `overrides`, `projectClone`, and `restore`) are ignored.

## Configuring cross-namespace DevWorkspaceTemplate imports

Expand Down
4 changes: 3 additions & 1 deletion docs/dwo-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ spec:
attributes:
controller.devfile.io/devworkspace-config:
name: <name of DevWorkspaceOperatorConfig CR>
namespace: <namespace of DevWorkspaceOperatorConfig CR>
namespace: <DevWorkspace namespace or operator namespace>
```
Configuration specified as above will be merged into the default global configuration, overriding any values present.

The referenced `DevWorkspaceOperatorConfig` must live in the DevWorkspace's namespace or the operator namespace. If it is not in the operator namespace, pod-level fields (`podSecurityContext`, `containerSecurityContext`, `serviceAccount`, `runtimeClassName`, `defaultTemplate`, `podAnnotations`, `schedulerName`, `initContainers`, `hostUsers`, `overrides`, `projectClone`, and `restore`) are ignored.

## Configuring the Webhook deployment
The `devworkspace-webhook-server` deployment can be configured in the global `DevWorkspaceOperatorConfig`.
The configuration options include:
Expand Down
61 changes: 60 additions & 1 deletion pkg/config/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func GetGlobalConfig() *controller.OperatorConfiguration {
// If the `controller.devfile.io/devworkspace-config` is not set, the global DevWorkspaceOperatorConfig is returned.
// If the `controller.devfile.io/devworkspace-config` attribute is incorrectly set, or the specified DevWorkspaceOperatorConfig
// does not exist on the cluster, an error is returned.
//
// Referenced DWOCs are restricted to the DevWorkspace's namespace and the operator namespace.
// Pod-level fields from DWOCs outside the operator namespace are omitted before merge.
func ResolveConfigForWorkspace(workspace *dw.DevWorkspace, client crclient.Client) (*controller.OperatorConfiguration, error) {
if !workspace.Spec.Template.Attributes.Exists(constants.ExternalDevWorkspaceConfiguration) {
return GetGlobalConfig(), nil
Expand All @@ -80,12 +83,68 @@ func ResolveConfigForWorkspace(workspace *dw.DevWorkspace, client crclient.Clien
return nil, fmt.Errorf("'namespace' must be set for attribute %s in DevWorkspace attributes", constants.ExternalDevWorkspaceConfiguration)
}

if !isAllowedExternalConfigNamespace(namespacedName.Namespace, workspace.Namespace) {
return nil, fmt.Errorf("DevWorkspaceOperatorConfig %s/%s referenced by attribute %s must be in the DevWorkspace namespace %q or the operator namespace %q",
namespacedName.Namespace, namespacedName.Name, constants.ExternalDevWorkspaceConfiguration, workspace.Namespace, configNamespace)
}

externalDWOC := &controller.DevWorkspaceOperatorConfig{}
err = client.Get(context.TODO(), namespacedName, externalDWOC)
if err != nil {
return nil, fmt.Errorf("could not fetch external DWOC with name %s in namespace %s: %w", namespacedName.Name, namespacedName.Namespace, err)
}
return getMergedConfig(externalDWOC.Config, internalConfig), nil

externalConfig := externalDWOC.Config
if !isOperatorNamespace(namespacedName.Namespace) {
externalConfig = omitOperatorOnlyWorkspaceFields(externalConfig)
}
return getMergedConfig(externalConfig, internalConfig), nil
}

// isAllowedExternalConfigNamespace reports whether a referenced DWOC may be used
// for a workspace. Only the workspace's own namespace and the operator namespace
// are permitted.
func isAllowedExternalConfigNamespace(dwocNamespace, workspaceNamespace string) bool {
if dwocNamespace == "" {
return false
}
if workspaceNamespace != "" && dwocNamespace == workspaceNamespace {
return true
}
return isOperatorNamespace(dwocNamespace)
}

func isOperatorNamespace(namespace string) bool {
return configNamespace != "" && namespace == configNamespace
}

// omitOperatorOnlyWorkspaceFields returns a copy of config with pod-level fields
// removed. Workspace-namespace DWOCs may still override operational settings
// (storage, timeouts, imagePullPolicy, etc.). Fields such as security context,
// service account, scheduler, and default template are applied only from the
// operator namespace.
func omitOperatorOnlyWorkspaceFields(config *controller.OperatorConfiguration) *controller.OperatorConfiguration {
if config == nil {
return nil
}
sanitized := config.DeepCopy()
if sanitized.Workspace == nil {
return sanitized
}
ws := sanitized.Workspace
ws.PodSecurityContext = nil
ws.ContainerSecurityContext = nil
ws.ServiceAccount = nil
ws.RuntimeClassName = nil
ws.DefaultTemplate = nil
ws.PodAnnotations = nil
ws.SchedulerName = ""
ws.InitContainers = nil
ws.HostUsers = nil
ws.Overrides = nil
ws.ProjectCloneConfig = nil
ws.RestoreConfig = nil
return sanitized
}

func GetConfigForTesting(customConfig *controller.OperatorConfiguration) *controller.OperatorConfiguration {
Expand Down
161 changes: 159 additions & 2 deletions pkg/config/sync_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -133,10 +133,11 @@ func TestCatchesNonExistentExternalDWOC(t *testing.T) {
setupForTest(t)

workspace := &dw.DevWorkspace{}
workspace.Namespace = externalConfigNamespace
attributes := attributes.Attributes{}
namespacedName := types.NamespacedName{
Name: "external-config-name",
Namespace: "external-config-namespace",
Namespace: externalConfigNamespace,
}
attributes.Put(constants.ExternalDevWorkspaceConfiguration, namespacedName, nil)
workspace.Spec.Template.DevWorkspaceTemplateSpecContent = dw.DevWorkspaceTemplateSpecContent{
Expand All @@ -155,6 +156,7 @@ func TestMergeExternalConfig(t *testing.T) {
setupForTest(t)

workspace := &dw.DevWorkspace{}
workspace.Namespace = externalConfigNamespace
attributes := attributes.Attributes{}
namespacedName := types.NamespacedName{
Name: externalConfigName,
Expand Down Expand Up @@ -219,6 +221,161 @@ func TestMergeExternalConfig(t *testing.T) {
}
}

func TestRejectsExternalDWOCOutsideAllowedNamespaces(t *testing.T) {
setupForTest(t)

workspace := workspaceReferencingExternalConfig(t, "workspace-ns", "other-ns-dwoc", "other-ns")
clusterConfig := buildConfig(defaultConfig.DeepCopy())
unrelatedDWOC := &v1alpha1.DevWorkspaceOperatorConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "other-ns-dwoc",
Namespace: "other-ns",
},
Config: &v1alpha1.OperatorConfiguration{
Workspace: &v1alpha1.WorkspaceConfig{
ImagePullPolicy: "Never",
},
},
}
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterConfig, unrelatedDWOC).Build()
err := SetupControllerConfig(client)
if !assert.NoError(t, err, "Should not return error") {
return
}

resolvedConfig, err := ResolveConfigForWorkspace(workspace, client)
if !assert.Error(t, err, "Should reject DWOC references outside the workspace and operator namespaces") {
return
}
assert.Nil(t, resolvedConfig, "No config should be returned for a disallowed DWOC reference")
assert.Contains(t, err.Error(), "must be in the DevWorkspace namespace")
}

func TestOmitsPodLevelFieldsFromWorkspaceNamespaceDWOC(t *testing.T) {
setupForTest(t)

workspaceNS := "workspace-ns"
workspace := workspaceReferencingExternalConfig(t, workspaceNS, externalConfigName, workspaceNS)

privileged := true
externalConfig := &v1alpha1.DevWorkspaceOperatorConfig{
ObjectMeta: metav1.ObjectMeta{
Name: externalConfigName,
Namespace: workspaceNS,
},
Config: &v1alpha1.OperatorConfiguration{
Workspace: &v1alpha1.WorkspaceConfig{
ImagePullPolicy: "Never",
PVCName: "workspace-pvc",
PodSecurityContext: &corev1.PodSecurityContext{RunAsUser: pointer.Int64(0)},
ContainerSecurityContext: &corev1.SecurityContext{Privileged: &privileged},
ServiceAccount: &v1alpha1.ServiceAccountConfig{ServiceAccountName: "custom-sa"},
RuntimeClassName: pointer.String("kata"),
SchedulerName: "custom-scheduler",
PodAnnotations: map[string]string{"test.devfile.io/from-workspace-dwoc": "true"},
DefaultTemplate: &dw.DevWorkspaceTemplateSpecContent{},
HostUsers: pointer.Bool(false),
InitContainers: []corev1.Container{{Name: "extra-init", Image: "example.com/init:latest"}},
Overrides: &v1alpha1.OverrideConfig{RestrictedContainerOverrideFields: []string{}},
ProjectCloneConfig: &v1alpha1.ProjectCloneConfig{Image: "example.com/clone:latest"},
RestoreConfig: &v1alpha1.RestoreConfig{
ImagePullPolicy: corev1.PullNever,
Env: []corev1.EnvVar{
{Name: "RESTORE_FROM_WORKSPACE_DWOC", Value: "true"},
},
},
},
},
}
clusterConfig := buildConfig(defaultConfig.DeepCopy())
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterConfig, externalConfig).Build()
err := SetupControllerConfig(client)
if !assert.NoError(t, err, "Should not return error") {
return
}

resolvedConfig, err := ResolveConfigForWorkspace(workspace, client)
if !assert.NoError(t, err, "Workspace-namespace DWOC should be allowed") {
return
}

assert.Equal(t, "Never", resolvedConfig.Workspace.ImagePullPolicy, "Operational fields should still merge")
assert.Equal(t, "workspace-pvc", resolvedConfig.Workspace.PVCName, "Operational fields should still merge")
assert.Equal(t, internalConfig.Workspace.PodSecurityContext, resolvedConfig.Workspace.PodSecurityContext)
assert.Equal(t, internalConfig.Workspace.ContainerSecurityContext, resolvedConfig.Workspace.ContainerSecurityContext)
assert.Equal(t, internalConfig.Workspace.ServiceAccount, resolvedConfig.Workspace.ServiceAccount)
assert.Equal(t, internalConfig.Workspace.RuntimeClassName, resolvedConfig.Workspace.RuntimeClassName)
assert.Equal(t, internalConfig.Workspace.SchedulerName, resolvedConfig.Workspace.SchedulerName)
assert.Equal(t, internalConfig.Workspace.PodAnnotations, resolvedConfig.Workspace.PodAnnotations)
assert.Nil(t, resolvedConfig.Workspace.DefaultTemplate)
assert.Equal(t, internalConfig.Workspace.HostUsers, resolvedConfig.Workspace.HostUsers)
assert.Empty(t, resolvedConfig.Workspace.InitContainers)
assert.Equal(t, internalConfig.Workspace.Overrides, resolvedConfig.Workspace.Overrides)
assert.Equal(t, internalConfig.Workspace.ProjectCloneConfig, resolvedConfig.Workspace.ProjectCloneConfig)
assert.Equal(t, internalConfig.Workspace.RestoreConfig, resolvedConfig.Workspace.RestoreConfig)
}

func TestAppliesPodLevelFieldsFromOperatorNamespaceDWOC(t *testing.T) {
setupForTest(t)

workspace := workspaceReferencingExternalConfig(t, "workspace-ns", externalConfigName, testNamespace)

privileged := true
runtimeClass := "kata"
externalConfig := &v1alpha1.DevWorkspaceOperatorConfig{
ObjectMeta: metav1.ObjectMeta{
Name: externalConfigName,
Namespace: testNamespace,
},
Config: &v1alpha1.OperatorConfiguration{
Workspace: &v1alpha1.WorkspaceConfig{
ContainerSecurityContext: &corev1.SecurityContext{Privileged: &privileged},
SchedulerName: "custom-scheduler",
RuntimeClassName: &runtimeClass,
ServiceAccount: &v1alpha1.ServiceAccountConfig{ServiceAccountName: "operator-sa"},
RestoreConfig: &v1alpha1.RestoreConfig{
ImagePullPolicy: corev1.PullNever,
},
},
},
}
clusterConfig := buildConfig(defaultConfig.DeepCopy())
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(clusterConfig, externalConfig).Build()
err := SetupControllerConfig(client)
if !assert.NoError(t, err, "Should not return error") {
return
}

resolvedConfig, err := ResolveConfigForWorkspace(workspace, client)
if !assert.NoError(t, err, "Operator-namespace DWOC should be allowed") {
return
}

if !assert.NotNil(t, resolvedConfig.Workspace.ContainerSecurityContext) {
return
}
assert.Equal(t, &privileged, resolvedConfig.Workspace.ContainerSecurityContext.Privileged)
assert.Equal(t, "custom-scheduler", resolvedConfig.Workspace.SchedulerName)
assert.Equal(t, &runtimeClass, resolvedConfig.Workspace.RuntimeClassName)
assert.Equal(t, "operator-sa", resolvedConfig.Workspace.ServiceAccount.ServiceAccountName)
assert.Equal(t, corev1.PullNever, resolvedConfig.Workspace.RestoreConfig.ImagePullPolicy)
}

func workspaceReferencingExternalConfig(t *testing.T, workspaceNS, dwocName, dwocNS string) *dw.DevWorkspace {
t.Helper()
workspace := &dw.DevWorkspace{}
workspace.Namespace = workspaceNS
attrs := attributes.Attributes{}
attrs.Put(constants.ExternalDevWorkspaceConfiguration, types.NamespacedName{
Name: dwocName,
Namespace: dwocNS,
}, nil)
workspace.Spec.Template.DevWorkspaceTemplateSpecContent = dw.DevWorkspaceTemplateSpecContent{
Attributes: attrs,
}
return workspace
}

func TestSetupControllerAlwaysSetsDefaultClusterRoutingSuffix(t *testing.T) {
setupForTest(t)
infrastructure.InitializeForTesting(infrastructure.OpenShiftv4)
Expand Down
5 changes: 4 additions & 1 deletion pkg/constants/attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const (
// which will merged with the internal/global DevWorkspaceOperatorConfig. The DevWorkspaceOperatorConfig resulting from the merge will be used for the workspace.
// The fields which are set in the external DevWorkspaceOperatorConfig will overwrite those existing in the
// internal/global DevWorkspaceOperatorConfig during the merge.
// The referenced DevWorkspaceOperatorConfig must be in the DevWorkspace's namespace or the operator namespace.
// Pod-level fields (security contexts, service account, default template, etc.) from DWOCs outside
// the operator namespace are ignored.
// The structure of the attribute value should contain two strings: name and namespace.
// 'name' specifies the metadata.name of the external operator configuration.
// 'namespace' specifies the metadata.namespace of the external operator configuration .
Expand All @@ -40,7 +43,7 @@ const (
// attributes:
// controller.devfile.io/devworkspace-config:
// name: external-dwoc-name
// namespace: some-namespace
// namespace: workspace-or-operator-namespace
ExternalDevWorkspaceConfiguration = "controller.devfile.io/devworkspace-config"

// RuntimeClassNameAttribute is an attribute added to a DevWorkspace to specify a runtimeClassName for container
Expand Down
Loading