Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d515a53
feat: configure init containers
akurinnoy Oct 30, 2025
c944db0
chore: run make update_devworkspace_api update_devworkspace_crds gen…
akurinnoy Oct 30, 2025
e566270
test: unit and e2e tests for init containers
akurinnoy Oct 30, 2025
ef4b2ea
Update apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go
akurinnoy Oct 31, 2025
105287a
Apply suggestion from @rohanKanojia
akurinnoy Oct 31, 2025
939a9dd
fixup! feat: configure init containers
akurinnoy Oct 31, 2025
9243abc
fixup! fixup! feat: configure init containers
akurinnoy Oct 31, 2025
366b021
fixup! fixup! fixup! feat: configure init containers
akurinnoy Oct 31, 2025
e351354
fixup! fixup! fixup! fixup! feat: configure init containers
akurinnoy Oct 31, 2025
855e02d
fixup! fixup! fixup! fixup! fixup! feat: configure init containers
akurinnoy Oct 31, 2025
7b1a437
feat: add strategic merge for init containers
akurinnoy Nov 19, 2025
30777bc
Apply suggestion from @tolusha
akurinnoy Nov 19, 2025
b0aa34d
fix: extract ensureHomeInitContainerFields to remove duplication
akurinnoy Nov 19, 2025
bd28716
fixes
akurinnoy Nov 19, 2025
8f04299
fixup! fixes
akurinnoy Nov 19, 2025
dfc4a73
fixup! fixup! fixes
akurinnoy Nov 25, 2025
f82748a
fixup! fixup! fixup! fixes
akurinnoy Nov 25, 2025
686e07b
fixup! fixup! fixup! fixup! fixes
akurinnoy Nov 25, 2025
1110865
fixup! fixup! fixup! fixup! fixup! fixes
akurinnoy Nov 28, 2025
8aaac10
fixup! fixup! fixup! fixup! fixup! fixup! fixes
akurinnoy Dec 2, 2025
665662c
Apply suggestion from @dkwon17
akurinnoy Dec 2, 2025
444244a
fixup! Apply suggestion from @dkwon17
akurinnoy Dec 2, 2025
7c5c545
fixup! fixup! Apply suggestion from @dkwon17
akurinnoy Dec 3, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ type WorkspaceConfig struct {
// If the feature is disabled, setting this field may cause an endless workspace start loop.
// +kubebuilder:validation:Optional
HostUsers *bool `json:"hostUsers,omitempty"`
// 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"`
}

