Skip to content

Commit 4845e93

Browse files
authored
enforce appwrapper components is immutable except to AW controller (#34)
1 parent 5ea4edb commit 4845e93

File tree

4 files changed

+69
-19
lines changed

4 files changed

+69
-19
lines changed

internal/controller/appwrapper_controller.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -384,15 +384,19 @@ func (r *AppWrapperReconciler) workloadStatus(ctx context.Context, aw *workloadv
384384
return summary, nil
385385
}
386386

387+
func replicas(ps workloadv1beta2.AppWrapperPodSet) int32 {
388+
if ps.Replicas == nil {
389+
return 1
390+
} else {
391+
return *ps.Replicas
392+
}
393+
}
394+
387395
func expectedPodCount(aw *workloadv1beta2.AppWrapper) int32 {
388396
var expected int32
389397
for _, c := range aw.Spec.Components {
390398
for _, s := range c.PodSets {
391-
if s.Replicas == nil {
392-
expected++
393-
} else {
394-
expected += *s.Replicas
395-
}
399+
expected += replicas(s)
396400
}
397401
}
398402
return expected

internal/controller/appwrapper_controller_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ var _ = Describe("AppWrapper Controller", func() {
5252
By("Updating aw.Spec by invoking RunWithPodSetsInfo")
5353
Expect((*AppWrapper)(aw).RunWithPodSetsInfo([]podset.PodSetInfo{markerPodSet, markerPodSet})).To(Succeed())
5454
Expect(aw.Spec.Suspend).To(BeFalse())
55-
Expect(k8sClient.Update(ctx, aw)).To(Succeed())
55+
Expect(k8sControllerClient.Update(ctx, aw)).To(Succeed())
5656

5757
By("Reconciling: Suspended -> Resuming")
5858
_, err = awReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: awName})
@@ -111,7 +111,7 @@ var _ = Describe("AppWrapper Controller", func() {
111111
Namespace: aw.Namespace,
112112
}
113113
awReconciler = &AppWrapperReconciler{
114-
Client: k8sClient,
114+
Client: k8sControllerClient,
115115
Scheme: k8sClient.Scheme(),
116116
}
117117
kueuePodSets = (*AppWrapper)(aw).PodSets()
@@ -178,7 +178,7 @@ var _ = Describe("AppWrapper Controller", func() {
178178
aw := getAppWrapper(awName)
179179
(*AppWrapper)(aw).Suspend()
180180
Expect((*AppWrapper)(aw).RestorePodSetsInfo(utilslices.Map(kueuePodSets, podset.FromPodSet))).To(BeTrue())
181-
Expect(k8sClient.Update(ctx, aw)).To(Succeed())
181+
Expect(k8sControllerClient.Update(ctx, aw)).To(Succeed())
182182

183183
By("Reconciling: Running -> Suspending")
184184
_, err := awReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: awName})

internal/controller/appwrapper_webhook.go

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package controller
1818

1919
import (
20+
"bytes"
2021
"context"
2122
"fmt"
2223

@@ -51,8 +52,8 @@ var _ webhook.CustomDefaulter = &AppWrapperWebhook{}
5152
// Default ensures that Suspend is set appropriately when an AppWrapper is created
5253
func (w *AppWrapperWebhook) Default(ctx context.Context, obj runtime.Object) error {
5354
aw := obj.(*workloadv1beta2.AppWrapper)
54-
log.FromContext(ctx).Info("Applying defaults", "job", aw)
5555
jobframework.ApplyDefaultForSuspend((*AppWrapper)(aw), w.Config.ManageJobsWithoutQueueName)
56+
log.FromContext(ctx).Info("Applied defaults", "job", aw)
5657
return nil
5758
}
5859

@@ -64,7 +65,7 @@ var _ webhook.CustomValidator = &AppWrapperWebhook{}
6465
func (w *AppWrapperWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
6566
aw := obj.(*workloadv1beta2.AppWrapper)
6667

67-
allErrors := w.validateAppWrapperInvariants(ctx, aw)
68+
allErrors := w.validateAppWrapperCreate(ctx, aw)
6869
if w.Config.ManageJobsWithoutQueueName || jobframework.QueueName((*AppWrapper)(aw)) != "" {
6970
allErrors = append(allErrors, jobframework.ValidateCreateForQueueName((*AppWrapper)(aw))...)
7071
}
@@ -83,7 +84,7 @@ func (w *AppWrapperWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj r
8384
oldAW := oldObj.(*workloadv1beta2.AppWrapper)
8485
newAW := newObj.(*workloadv1beta2.AppWrapper)
8586

86-
allErrors := w.validateAppWrapperInvariants(ctx, newAW)
87+
allErrors := w.validateAppWrapperUpdate(ctx, oldAW, newAW)
8788
if w.Config.ManageJobsWithoutQueueName || jobframework.QueueName((*AppWrapper)(newAW)) != "" {
8889
allErrors = append(allErrors, jobframework.ValidateUpdateForQueueName((*AppWrapper)(oldAW), (*AppWrapper)(newAW))...)
8990
allErrors = append(allErrors, jobframework.ValidateUpdateForWorkloadPriorityClassName((*AppWrapper)(oldAW), (*AppWrapper)(newAW))...)
@@ -107,13 +108,13 @@ func (w *AppWrapperWebhook) ValidateDelete(context.Context, runtime.Object) (adm
107108
//+kubebuilder:rbac:groups=authorization.k8s.io,resources=subjectaccessreviews,verbs=create
108109
//+kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=list
109110

110-
// validateAppWrapperInvariants checks these invariants:
111+
// validateAppWrapperCreate checks these invariants:
111112
// 1. AppWrappers must not contain other AppWrappers
112113
// 2. AppWrappers must only contain resources intended for their own namespace
113114
// 3. AppWrappers must not contain any resources that the user could not create directly
114115
// 4. Every PodSet must be well-formed: the Path must exist and must be parseable as a PodSpecTemplate
115116
// 5. AppWrappers must contain between 1 and 8 PodSets (Kueue invariant)
116-
func (w *AppWrapperWebhook) validateAppWrapperInvariants(ctx context.Context, aw *workloadv1beta2.AppWrapper) field.ErrorList {
117+
func (w *AppWrapperWebhook) validateAppWrapperCreate(ctx context.Context, aw *workloadv1beta2.AppWrapper) field.ErrorList {
117118
allErrors := field.ErrorList{}
118119
components := aw.Spec.Components
119120
componentsPath := field.NewPath("spec").Child("components")
@@ -124,11 +125,6 @@ func (w *AppWrapperWebhook) validateAppWrapperInvariants(ctx context.Context, aw
124125
}
125126
userInfo := request.UserInfo
126127

127-
// To reduce overhead, skip invariant validation when the AppWrapper controller is the user performing the update
128-
if w.Config.ServiceAccountName != "" && userInfo.Username == w.Config.ServiceAccountName {
129-
return allErrors
130-
}
131-
132128
for idx, component := range components {
133129
compPath := componentsPath.Index(idx)
134130
unstruct := &unstructured.Unstructured{}
@@ -205,6 +201,46 @@ func (w *AppWrapperWebhook) validateAppWrapperInvariants(ctx context.Context, aw
205201
return allErrors
206202
}
207203

204+
// validateAppWrapperUpdate enforces that AppWrapper.Spec.Components is deeply immutable
205+
func (w *AppWrapperWebhook) validateAppWrapperUpdate(ctx context.Context, old *workloadv1beta2.AppWrapper, new *workloadv1beta2.AppWrapper) field.ErrorList {
206+
// The AppWrapper controller must be allowed to mutate Spec.Components
207+
// to enable it to implement RunWithPodSetsInfo and RestorePodSetsInfo
208+
if request, err := admission.RequestFromContext(ctx); err == nil {
209+
if w.Config.ServiceAccountName != "" && request.UserInfo.Username == w.Config.ServiceAccountName {
210+
return field.ErrorList{}
211+
}
212+
}
213+
214+
allErrors := field.ErrorList{}
215+
msg := "attempt to change immutable field"
216+
componentsPath := field.NewPath("spec").Child("components")
217+
if len(old.Spec.Components) != len(new.Spec.Components) {
218+
return field.ErrorList{field.Forbidden(componentsPath, msg)}
219+
}
220+
for idx := range new.Spec.Components {
221+
compPath := componentsPath.Index(idx)
222+
oldComponent := old.Spec.Components[idx]
223+
newComponent := new.Spec.Components[idx]
224+
if !bytes.Equal(oldComponent.Template.Raw, newComponent.Template.Raw) {
225+
allErrors = append(allErrors, field.Forbidden(compPath.Child("template").Child("raw"), msg))
226+
}
227+
if len(oldComponent.PodSets) != len(newComponent.PodSets) {
228+
allErrors = append(allErrors, field.Forbidden(compPath.Child("podsets"), msg))
229+
} else {
230+
for psIdx := range newComponent.PodSets {
231+
if replicas(oldComponent.PodSets[psIdx]) != replicas(newComponent.PodSets[psIdx]) {
232+
allErrors = append(allErrors, field.Forbidden(compPath.Child("podsets").Index(psIdx).Child("replicas"), msg))
233+
}
234+
if oldComponent.PodSets[psIdx].Path != newComponent.PodSets[psIdx].Path {
235+
allErrors = append(allErrors, field.Forbidden(compPath.Child("podsets").Index(psIdx).Child("path"), msg))
236+
}
237+
}
238+
}
239+
}
240+
241+
return allErrors
242+
}
243+
208244
func (w *AppWrapperWebhook) lookupResource(gvk *schema.GroupVersionKind) string {
209245
if known, ok := w.kindToResourceCache[gvk.String()]; ok {
210246
return known

internal/controller/suite_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import (
5353

5454
var cfg *rest.Config
5555
var k8sClient client.Client
56+
var k8sControllerClient client.Client
5657
var k8sLimitedClient client.Client
5758
var testEnv *envtest.Environment
5859
var ctx context.Context
@@ -110,6 +111,15 @@ var _ = BeforeSuite(func() {
110111
Expect(err).NotTo(HaveOccurred())
111112
Expect(k8sClient).NotTo(BeNil())
112113

114+
// configure a client that impersonates as the AppWrapper operator
115+
ctrlUserName := "appwrapper-controller"
116+
ctrlCfg := *cfg
117+
ctrlCfg.Impersonate = rest.ImpersonationConfig{UserName: ctrlUserName, Groups: []string{"system:masters"}}
118+
_, err = testEnv.AddUser(envtest.User{Name: ctrlUserName}, &ctrlCfg)
119+
Expect(err).NotTo(HaveOccurred())
120+
k8sControllerClient, err = client.New(&ctrlCfg, client.Options{Scheme: scheme})
121+
Expect(err).NotTo(HaveOccurred())
122+
113123
// configure a restricted rbac user who can create AppWrappers and Pods but not Deployments
114124
limitedUserName := "limited-user"
115125
limitedCfg := *cfg
@@ -151,7 +161,7 @@ var _ = BeforeSuite(func() {
151161
})
152162
Expect(err).NotTo(HaveOccurred())
153163

154-
awConfig := AppWrapperConfig{ManageJobsWithoutQueueName: true}
164+
awConfig := AppWrapperConfig{ManageJobsWithoutQueueName: true, ServiceAccountName: ctrlUserName}
155165
err = (&AppWrapperWebhook{Config: &awConfig}).SetupWebhookWithManager(mgr)
156166
Expect(err).NotTo(HaveOccurred())
157167

0 commit comments

Comments
 (0)