Skip to content

Commit 9c87c0e

Browse files
committed
feat: configure init containers
Signed-off-by: Oleksii Kurinnyi <[email protected]>
1 parent b61eaed commit 9c87c0e

File tree

6 files changed

+280
-17
lines changed

6 files changed

+280
-17
lines changed

apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ type WorkspaceConfig struct {
203203
// If the feature is disabled, setting this field may cause an endless workspace start loop.
204204
// +kubebuilder:validation:Optional
205205
HostUsers *bool `json:"hostUsers,omitempty"`
206+
// InitContainers are injected into all workspace pods as Kubernetes init containers.
207+
// Typical uses: injecting organization tools/configs, initializing persistent home, etc.
208+
// Note: Only trusted administrators should be allowed to edit the DevWorkspaceOperatorConfig.
209+
InitContainers []corev1.Container `json:"initContainers,omitempty"`
206210
}
207211

208212
type WebhookConfig struct {

controllers/workspace/devworkspace_controller.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,107 @@ import (
6767
"sigs.k8s.io/controller-runtime/pkg/reconcile"
6868
)
6969

70+
// applyHomeInitDefaults applies default values for image and command fields
71+
// of the init-persistent-home container.
72+
func applyHomeInitDefaults(c corev1.Container, workspace *common.DevWorkspaceWithConfig) (corev1.Container, error) {
73+
if c.Image == "" {
74+
inferred := home.InferWorkspaceImage(&workspace.Spec.Template)
75+
if inferred == "" {
76+
return c, fmt.Errorf("unable to infer workspace image for init-persistent-home; specify image explicitly")
77+
}
78+
c.Image = inferred
79+
}
80+
if len(c.Command) == 0 {
81+
c.Command = []string{"/bin/sh", "-c"}
82+
}
83+
return c, nil
84+
}
85+
86+
// validateNoAdvancedFields validates that the init-persistent-home container
87+
// does not use advanced Kubernetes container fields that could make behavior unpredictable.
88+
func validateNoAdvancedFields(c corev1.Container) error {
89+
if len(c.Ports) > 0 {
90+
return fmt.Errorf("ports are not allowed for init-persistent-home")
91+
}
92+
93+
if c.LivenessProbe != nil || c.ReadinessProbe != nil || c.StartupProbe != nil {
94+
return fmt.Errorf("probes are not allowed for init-persistent-home")
95+
}
96+
97+
if c.Lifecycle != nil {
98+
return fmt.Errorf("lifecycle hooks are not allowed for init-persistent-home")
99+
}
100+
101+
if c.Stdin || c.StdinOnce || c.TTY {
102+
return fmt.Errorf("stdin/tty fields are not allowed for init-persistent-home")
103+
}
104+
105+
if len(c.VolumeDevices) > 0 || c.WorkingDir != "" {
106+
return fmt.Errorf("volumeDevices and workingDir are not allowed for init-persistent-home")
107+
}
108+
109+
if c.TerminationMessagePath != "" || c.TerminationMessagePolicy != "" {
110+
return fmt.Errorf("termination message fields are not allowed for init-persistent-home")
111+
}
112+
113+
if c.SecurityContext != nil {
114+
return fmt.Errorf("securityContext is not allowed for init-persistent-home")
115+
}
116+
if c.Resources.Limits != nil || c.Resources.Requests != nil {
117+
return fmt.Errorf("resource limits/requests are not allowed for init-persistent-home")
118+
}
119+
120+
return nil
121+
}
122+
123+
// validateHomeInitContainer validates all aspects of the init-persistent-home container.
124+
func validateHomeInitContainer(c corev1.Container) error {
125+
if strings.ContainsAny(c.Image, "\n\r\t ") {
126+
return fmt.Errorf("invalid image reference for init-persistent-home: image reference contains invalid whitespace characters")
127+
}
128+
129+
if len(c.Command) != 2 || c.Command[0] != "/bin/sh" || c.Command[1] != "-c" {
130+
return fmt.Errorf("command must be exactly [/bin/sh, -c]")
131+
}
132+
133+
if len(c.Args) != 1 {
134+
return fmt.Errorf("args must contain exactly one script string")
135+
}
136+
137+
if len(c.VolumeMounts) > 0 {
138+
return fmt.Errorf("volumeMounts are not allowed for init-persistent-home; persistent-home is auto-mounted at /home/user/")
139+
}
140+
141+
if err := validateNoAdvancedFields(c); err != nil {
142+
return err
143+
}
144+
145+
return nil
146+
}
147+
148+
// defaultAndValidateHomeInitContainer applies defaults and validation for a custom
149+
// DWOC-provided init container named init-persistent-home. It ensures a shell execution
150+
// model, a single script arg, injects the persistent-home mount at /home/user/, and
151+
// defaults image to the inferred workspace image if not provided.
152+
func defaultAndValidateHomeInitContainer(c corev1.Container, workspace *common.DevWorkspaceWithConfig) (corev1.Container, error) {
153+
var err error
154+
155+
if c, err = applyHomeInitDefaults(c, workspace); err != nil {
156+
return c, err
157+
}
158+
159+
if err = validateHomeInitContainer(c); err != nil {
160+
return c, err
161+
}
162+
163+
c.VolumeMounts = []corev1.VolumeMount{{
164+
Name: constants.HomeVolumeName,
165+
MountPath: constants.HomeUserDirectory,
166+
}}
167+
168+
return c, nil
169+
}
170+
70171
const (
71172
startingWorkspaceRequeueInterval = 5 * time.Second
72173
)
@@ -360,6 +461,34 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
360461
devfilePodAdditions.InitContainers = append([]corev1.Container{*projectClone}, devfilePodAdditions.InitContainers...)
361462
}
362463

