Skip to content

Commit a964673

Browse files
authored
Unproject workloads no longer targeted by a ServiceBinding (#348)
When a previously bound workload is no longer targeted by a ServiceBinding it is now unprojected. Previously, the projection was orphaned as the controller only managed workload matching the reference on the ServiceBinding resource. This could happen for a few different reasons: - the name of the referenced workload was updated on the ServiceBinding - the label selector matching the workload was updated on the ServiceBinding - the labels on the workload were updated to no longer match the selector on the ServiceBinding. In order to find previously bound workloads, we now list all resources matching the workload refs GVK in the namespace. That list is filtered to resources that are currently projected, or match the new criteria. All matching workloads are unprojected, but only resources matching the current ref are re-projected. To avoid a much larger search area, the workloadRef apiVersion and kind fields are now immutable. Users who need to update either of these values will need to delete the ServiceBinding and create a new resource with the desired values. Signed-off-by: Scott Andrews <andrewssc@vmware.com>
1 parent d12e4ec commit a964673

15 files changed

Lines changed: 759 additions & 163 deletions

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ jobs:
342342
uses: actions/checkout@v4
343343
with:
344344
repository: servicebinding/conformance.git
345-
ref: v0.3.1
345+
ref: v0.3.2
346346
fetch-depth: 1
347347
path: conformance-tests
348348

apis/v1beta1/servicebinding_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import (
2020
"testing"
2121

2222
"github.com/google/go-cmp/cmp"
23+
corev1 "k8s.io/api/core/v1"
2324
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
25+
"k8s.io/apimachinery/pkg/runtime"
2426
"k8s.io/apimachinery/pkg/util/validation/field"
2527
)
2628

@@ -273,3 +275,166 @@ func TestServiceBindingValidate(t *testing.T) {
273275
})
274276
}
275277
}
278+
279+
func TestServiceBindingValidate_Immutable(t *testing.T) {
280+
tests := []struct {
281+
name string
282+
seed *ServiceBinding
283+
old runtime.Object
284+
expected field.ErrorList
285+
}{
286+
{
287+
name: "allow update workload name",
288+
seed: &ServiceBinding{
289+
Spec: ServiceBindingSpec{
290+
Name: "my-binding",
291+
Service: ServiceBindingServiceReference{
292+
APIVersion: "v1",
293+
Kind: "Secret",
294+
Name: "my-service",
295+
},
296+
Workload: ServiceBindingWorkloadReference{
297+
APIVersion: "apps/v1",
298+
Kind: "Deloyment",
299+
Name: "new-workload",
300+
},
301+
},
302+
},
303+
old: &ServiceBinding{
304+
Spec: ServiceBindingSpec{
305+
Name: "my-binding",
306+
Service: ServiceBindingServiceReference{
307+
APIVersion: "v1",
308+
Kind: "Secret",
309+
Name: "my-service",
310+
},
311+
Workload: ServiceBindingWorkloadReference{
312+
APIVersion: "apps/v1",
313+
Kind: "Deloyment",
314+
Name: "old-workload",
315+
},
316+
},
317+
},
318+
expected: field.ErrorList{},
319+
},
320+
{
321+
name: "reject update workload apiVersion",
322+
seed: &ServiceBinding{
323+
Spec: ServiceBindingSpec{
324+
Name: "my-binding",
325+
Service: ServiceBindingServiceReference{
326+
APIVersion: "v1",
327+
Kind: "Secret",
328+
Name: "my-service",
329+
},
330+
Workload: ServiceBindingWorkloadReference{
331+
APIVersion: "apps/v1",
332+
Kind: "Deloyment",
333+
Name: "my-workload",
334+
},
335+
},
336+
},
337+
old: &ServiceBinding{
338+
Spec: ServiceBindingSpec{
339+
Name: "my-binding",
340+
Service: ServiceBindingServiceReference{
341+
APIVersion: "v1",
342+
Kind: "Secret",
343+
Name: "my-service",
344+
},
345+
Workload: ServiceBindingWorkloadReference{
346+
APIVersion: "extensions/v1beta1",
347+
Kind: "Deloyment",
348+
Name: "my-workload",
349+
},
350+
},
351+
},
352+
expected: field.ErrorList{
353+
{
354+
Type: field.ErrorTypeForbidden,
355+
Field: "spec.workload.apiVersion",
356+
Detail: "Workload apiVersion is immutable. Delete and recreate the ServiceBinding to update.",
357+
BadValue: "",
358+
},
359+
},
360+
},
361+
{
362+
name: "reject update workload kind",
363+
seed: &ServiceBinding{
364+
Spec: ServiceBindingSpec{
365+
Name: "my-binding",
366+
Service: ServiceBindingServiceReference{
367+
APIVersion: "v1",
368+
Kind: "Secret",
369+
Name: "my-service",
370+
},
371+
Workload: ServiceBindingWorkloadReference{
372+
APIVersion: "apps/v1",
373+
Kind: "Deloyment",
374+
Name: "my-workload",
375+
},
376+
},
377+
},
378+
old: &ServiceBinding{
379+
Spec: ServiceBindingSpec{
380+
Name: "my-binding",
381+
Service: ServiceBindingServiceReference{
382+
APIVersion: "v1",
383+
Kind: "Secret",
384+
Name: "my-service",
385+
},
386+
Workload: ServiceBindingWorkloadReference{
387+
APIVersion: "apps/v1",
388+
Kind: "StatefulSet",
389+
Name: "my-workload",
390+
},
391+
},
392+
},
393+
expected: field.ErrorList{
394+
{
395+
Type: field.ErrorTypeForbidden,
396+
Field: "spec.workload.kind",
397+
Detail: "Workload kind is immutable. Delete and recreate the ServiceBinding to update.",
398+
BadValue: "",
399+
},
400+
},
401+
},
402+
{
403+
name: "unkonwn old object",
404+
seed: &ServiceBinding{
405+
Spec: ServiceBindingSpec{
406+
Name: "my-binding",
407+
Service: ServiceBindingServiceReference{
408+
APIVersion: "v1",
409+
Kind: "Secret",
410+
Name: "my-service",
411+
},
412+
Workload: ServiceBindingWorkloadReference{
413+
APIVersion: "apps/v1",
414+
Kind: "Deloyment",
415+
Name: "new-workload",
416+
},
417+
},
418+
},
419+
old: &corev1.Pod{},
420+
expected: field.ErrorList{
421+
{
422+
Type: field.ErrorTypeInternal,
423+
Field: "<nil>",
424+
Detail: "old object must be of type v1beta1.ServiceBinding",
425+
},
426+
},
427+
},
428+
}
429+
430+
for _, c := range tests {
431+
t.Run(c.name, func(t *testing.T) {
432+
expectedErr := c.expected.ToAggregate()
433+
434+
_, actualUpdateErr := c.seed.ValidateUpdate(c.old)
435+
if diff := cmp.Diff(expectedErr, actualUpdateErr); diff != "" {
436+
t.Errorf("ValidateCreate (-expected, +actual): %s", diff)
437+
}
438+
})
439+
}
440+
}

