Skip to content
This repository was archived by the owner on Jun 26, 2023. It is now read-only.

Commit 6f44c1c

Browse files
authored
Merge pull request #1115 from yiqigao217/v0.5
Cherrypick: Handle source object overwriting conflict
2 parents 6463d86 + e907658 commit 6f44c1c

File tree

7 files changed

+255
-6
lines changed

7 files changed

+255
-6
lines changed

incubator/hnc/internal/forest/namespaceobjects.go

+8
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ func (ns *Namespace) GetPropagatedObjects(gvk schema.GroupVersionKind) []*unstru
6363
return o
6464
}
6565

66+
// GetPropagatingObjects returns all the source objects to be propagated into the
67+
// descendants of this namespace.
68+
// TODO update this function to reflect the changes introduced by the future HNC
69+
// exceptions implementation.
70+
func (ns *Namespace) GetPropagatingObjects(gvk schema.GroupVersionKind) []*unstructured.Unstructured {
71+
return append(ns.GetPropagatedObjects(gvk), ns.GetOriginalObjects(gvk)...)
72+
}
73+
6674
// GetSource returns the original copy in the ancestors if it exists.
6775
// Otherwise, return nil.
6876
func (ns *Namespace) GetSource(gvk schema.GroupVersionKind, name string) *unstructured.Unstructured {

incubator/hnc/internal/reconcilers/object.go

+19-5
Original file line numberDiff line numberDiff line change
@@ -325,21 +325,33 @@ func (r *ObjectReconciler) syncObject(ctx context.Context, log logr.Logger, inst
325325
return actionNop, nil
326326
}
327327

328-
// This object is the source if it doesn't have the "api.LabelInheritedFrom" label.
329-
if !hasPropagatedLabel(inst) {
328+
// This object is a propagated copy if it has "api.LabelInheritedFrom" label.
329+
if hasPropagatedLabel(inst) {
330+
return r.syncPropagated(ctx, log, inst)
331+
}
332+
333+
// Find the source object of the same name in the ancestors from top down to
334+
// see if there's a conflicting source.
335+
srcInst := r.Forest.Get(inst.GetNamespace()).GetSource(r.GVK, inst.GetName())
336+
337+
// The object is a source without conflict if a copy of the source is not
338+
// found in the forest or itself is found.
339+
if srcInst == nil || srcInst.GetNamespace() == inst.GetNamespace() {
330340
r.syncSource(ctx, log, inst)
331341
// No action needs to take on source objects.
332342
return actionNop, nil
333343
}
334344

335-
// This object is a propagated copy.
345+
// Since there's a conflict that another source with the same name is found in
346+
// the ancestors, this instance will be treated as propagated objects and will
347+
// be overwritten by the source in the ancestor.
336348
return r.syncPropagated(ctx, log, inst)
337349
}
338350

339351
// syncPropagated will determine whether to delete the obsolete copy or overwrite it with the source.
340352
// Or do nothing if it remains the same as the source object.
341353
func (r *ObjectReconciler) syncPropagated(ctx context.Context, log logr.Logger, inst *unstructured.Unstructured) (syncAction, *unstructured.Unstructured) {
342-
// Find a source object of the same name in any of the ancestores.
354+
// Find the source object of the same name in the ancestors from top down.
343355
srcInst := r.Forest.Get(inst.GetNamespace()).GetSource(r.GVK, inst.GetName())
344356

345357
// If no source object exists, delete this object. This can happen when the source was deleted by
@@ -354,7 +366,9 @@ func (r *ObjectReconciler) syncPropagated(ctx context.Context, log logr.Logger,
354366
// If the copy does not exist, or is different from the source, return the write action and the
355367
// source instance. Note that DeepEqual could return `true` even if the object doesn't exist if
356368
// the source object is trivial (e.g. a completely empty ConfigMap).
357-
if !exists || !reflect.DeepEqual(object.Canonical(inst), object.Canonical(srcInst)) {
369+
if !exists ||
370+
!reflect.DeepEqual(object.Canonical(inst), object.Canonical(srcInst)) ||
371+
inst.GetLabels()[api.LabelInheritedFrom] != srcInst.GetNamespace() {
358372
metadata.SetLabel(inst, api.LabelInheritedFrom, srcInst.GetNamespace())
359373
return actionWrite, srcInst
360374
}

incubator/hnc/internal/reconcilers/object_test.go

+19
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,25 @@ var _ = Describe("Secret", func() {
134134
Eventually(isModified(ctx, bazName, "foo-role")).Should(BeTrue())
135135
})
136136

137+
It("should overwrite the conflicting source in the descedants", func() {
138+
setParent(ctx, barName, fooName)
139+
setParent(ctx, bazName, barName)
140+
Eventually(hasObject(ctx, "Role", barName, "bar-role")).Should(BeTrue())
141+
Eventually(hasObject(ctx, "Role", bazName, "bar-role")).Should(BeTrue())
142+
Expect(objectInheritedFrom(ctx, "Role", bazName, "bar-role")).Should(Equal(barName))
143+
144+
makeObject(ctx, "Role", fooName, "bar-role")
145+
// Add a 500-millisecond gap here to allow updating the cached bar-roles in bar
146+
// and baz namespaces. Without this, even having 20 seconds in the "Eventually()"
147+
// funcs below, the test failed with timeout. Guess the reason is that it's
148+
// constantly getting the cached object.
149+
time.Sleep(500 * time.Millisecond)
150+
Eventually(hasObject(ctx, "Role", bazName, "bar-role")).Should(BeTrue())
151+
Eventually(objectInheritedFrom(ctx, "Role", bazName, "bar-role")).Should(Equal(fooName))
152+
Eventually(hasObject(ctx, "Role", barName, "bar-role")).Should(BeTrue())
153+
Eventually(objectInheritedFrom(ctx, "Role", barName, "bar-role")).Should(Equal(fooName))
154+
})
155+
137156
It("should have deletions propagated after crit conditions are removed", func() {
138157
// Create tree: bar -> foo (root) and make sure foo-role is propagated
139158
setParent(ctx, barName, fooName)

incubator/hnc/internal/validators/hierarchy.go

+52
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strings"
78

89
"github.com/go-logr/logr"
910
admissionv1beta1 "k8s.io/api/admission/v1beta1"
@@ -12,6 +13,7 @@ import (
1213
corev1 "k8s.io/api/core/v1"
1314
"k8s.io/apimachinery/pkg/api/errors"
1415
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
"k8s.io/apimachinery/pkg/runtime/schema"
1517
"k8s.io/apimachinery/pkg/types"
1618
"sigs.k8s.io/controller-runtime/pkg/client"
1719
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -210,9 +212,59 @@ func (v *Hierarchy) checkParent(ns, curParent, newParent *forest.Namespace) admi
210212
return deny(metav1.StatusReasonConflict, "Illegal parent: "+reason)
211213
}
212214

215+
// Prevent overwriting source objects in the descendants after the hierarchy change.
216+
if co := v.getConflictingObjects(newParent, ns); len(co) != 0 {
217+
msg := "Cannot update hierarchy because it would overwrite the following object(s):\n"
218+
msg += " * " + strings.Join(co, "\n * ") + "\n"
219+
msg += "To fix this, please rename or remove the conflicting objects first."
220+
return deny(metav1.StatusReasonConflict, msg)
221+
}
222+
213223
return allow("")
214224
}
215225

226+
// getConflictingObjects returns a list of namespaced objects if there's any conflict.
227+
func (v *Hierarchy) getConflictingObjects(newParent, ns *forest.Namespace) []string {
228+
// If the new parent is nil, early exit since it's impossible to introduce
229+
// new naming conflicts.
230+
if newParent == nil {
231+
return nil
232+
}
233+
// Traverse all the types with 'Propagate' mode to find any conflicts.
234+
conflicts := []string{}
235+
for _, t := range v.Forest.GetTypeSyncers() {
236+
if t.GetMode() == api.Propagate {
237+
conflicts = append(conflicts, v.getConflictingObjectsOfType(t.GetGVK(), newParent, ns)...)
238+
}
239+
}
240+
return conflicts
241+
}
242+
243+
// getConflictingObjectsOfType returns a list of namespaced objects if there's
244+
// any conflict between the new ancestors and the descendants.
245+
func (v *Hierarchy) getConflictingObjectsOfType(gvk schema.GroupVersionKind, newParent, ns *forest.Namespace) []string {
246+
// Get all the source objects in the new ancestors that would be propagated
247+
// into the descendants.
248+
newAnsSrcObjs := make(map[string]bool)
249+
for _, o := range newParent.GetPropagatingObjects(gvk) {
250+
newAnsSrcObjs[o.GetName()] = true
251+
}
252+
253+
// Look in the descendants to find if there's any conflict.
254+
cos := []string{}
255+
dnses := append(ns.DescendantNames(), ns.Name())
256+
for _, dns := range dnses {
257+
for _, o := range v.Forest.Get(dns).GetOriginalObjects(gvk) {
258+
if newAnsSrcObjs[o.GetName()] {
259+
co := fmt.Sprintf("Namespace %q: %s (%v)", dns, o.GetName(), gvk)
260+
cos = append(cos, co)
261+
}
262+
}
263+
}
264+
265+
return cos
266+
}
267+
216268
type serverCheckType int
217269

218270
const (

incubator/hnc/internal/validators/hierarchy_test.go

+53
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
. "github.com/onsi/gomega"
88
authn "k8s.io/api/authentication/v1"
99
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/runtime/schema"
1011
"sigs.k8s.io/controller-runtime/pkg/log/zap"
12+
"sigs.k8s.io/multi-tenancy/incubator/hnc/internal/reconcilers"
1113

1214
api "sigs.k8s.io/multi-tenancy/incubator/hnc/api/v1alpha1"
1315
"sigs.k8s.io/multi-tenancy/incubator/hnc/internal/foresttest"
@@ -96,6 +98,57 @@ func TestChangeParentOnManagedBy(t *testing.T) {
9698
}
9799
}
98100

101+
func TestChangeParentWithConflict(t *testing.T) {
102+
f := foresttest.Create("-a-c") // a <- b; c <- d
103+
104+
// Set secret to "Propagate" mode. (Use Secret in this test because the test
105+
// forest doesn't have Role or RoleBinding by default either. We can also create
106+
// secret by existing `createSecret()` function.)
107+
or := &reconcilers.ObjectReconciler{
108+
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
109+
Mode: api.Propagate,
110+
}
111+
f.AddTypeSyncer(or)
112+
113+
// Create secrets with the same name in namespace 'a' and 'd'.
114+
createSecret("conflict", "a", f)
115+
createSecret("conflict", "d", f)
116+
117+
h := &Hierarchy{Forest: f}
118+
l := zap.Logger(false)
119+
120+
tests := []struct {
121+
name string
122+
nnm string
123+
pnm string
124+
fail bool
125+
}{
126+
{name: "conflict in itself and the new parent", nnm: "a", pnm: "d", fail: true},
127+
{name: "conflict in itself and a new ancestor (not the parent)", nnm: "d", pnm: "b", fail: true},
128+
{name: "ok: no conflict in ancestors", nnm: "a", pnm: "c"},
129+
{name: "conflict in subtree leaf and the new parent", nnm: "c", pnm: "a", fail: true},
130+
{name: "conflict in subtree leaf and a new ancestor (not the parent)", nnm: "c", pnm: "b", fail: true},
131+
{name: "ok: set a namespace as root", nnm: "d", pnm: ""},
132+
}
133+
for _, tc := range tests {
134+
t.Run(tc.name, func(t *testing.T) {
135+
// Setup
136+
g := NewGomegaWithT(t)
137+
hc := &api.HierarchyConfiguration{Spec: api.HierarchyConfigurationSpec{Parent: tc.pnm}}
138+
hc.ObjectMeta.Name = api.Singleton
139+
hc.ObjectMeta.Namespace = tc.nnm
140+
req := &request{hc: hc}
141+
142+
// Test
143+
got := h.handle(context.Background(), l, req)
144+
145+
// Report
146+
logResult(t, got.AdmissionResponse.Result)
147+
g.Expect(got.AdmissionResponse.Allowed).ShouldNot(Equal(tc.fail))
148+
})
149+
}
150+
}
151+
99152
func TestAuthz(t *testing.T) {
100153
tests := []struct {
101154
name string

incubator/hnc/internal/validators/object.go

+36-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package validators
22

33
import (
44
"context"
5+
"fmt"
56
"reflect"
7+
"strings"
68

79
"github.com/go-logr/logr"
810
admissionv1beta1 "k8s.io/api/admission/v1beta1"
@@ -106,8 +108,14 @@ func (o *Object) handle(ctx context.Context, log logr.Logger, op admissionv1beta
106108
oldSource, oldInherited := metadata.GetLabel(oldInst, api.LabelInheritedFrom)
107109
newSource, newInherited := metadata.GetLabel(inst, api.LabelInheritedFrom)
108110

109-
// If the object wasn't and isn't inherited, it's none of our business.
111+
// If the object wasn't and isn't inherited, we will check to see if the
112+
// source can be created without causing any conflict.
110113
if !oldInherited && !newInherited {
114+
if yes, dnses := o.hasConflict(inst); yes {
115+
dnsesStr := strings.Join(dnses, "\n * ")
116+
msg := fmt.Sprintf("\nCannot create %q (%s) in namespace %q because it would overwrite objects in the following descendant namespace(s):\n * %s\nTo fix this, choose a different name for the object, or remove the conflicting objects from the above namespaces.", inst.GetName(), inst.GroupVersionKind(), inst.GetNamespace(), dnsesStr)
117+
return deny(metav1.StatusReasonConflict, msg)
118+
}
111119
return allow("source object")
112120
}
113121

@@ -146,6 +154,33 @@ func (o *Object) handle(ctx context.Context, log logr.Logger, op admissionv1beta
146154
return deny(metav1.StatusReasonInternalError, "unknown operation: "+string(op))
147155
}
148156

157+
// hasConflict checks if there's any conflicting objects in the descendants. Returns
158+
// true and a list of conflicting descendants, if yes.
159+
func (o *Object) hasConflict(inst *unstructured.Unstructured) (bool, []string) {
160+
o.Forest.Lock()
161+
defer o.Forest.Unlock()
162+
163+
// If the instance is empty (for a delete operation) or it's not namespace-scoped,
164+
// there must be no conflict.
165+
if inst == nil || inst.GetNamespace() == "" {
166+
return false, nil
167+
}
168+
169+
nm := inst.GetName()
170+
gvk := inst.GroupVersionKind()
171+
descs := o.Forest.Get(inst.GetNamespace()).DescendantNames()
172+
conflicts := []string{}
173+
174+
// Get a list of conflicting descendants if there's any.
175+
for _, desc := range descs {
176+
if o.Forest.Get(desc).HasOriginalObject(gvk, nm) {
177+
conflicts = append(conflicts, desc)
178+
}
179+
}
180+
181+
return len(conflicts) != 0, conflicts
182+
}
183+
149184
func (o *Object) InjectClient(c client.Client) error {
150185
o.client = c
151186
return nil

incubator/hnc/internal/validators/object_test.go

+68
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"k8s.io/apimachinery/pkg/runtime/schema"
1212
"sigs.k8s.io/controller-runtime/pkg/log/zap"
1313
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
14+
"sigs.k8s.io/multi-tenancy/incubator/hnc/internal/foresttest"
1415

1516
api "sigs.k8s.io/multi-tenancy/incubator/hnc/api/v1alpha1"
1617
"sigs.k8s.io/multi-tenancy/incubator/hnc/internal/forest"
@@ -332,3 +333,70 @@ func TestUserChanges(t *testing.T) {
332333
})
333334
}
334335
}
336+
337+
func TestCreatingConflictSource(t *testing.T) {
338+
tests := []struct {
339+
name string
340+
forest string
341+
conflictInstName string
342+
conflictNamespace string
343+
newInstName string
344+
newInstNamespace string
345+
fail bool
346+
}{{
347+
name: "Deny creation of source objects with conflict in child",
348+
forest: "-a",
349+
conflictInstName: "secret-b",
350+
conflictNamespace: "b",
351+
newInstName: "secret-b",
352+
newInstNamespace: "a",
353+
fail: true,
354+
}, {
355+
name: "Deny creation of source objects with conflict in grandchild",
356+
forest: "-ab",
357+
conflictInstName: "secret-c",
358+
conflictNamespace: "c",
359+
newInstName: "secret-c",
360+
newInstNamespace: "a",
361+
fail: true,
362+
}, {
363+
name: "Allow creation of source objects with no conflict",
364+
newInstName: "secret-a",
365+
newInstNamespace: "a",
366+
}}
367+
368+
for _, tc := range tests {
369+
t.Run(tc.name, func(t *testing.T) {
370+
// Setup
371+
g := NewGomegaWithT(t)
372+
f := foresttest.Create(tc.forest)
373+
createSecret(tc.conflictInstName, tc.conflictNamespace, f)
374+
o := &Object{Forest: f}
375+
l := zap.Logger(false)
376+
op := admissionv1beta1.Create
377+
inst := &unstructured.Unstructured{}
378+
inst.SetName(tc.newInstName)
379+
inst.SetNamespace(tc.newInstNamespace)
380+
inst.SetGroupVersionKind(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"})
381+
// Test
382+
got := o.handle(context.Background(), l, op, inst, &unstructured.Unstructured{})
383+
// Report
384+
code := got.AdmissionResponse.Result.Code
385+
reason := got.AdmissionResponse.Result.Reason
386+
msg := got.AdmissionResponse.Result.Message
387+
t.Logf("Got code %d, reason %q, message %q", code, reason, msg)
388+
g.Expect(got.AdmissionResponse.Allowed).ShouldNot(Equal(tc.fail))
389+
})
390+
}
391+
}
392+
393+
func createSecret(nm, nsn string, f *forest.Forest) {
394+
if nm == "" || nsn == "" {
395+
return
396+
}
397+
inst := &unstructured.Unstructured{}
398+
inst.SetName(nm)
399+
inst.SetNamespace(nsn)
400+
inst.SetGroupVersionKind(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"})
401+
f.Get(nsn).SetOriginalObject(inst)
402+
}

0 commit comments

Comments
 (0)