464+
// Inject operator-configured init containers
465+
if workspace.Config != nil && workspace.Config.Workspace != nil {
466+
// Check if init-persistent-home should be disabled
467+
disableHomeInit := workspace.Config.Workspace.PersistUserHome.DisableInitContainer != nil &&
468+
*workspace.Config.Workspace.PersistUserHome.DisableInitContainer
469+
470+
for _, c := range workspace.Config.Workspace.InitContainers {
471+
// Special handling for init-persistent-home
472+
if c.Name == constants.HomeInitComponentName {
473+
// Skip if persistent home is disabled
474+
if !home.PersistUserHomeEnabled(workspace) {
475+
continue
476+
}
477+
// Skip if init container is explicitly disabled
478+
if disableHomeInit {
479+
continue
480+
}
481+
// Apply defaults and validation for init-persistent-home
482+
validated, err := defaultAndValidateHomeInitContainer(c, workspace)
483+
if err != nil {
484+
return r.failWorkspace(workspace, fmt.Sprintf("Invalid init-persistent-home container: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
485+
}
486+
c = validated
487+
}
488+
devfilePodAdditions.InitContainers = append(devfilePodAdditions.InitContainers, c)
489+
}
490+
}
491+
363492
// Add ServiceAccount tokens into devfile containers
364493
if err := wsprovision.ProvisionServiceAccountTokensInto(devfilePodAdditions, workspace); err != nil {
365494
return r.failWorkspace(workspace, fmt.Sprintf("Failed to mount ServiceAccount tokens to workspace: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil

docs/dwo-configuration.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,83 @@ config:
125125
```
126126

127127
The config above will have newly created PVCs to have its access mode set to `ReadWriteMany`.
128+
129+
## Configuring Custom Init Containers
130+
131+
The DevWorkspace Operator allows cluster administrators to inject custom init containers into all workspace pods via the `config.workspace.initContainers` field in the global DWOC. This feature enables use cases such as:
132+
133+
- Injecting organization-specific tools or configurations
134+
- Customizing the persistent home directory initialization logic
135+
- Extracting cluster utilities (e.g., `oc` CLI) to ensure version compatibility
136+
137+
**Security Note:** Only trusted administrators should have RBAC permissions to edit the `DevWorkspaceOperatorConfig`, as custom init containers run in every workspace and can execute arbitrary code.
138+
139+
### Basic Example: Injecting Custom Tools
140+
141+
```yaml
142+
apiVersion: controller.devfile.io/v1alpha1
143+
kind: DevWorkspaceOperatorConfig
144+
metadata:
145+
name: devworkspace-operator-config
146+
namespace: $OPERATOR_INSTALL_NAMESPACE
147+
config:
148+
workspace:
149+
initContainers:
150+
- name: inject-oc-cli
151+
image: registry.redhat.io/openshift4/ose-cli:latest
152+
command: ["/bin/sh", "-c"]
153+
args:
154+
- |
155+
cp /usr/bin/oc /home/user/bin/oc
156+
cp /usr/bin/kubectl /home/user/bin/kubectl
157+
volumeMounts:
158+
- name: persistent-home
159+
mountPath: /home/user/
160+
```
161+
162+
### Special Container: `init-persistent-home`
163+
164+
A specially-named init container `init-persistent-home` can be used to override the built-in persistent home directory initialization logic when `config.workspace.persistUserHome.enabled: true`. This is useful for enterprises using customized UDI images that require different home directory setup logic.
165+
166+
**Contract for `init-persistent-home`:**
167+
168+
- **Name:** Must be exactly `init-persistent-home`
169+
- **Image:** Optional. If omitted, defaults to the first non-imported workspace container's image. If no suitable image can be inferred, the workspace will fail to start with an error.
170+
- **Command:** Optional. If omitted, defaults to `["/bin/sh", "-c"]`. If provided, must exactly match this value.
171+
- **Args:** Required. Must contain exactly one string with the initialization script.
172+
- **VolumeMounts:** Forbidden. The operator automatically mounts the `persistent-home` volume at `/home/user/`.
173+
- **Env:** Optional. Environment variables are allowed.
174+
- **Other fields:** Not allowed. Fields such as `ports`, `probes`, `lifecycle`, `securityContext`, `resources`, `volumeDevices`, `stdin`, `tty`, and `workingDir` are rejected to keep behavior predictable.
175+
176+
**Note:** If `persistUserHome.enabled` is `false`, any `init-persistent-home` container is ignored.
177+
178+
### Example: Custom Persistent Home Initialization
179+
180+
```yaml
181+
apiVersion: controller.devfile.io/v1alpha1
182+
kind: DevWorkspaceOperatorConfig
183+
metadata:
184+
name: devworkspace-operator-config
185+
namespace: $OPERATOR_INSTALL_NAMESPACE
186+
config:
187+
workspace:
188+
persistUserHome:
189+
enabled: true
190+
initContainers:
191+
- name: init-persistent-home
192+
# image: optional - defaults to workspace image
193+
# command: optional - defaults to ["/bin/sh", "-c"]
194+
args:
195+
- |
196+
echo "Enterprise home init"
197+
# Custom logic for enterprise UDI
198+
rsync -a --ignore-existing /home/tooling/ /home/user/ || true
199+
touch /home/user/.home_initialized
200+
env:
201+
- name: CUSTOM_VAR
202+
value: "custom-value"
203+
```
204+
205+
### Execution Order
206+
207+
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.

pkg/config/sync.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,14 @@ func mergeConfig(from, to *controller.OperatorConfiguration) {
439439
if from.Workspace.HostUsers != nil {
440440
to.Workspace.HostUsers = from.Workspace.HostUsers
441441
}
442+
443+
if from.Workspace.InitContainers != nil {
444+
initContainersCopy := make([]corev1.Container, len(from.Workspace.InitContainers))
445+
for i, container := range from.Workspace.InitContainers {
446+
initContainersCopy[i] = *container.DeepCopy()
447+
}
448+
to.Workspace.InitContainers = initContainersCopy
449+
}
442450
}
443451
}
444452

@@ -687,6 +695,13 @@ func GetCurrentConfigString(currConfig *controller.OperatorConfiguration) string
687695
if workspace.HostUsers != nil {
688696
config = append(config, fmt.Sprintf("workspace.hostUsers=%t", *workspace.HostUsers))
689697
}
698+
if len(workspace.InitContainers) > 0 {
699+
initContainerNames := make([]string, len(workspace.InitContainers))
700+
for i, container := range workspace.InitContainers {
701+
initContainerNames[i] = container.Name
702+
}
703+
config = append(config, fmt.Sprintf("workspace.initContainers=[%s]", strings.Join(initContainerNames, ", ")))
704+
}
690705
}
691706
if currConfig.EnableExperimentalFeatures != nil && *currConfig.EnableExperimentalFeatures {
692707
config = append(config, "enableExperimentalFeatures=true")

pkg/library/home/persistentHome.go

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,19 @@ func AddPersistentHomeVolume(workspace *common.DevWorkspaceWithConfig) (*v1alpha
6262
Path: constants.HomeUserDirectory,
6363
}
6464

65-
if workspace.Config.Workspace.PersistUserHome.DisableInitContainer == nil || !*workspace.Config.Workspace.PersistUserHome.DisableInitContainer {
65+
// Determine if a custom home init container is configured via DWOC
66+
hasCustomHomeInit := false
67+
if workspace.Config != nil && workspace.Config.Workspace != nil {
68+
for _, c := range workspace.Config.Workspace.InitContainers {
69+
if c.Name == constants.HomeInitComponentName {
70+
hasCustomHomeInit = true
71+
break
72+
}
73+
}
74+
}
75+
76+
// Add default init container only if not disabled and no custom init is configured
77+
if (workspace.Config.Workspace.PersistUserHome.DisableInitContainer == nil || !*workspace.Config.Workspace.PersistUserHome.DisableInitContainer) && !hasCustomHomeInit {
6678
err := addInitContainer(dwTemplateSpecCopy)
6779
if err != nil {
6880
return nil, fmt.Errorf("failed to add init container for home persistence setup: %w", err)
@@ -216,22 +228,8 @@ func addInitContainerComponent(dwTemplateSpec *v1alpha2.DevWorkspaceTemplateSpec
216228
}
217229

218230
func inferInitContainer(dwTemplateSpec *v1alpha2.DevWorkspaceTemplateSpec) *v1alpha2.Container {
219-
var nonImportedComponent v1alpha2.Component
220-
for _, component := range dwTemplateSpec.Components {
221-
if component.Container == nil {
222-
continue
223-
}
224-
225-
pluginSource := component.Attributes.GetString(constants.PluginSourceAttribute, nil)
226-
if pluginSource == "" || pluginSource == "parent" {
227-
// First non-imported container component is selected
228-
nonImportedComponent = component
229-
break
230-
}
231-
}
232-
233-
if nonImportedComponent.Name != "" {
234-
image := nonImportedComponent.Container.Image
231+
image := InferWorkspaceImage(dwTemplateSpec)
232+
if image != "" {
235233
return &v1alpha2.Container{
236234
Image: image,
237235
Command: []string{"/bin/sh", "-c"},

pkg/library/home/util.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// Copyright (c) 2019-2025 Red Hat, Inc.
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
package home
17+
18+
import (
19+
"github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
20+
"github.com/devfile/devworkspace-operator/pkg/constants"
21+
)
22+
23+
// InferWorkspaceImage finds the first non-imported container component image in the
24+
// flattened devfile template. This mirrors the selection rule used by the built-in
25+
// persistent-home initializer to pick a "primary" workspace image.
26+
func InferWorkspaceImage(dwTemplate *v1alpha2.DevWorkspaceTemplateSpec) string {
27+
for _, component := range dwTemplate.Components {
28+
if component.Container == nil {
29+
continue
30+
}
31+
pluginSource := component.Attributes.GetString(constants.PluginSourceAttribute, nil)
32+
if pluginSource == "" || pluginSource == "parent" {
33+
return component.Container.Image
34+
}
35+
}
36+
return ""
37+
}

0 commit comments

Comments
 (0)