apis/v1beta1/servicebinding_webhook.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"fmt"
21+
2022
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2123
"k8s.io/apimachinery/pkg/runtime"
2224
"k8s.io/apimachinery/pkg/util/validation/field"
2325
ctrl "sigs.k8s.io/controller-runtime"
26+
"sigs.k8s.io/controller-runtime/pkg/conversion"
2427
"sigs.k8s.io/controller-runtime/pkg/webhook"
2528
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
2629
)
@@ -52,9 +55,41 @@ func (r *ServiceBinding) ValidateCreate() (admission.Warnings, error) {
5255

5356
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
5457
func (r *ServiceBinding) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
55-
// TODO(user): check for immutable fields, if any
5658
r.Default()
57-
return nil, r.validate().ToAggregate()
59+
60+
errs := field.ErrorList{}
61+
62+
// check immutable fields
63+
var ro *ServiceBinding
64+
if o, ok := old.(*ServiceBinding); ok {
65+
ro = o
66+
} else if o, ok := old.(conversion.Convertible); ok {
67+
ro = &ServiceBinding{}
68+
if err := o.ConvertTo(ro); err != nil {
69+
return nil, err
70+
}
71+
} else {
72+
errs = append(errs,
73+
field.InternalError(nil, fmt.Errorf("old object must be of type v1beta1.ServiceBinding")),
74+
)
75+
}
76+
if len(errs) == 0 {
77+
if r.Spec.Workload.APIVersion != ro.Spec.Workload.APIVersion {
78+
errs = append(errs,
79+
field.Forbidden(field.NewPath("spec", "workload", "apiVersion"), "Workload apiVersion is immutable. Delete and recreate the ServiceBinding to update."),
80+
)
81+
}
82+
if r.Spec.Workload.Kind != ro.Spec.Workload.Kind {
83+
errs = append(errs,
84+
field.Forbidden(field.NewPath("spec", "workload", "kind"), "Workload kind is immutable. Delete and recreate the ServiceBinding to update."),
85+
)
86+
}
87+
}
88+
89+
// validate new object
90+
errs = append(errs, r.validate()...)
91+
92+
return nil, errs.ToAggregate()
5893
}
5994

6095
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type