type WebhookConfig struct {
Expand Down
7 changes: 7 additions & 0 deletions apis/controller/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

185 changes: 185 additions & 0 deletions controllers/workspace/devworkspace_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ package controllers
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"

"github.com/devfile/devworkspace-operator/pkg/library/initcontainers"
"github.com/devfile/devworkspace-operator/pkg/library/ssh"

dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
Expand Down Expand Up @@ -67,6 +69,143 @@ import (
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)

// validateNoAdvancedFields validates that the init-persistent-home container
// does not use advanced Kubernetes container fields that could make behavior unpredictable.
func validateNoAdvancedFields(c corev1.Container) error {
if len(c.Ports) > 0 {
return fmt.Errorf("ports are not allowed for %s", constants.HomeInitComponentName)
}

if c.LivenessProbe != nil || c.ReadinessProbe != nil || c.StartupProbe != nil {
return fmt.Errorf("probes are not allowed for %s", constants.HomeInitComponentName)
}

if c.Lifecycle != nil {
return fmt.Errorf("lifecycle hooks are not allowed for %s", constants.HomeInitComponentName)
}

if c.Stdin || c.StdinOnce || c.TTY {
return fmt.Errorf("stdin/tty fields are not allowed for %s", constants.HomeInitComponentName)
}

if len(c.VolumeDevices) > 0 {
return fmt.Errorf("volumeDevices are not allowed for %s", constants.HomeInitComponentName)
}

if c.WorkingDir != "" {
return fmt.Errorf("workingDir is not allowed for %s", constants.HomeInitComponentName)
}

if c.TerminationMessagePath != "" || c.TerminationMessagePolicy != "" {
return fmt.Errorf("termination message fields are not allowed for %s", constants.HomeInitComponentName)
}

if c.SecurityContext != nil {
return fmt.Errorf("securityContext is not allowed for %s", constants.HomeInitComponentName)
}
if c.Resources.Limits != nil || c.Resources.Requests != nil {
return fmt.Errorf("resource limits/requests are not allowed for %s", constants.HomeInitComponentName)
}

return nil
}

// validateHomeInitContainer validates all aspects of the init-persistent-home container.
// It only validates fields that are present, as missing fields will be filled by
// the strategic merge with the default init container.
func validateHomeInitContainer(c corev1.Container) error {
// Only validate if present
if c.Image != "" {
if err := validateImageReference(c.Image); err != nil {
return fmt.Errorf("invalid image reference for %s: %w", constants.HomeInitComponentName, err)
}
}

// Only validate if present
if c.Command != nil {
if len(c.Command) != 2 || c.Command[0] != "/bin/sh" || c.Command[1] != "-c" {
return fmt.Errorf("command must be exactly [/bin/sh, -c] for %s", constants.HomeInitComponentName)
}
}

// Only validate if present
if c.Args != nil {
if len(c.Args) != 1 {
return fmt.Errorf("args must contain exactly one script string for %s", constants.HomeInitComponentName)
}
}

// Always validate - should not be provided
if len(c.VolumeMounts) > 0 {
return fmt.Errorf("volumeMounts are not allowed for %s; persistent-home is auto-mounted at /home/user/", constants.HomeInitComponentName)
}

// Always validate - should not be provided
if err := validateNoAdvancedFields(c); err != nil {
return err
}

return nil
}

func validateImageReference(image string) error {
if image == "" {
return fmt.Errorf("image reference cannot be empty")
}

// whitespace and control characters
if strings.ContainsAny(image, "\n\r\t ") {
return fmt.Errorf("contains invalid whitespace characters")
}

// other control characters
for _, r := range image {
if r < 0x20 || r == 0x7F {
return fmt.Errorf("contains invalid control characters")
}
}

// format: [registry[:port]/]repository[:tag][@digest]
imagePattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])*|\[?[0-9a-fA-F:]+]?)(:\d{1,5})?(/[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])*)*(:[a-zA-Z0-9_.-]+)?(@sha256:[a-f0-9]{64})?$`)
if !imagePattern.MatchString(image) {
return fmt.Errorf("invalid format: should match regex: %s", imagePattern.String())
}

// port range
portMatch := regexp.MustCompile(`:(\d{1,5})(/|:|@|$)`)
matches := portMatch.FindStringSubmatch(image)
if len(matches) > 1 {
port, err := strconv.Atoi(matches[1])
if err != nil {
return fmt.Errorf("invalid port format: %w", err)
}
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port number: %d (must be 1-65535)", port)
}
}

// length check
if len(image) > 4096 {
return fmt.Errorf("length exceeds 4096 characters")
}

return nil
}

// ensureHomeInitContainerFields ensures that an init-persistent-home container has
// the correct Command, Args, and VolumeMounts.
func ensureHomeInitContainerFields(c *corev1.Container) error {
c.Command = []string{"/bin/sh", "-c"}
if len(c.Args) != 1 {
return fmt.Errorf("args must contain exactly one script string for %s", constants.HomeInitComponentName)
}
c.VolumeMounts = []corev1.VolumeMount{{
Name: constants.HomeVolumeName,
MountPath: constants.HomeUserDirectory,
}}
return nil
}

const (
startingWorkspaceRequeueInterval = 5 * time.Second
)
Expand Down Expand Up @@ -368,6 +507,52 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
devfilePodAdditions.InitContainers = append([]corev1.Container{*projectClone}, devfilePodAdditions.InitContainers...)
}

// Inject operator-configured init containers
if workspace.Config != nil && workspace.Config.Workspace != nil && len(workspace.Config.Workspace.InitContainers) > 0 {
// Check if init-persistent-home should be disabled
disableHomeInit := pointer.BoolDeref(workspace.Config.Workspace.PersistUserHome.DisableInitContainer, constants.DefaultDisableHomeInitContainer)

// Prepare patches: filter and preprocess init containers from config
patches := []corev1.Container{}
for _, container := range workspace.Config.Workspace.InitContainers {
// Special handling for init-persistent-home
if container.Name == constants.HomeInitComponentName {
// Skip if persistent home is disabled
if !home.PersistUserHomeEnabled(workspace) {
reqLogger.Info("Skipping init-persistent-home container: persistent home is disabled")
continue
}
// Skip if init container is explicitly disabled
if disableHomeInit {
reqLogger.Info("Skipping init-persistent-home container: DisableInitContainer is true")
continue
}
// Validate custom home init container
if err := validateHomeInitContainer(container); err != nil {
return r.failWorkspace(workspace, fmt.Sprintf("Invalid %s container: %s", constants.HomeInitComponentName, err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
}
}
patches = append(patches, container)
}

// Perform strategic merge
merged, err := initcontainers.MergeInitContainers(devfilePodAdditions.InitContainers, patches)
if err != nil {
return r.failWorkspace(workspace, fmt.Sprintf("Failed to merge init containers: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
}

// Ensure init-persistent-home container have correct fields after merge
for i := range merged {
if merged[i].Name == constants.HomeInitComponentName {
if err := ensureHomeInitContainerFields(&merged[i]); err != nil {
return r.failWorkspace(workspace, fmt.Sprintf("Invalid %s container: %s", constants.HomeInitComponentName, err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
}
}
}

devfilePodAdditions.InitContainers = merged
}

// Add ServiceAccount tokens into devfile containers
if err := wsprovision.ProvisionServiceAccountTokensInto(devfilePodAdditions, workspace); err != nil {
return r.failWorkspace(workspace, fmt.Sprintf("Failed to mount ServiceAccount tokens to workspace: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
Expand Down
Loading
Loading