Skip to content

Commit 3f7e370

Browse files
committed
feat: add strategic merge for init containers
Signed-off-by: Oleksii Kurinnyi <[email protected]>
1 parent 855e02d commit 3f7e370

File tree

3 files changed

+250
-2
lines changed

3 files changed

+250
-2
lines changed

controllers/workspace/devworkspace_controller.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"strings"
2323
"time"
2424

25+
"github.com/devfile/devworkspace-operator/pkg/library/initcontainers"
2526
"github.com/devfile/devworkspace-operator/pkg/library/ssh"
2627

2728
dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
@@ -474,11 +475,13 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
474475
}
475476

476477
// Inject operator-configured init containers
477-
if workspace.Config != nil && workspace.Config.Workspace != nil {
478+
if workspace.Config != nil && workspace.Config.Workspace != nil && len(workspace.Config.Workspace.InitContainers) > 0 {
478479
// Check if init-persistent-home should be disabled
479480
disableHomeInit := workspace.Config.Workspace.PersistUserHome.DisableInitContainer != nil &&
480481
*workspace.Config.Workspace.PersistUserHome.DisableInitContainer
481482

483+
// Prepare patches: filter and preprocess init containers from config
484+
patches := []corev1.Container{}
482485
for _, c := range workspace.Config.Workspace.InitContainers {
483486
// Special handling for init-persistent-home
484487
if c.Name == constants.HomeInitComponentName {
@@ -497,8 +500,33 @@ func (r *DevWorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request
497500
}
498501
c = validated
499502
}
500-
devfilePodAdditions.InitContainers = append(devfilePodAdditions.InitContainers, c)
503+
patches = append(patches, c)
501504
}
505+
506+
// Perform strategic merge
507+
merged, err := initcontainers.MergeInitContainers(devfilePodAdditions.InitContainers, patches)
508+
if err != nil {
509+
return r.failWorkspace(workspace, fmt.Sprintf("Failed to merge init containers: %s", err), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
510+
}
511+
512+
// Ensure init-persistent-home container have correct fields after merge
513+
for i := range merged {
514+
if merged[i].Name == constants.HomeInitComponentName {
515+
// Ensure Command is correct (should be set by defaultAndValidateHomeInitContainer, but enforce after merge)
516+
merged[i].Command = []string{"/bin/sh", "-c"}
517+
// Args should be set by patch validation, but ensure it has exactly one element
518+
if len(merged[i].Args) != 1 {
519+
return r.failWorkspace(workspace, fmt.Sprintf("Invalid %s container: args must contain exactly one script string", constants.HomeInitComponentName), metrics.ReasonBadRequest, reqLogger, &reconcileStatus), nil
520+
}
521+
// Ensure VolumeMounts are correct
522+
merged[i].VolumeMounts = []corev1.VolumeMount{{
523+
Name: constants.HomeVolumeName,
524+
MountPath: constants.HomeUserDirectory,
525+
}}
526+
}
527+
}
528+
529+
devfilePodAdditions.InitContainers = merged
502530
}
503531

504532
// Add ServiceAccount tokens into devfile containers
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 initcontainers
17+
18+
import (
19+
"fmt"
20+
21+
corev1 "k8s.io/api/core/v1"
22+
"k8s.io/apimachinery/pkg/util/json"
23+
"k8s.io/apimachinery/pkg/util/strategicpatch"
24+
)
25+
26+
// MergeInitContainers performs a strategic merge of init containers.
27+
// Containers with the same name in the patch will be merged into the base,
28+
// and new containers will be appended. The merge uses Kubernetes' strategic
29+
// merge patch semantics with name as the merge key.
30+
func MergeInitContainers(base []corev1.Container, patches []corev1.Container) ([]corev1.Container, error) {
31+
if len(patches) == 0 {
32+
return base, nil
33+
}
34+
35+
// create PodSpec structure with base init containers
36+
basePodSpec := corev1.PodSpec{
37+
InitContainers: base,
38+
}
39+
40+
// create PodSpec structure with patch init containers
41+
patchPodSpec := corev1.PodSpec{
42+
InitContainers: patches,
43+
}
44+
45+
// marshal both structures to JSON
46+
baseBytes, err := json.Marshal(basePodSpec)
47+
if err != nil {
48+
return nil, fmt.Errorf("failed to marshal base init containers: %w", err)
49+
}
50+
patchBytes, err := json.Marshal(patchPodSpec)
51+
if err != nil {
52+
return nil, fmt.Errorf("failed to marshal patch init containers: %w", err)
53+
}
54+
55+
// perform strategic merge patch
56+
mergedBytes, err := strategicpatch.StrategicMergePatch(
57+
baseBytes,
58+
patchBytes,
59+
&corev1.PodSpec{},
60+
)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to apply strategic merge patch: %w", err)
63+
}
64+
65+
// unmarshal the merged result
66+
var mergedPodSpec corev1.PodSpec
67+
if err := json.Unmarshal(mergedBytes, &mergedPodSpec); err != nil {
68+
return nil, fmt.Errorf("failed to unmarshal merged init containers: %w", err)
69+
}
70+
71+
/* restore the original containers order */
72+
73+
// build map for quick lookup
74+
mergedMap := make(map[string]corev1.Container)
75+
for _, container := range mergedPodSpec.InitContainers {
76+
mergedMap[container.Name] = container
77+
}
78+
79+
result := make([]corev1.Container, 0, len(mergedPodSpec.InitContainers))
80+
baseNames := make(map[string]bool)
81+
82+
// add base containers in order, merged one if patched
83+
for _, baseContainer := range base {
84+
baseNames[baseContainer.Name] = true
85+
if merged, exists := mergedMap[baseContainer.Name]; exists {
86+
result = append(result, merged)
87+
delete(mergedMap, baseContainer.Name)
88+
} else {
89+
result = append(result, baseContainer)
90+
}
91+
}
92+
93+
// append new containers from patches
94+
for _, patchContainer := range patches {
95+
if !baseNames[patchContainer.Name] {
96+
if merged, exists := mergedMap[patchContainer.Name]; exists {
97+
result = append(result, merged)
98+
} else {
99+
result = append(result, patchContainer)
100+
}
101+
}
102+
}
103+
104+
return result, nil
105+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 initcontainers
17+
18+
import (
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
corev1 "k8s.io/api/core/v1"
23+
)
24+
25+
func TestMergeInitContainers(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
base []corev1.Container
29+
patches []corev1.Container
30+
want []corev1.Container
31+
wantErr bool
32+
}{
33+
{
34+
name: "empty base",
35+
base: []corev1.Container{},
36+
patches: []corev1.Container{
37+
{Name: "new-container", Image: "new-image"},
38+
},
39+
want: []corev1.Container{
40+
{Name: "new-container", Image: "new-image"},
41+
},
42+
wantErr: false,
43+
},
44+
{
45+
name: "empty patches",
46+
base: []corev1.Container{
47+
{Name: "base-container", Image: "base-image"},
48+
},
49+
patches: []corev1.Container{},
50+
want: []corev1.Container{
51+
{Name: "base-container", Image: "base-image"},
52+
},
53+
wantErr: false,
54+
},
55+
{
56+
name: "multiple containers",
57+
base: []corev1.Container{
58+
{Name: "first", Image: "first-image"},
59+
{Name: "second", Image: "second-image"},
60+
{Name: "third", Image: "third-image"},
61+
},
62+
patches: []corev1.Container{
63+
{Name: "new-container", Image: "new-image"},
64+
{Name: "second", Image: "updated-second-image"},
65+
},
66+
want: []corev1.Container{
67+
{Name: "first", Image: "first-image"},
68+
{Name: "second", Image: "updated-second-image"},
69+
{Name: "third", Image: "third-image"},
70+
{Name: "new-container", Image: "new-image"},
71+
},
72+
wantErr: false,
73+
},
74+
{
75+
name: "partial field merge",
76+
base: []corev1.Container{
77+
{
78+
Name: "base-container",
79+
Image: "base-image",
80+
Command: []string{"/bin/sh", "-c"},
81+
Args: []string{"echo 'base'"},
82+
Env: []corev1.EnvVar{{Name: "BASE_VAR", Value: "base-value"}},
83+
},
84+
},
85+
patches: []corev1.Container{
86+
{
87+
Name: "base-container",
88+
Args: []string{"echo 'patched'"}, // only this field changed
89+
},
90+
},
91+
want: []corev1.Container{
92+
{
93+
Name: "base-container",
94+
Image: "base-image",
95+
Command: []string{"/bin/sh", "-c"},
96+
Args: []string{"echo 'patched'"},
97+
Env: []corev1.EnvVar{{Name: "BASE_VAR", Value: "base-value"}},
98+
},
99+
},
100+
wantErr: false,
101+
},
102+
}
103+
104+
for _, tt := range tests {
105+
t.Run(tt.name, func(t *testing.T) {
106+
got, err := MergeInitContainers(tt.base, tt.patches)
107+
if tt.wantErr {
108+
assert.Error(t, err, "should return error")
109+
} else {
110+
assert.NoError(t, err, "should not return error")
111+
assert.Equal(t, tt.want, got, "should return merged containers")
112+
}
113+
})
114+
}
115+
}

0 commit comments

Comments
 (0)