controllers/servicebinding_controller.go

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import (
2222

2323
"github.com/vmware-labs/reconciler-runtime/apis"
2424
"github.com/vmware-labs/reconciler-runtime/reconcilers"
25-
corev1 "k8s.io/api/core/v1"
25+
"github.com/vmware-labs/reconciler-runtime/tracker"
2626
apierrs "k8s.io/apimachinery/pkg/api/errors"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2728
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2829
"k8s.io/apimachinery/pkg/runtime"
30+
"k8s.io/apimachinery/pkg/runtime/schema"
2931
ctlr "sigs.k8s.io/controller-runtime"
3032
"sigs.k8s.io/controller-runtime/pkg/builder"
3133
"sigs.k8s.io/controller-runtime/pkg/handler"
@@ -63,13 +65,7 @@ func ResolveBindingSecret(hooks lifecycle.ServiceBindingHooks) reconcilers.SubRe
6365
Sync: func(ctx context.Context, resource *servicebindingv1beta1.ServiceBinding) error {
6466
c := reconcilers.RetrieveConfigOrDie(ctx)
6567

66-
ref := corev1.ObjectReference{
67-
APIVersion: resource.Spec.Service.APIVersion,
68-
Kind: resource.Spec.Service.Kind,
69-
Namespace: resource.Namespace,
70-
Name: resource.Spec.Service.Name,
71-
}
72-
secretName, err := hooks.GetResolver(TrackingClient(c)).LookupBindingSecret(ctx, ref)
68+
secretName, err := hooks.GetResolver(TrackingClient(c)).LookupBindingSecret(ctx, resource)
7369
if err != nil {
7470
if apierrs.IsNotFound(err) {
7571
// leave Unknown, the provisioned service may be created shortly
@@ -122,20 +118,26 @@ func ResolveWorkloads(hooks lifecycle.ServiceBindingHooks) reconcilers.SubReconc
122118
SyncWithResult: func(ctx context.Context, resource *servicebindingv1beta1.ServiceBinding) (reconcile.Result, error) {
123119
c := reconcilers.RetrieveConfigOrDie(ctx)
124120

125-
ref := corev1.ObjectReference{
126-
APIVersion: resource.Spec.Workload.APIVersion,
127-
Kind: resource.Spec.Workload.Kind,
128-
Namespace: resource.Namespace,
129-
Name: resource.Spec.Workload.Name,
121+
trackingRef := tracker.Reference{
122+
APIGroup: schema.FromAPIVersionAndKind(resource.Spec.Workload.APIVersion, "").Group,
123+
Kind: resource.Spec.Workload.Kind,
124+
Namespace: resource.Namespace,
130125
}
131-
workloads, err := hooks.GetResolver(TrackingClient(c)).LookupWorkloads(ctx, ref, resource.Spec.Workload.Selector)
132-
if err != nil {
133-
if apierrs.IsNotFound(err) {
134-
// leave Unknown, the workload may be created shortly
135-
resource.GetConditionManager().MarkUnknown(servicebindingv1beta1.ServiceBindingConditionWorkloadProjected, "WorkloadNotFound", "the workload was not found")
136-
// TODO use track rather than requeue
137-
return reconcile.Result{Requeue: true}, nil
126+
if resource.Spec.Workload.Name != "" {
127+
trackingRef.Name = resource.Spec.Workload.Name
128+
}
129+
if resource.Spec.Workload.Selector != nil {
130+
selector, err := metav1.LabelSelectorAsSelector(resource.Spec.Workload.Selector)
131+
if err != nil {
132+
// should never get here
133+
return reconcile.Result{}, err
138134
}
135+
trackingRef.Selector = selector
136+
}
137+
c.Tracker.TrackReference(trackingRef, resource)
138+
139+
workloads, err := hooks.GetResolver(c).LookupWorkloads(ctx, resource)
140+
if err != nil {
139141
if apierrs.IsForbidden(err) {
140142
// set False, the operator needs to give access to the resource
141143
// see https://servicebinding.io/spec/core/1.0.0/#considerations-for-role-based-access-control-rbac-1
@@ -144,12 +146,24 @@ func ResolveWorkloads(hooks lifecycle.ServiceBindingHooks) reconcilers.SubReconc
144146
} else {
145147
resource.GetConditionManager().MarkFalse(servicebindingv1beta1.ServiceBindingConditionWorkloadProjected, "WorkloadForbidden", "the controller does not have permission to get the workload")
146148
}
147-
// TODO use track rather than requeue
148-
return reconcile.Result{Requeue: true}, nil
149+
return reconcile.Result{}, nil
149150
}
150151
// TODO handle other err cases
151152
return reconcile.Result{}, err
152153
}
154+
if resource.Spec.Workload.Name != "" {
155+
found := false
156+
for _, workload := range workloads {
157+
if workload.(metav1.Object).GetName() == resource.Spec.Workload.Name {
158+
found = true
159+
break
160+
}
161+
}
162+
if !found {
163+
// leave Unknown, the workload may be created shortly
164+
resource.GetConditionManager().MarkUnknown(servicebindingv1beta1.ServiceBindingConditionWorkloadProjected, "WorkloadNotFound", "the workload was not found")
165+
}
166+
}
153167

154168
StashWorkloads(ctx, workloads)
155169

0 commit comments

Comments
 (0)