Skip to content

Commit 496d542

Browse files
committed
wire in pooling
1 parent 2d39682 commit 496d542

10 files changed

Lines changed: 294 additions & 50 deletions

File tree

v2/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ v2/
3333
(`schema.source: CRD`, single served version, no conversion webhook) onto the
3434
consumer, and report `Ready` + `boundAPIs`.
3535
- A dynamic per-GVR syncer copies instance **spec up** (server-side apply with
36-
ownership markers + a finalizer) and **status down**, with `conflictPolicy:
37-
Fail` surfaced as an Event (+ best-effort object condition).
36+
ownership markers + a finalizer) and **status down**. `conflictPolicy: Fail`
37+
refuses a foreign provider target (Event + conflict annotation, counted on the
38+
binding's `conflictCount` + `Conflicts` condition); `conflictPolicy: Adopt`
39+
takes over an *un-owned* provider object (never one owned by another binding).
40+
- The Connection **re-discovers** exported APIs periodically, so a CRD labeled
41+
`exported` after connect is picked up and its binding goes Ready.
3842
- The provider side is the **multicluster-runtime engaged cluster** for each
3943
Connection: writes go through its client, fresh reads through its API reader,
4044
and status/drift events arrive via a **watch on its cache** (event-driven, not
@@ -50,8 +54,9 @@ v2/
5054
the provider — so `kubectl delete -f bundle.yaml` is order-don't-care.
5155

5256
Known POC simplifications (tracked against the proposal): `schema.source:
53-
OpenAPI`, `conflictPolicy: Adopt`, related resources, `autoBind` and the
54-
provider `Lease` are not implemented yet.
57+
OpenAPI`, related resources, `pullPolicy: All`/`None`, `updatePolicy: Always`,
58+
`autoBind`, `deletion-policy: Orphan` and the provider `Lease` are not
59+
implemented yet.
5560

5661
## Build
5762

v2/konnector/engine/binding/cleanup.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ func (b *base) cleanup(ctx context.Context, obj corev1alpha1.BindingAccessor, na
8484
return nil
8585
}
8686

87+
// countConflicts counts consumer instances of gvr in scope that the syncer
88+
// marked as conflicting (the conflict annotation).
89+
func (b *base) countConflicts(ctx context.Context, gvr schema.GroupVersionResource, namespace string) int32 {
90+
ri := b.dyn.Resource(gvr)
91+
var lister dynamicLister = ri
92+
if namespace != "" {
93+
lister = ri.Namespace(namespace)
94+
}
95+
list, err := lister.List(ctx, metav1.ListOptions{})
96+
if err != nil {
97+
return 0
98+
}
99+
var n int32
100+
for i := range list.Items {
101+
if list.Items[i].GetAnnotations()[corev1alpha1.AnnotationConflict] != "" {
102+
n++
103+
}
104+
}
105+
return n
106+
}
107+
87108
// drainInstances deletes provider copies and releases the syncer finalizer on
88109
// every consumer instance of gvr in scope.
89110
func (b *base) drainInstances(ctx context.Context, gvr schema.GroupVersionResource, namespace string, providerClient client.Client, localUID string) error {

v2/konnector/engine/binding/reconciler.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"fmt"
2727
"sort"
2828
"strings"
29+
"time"
2930

3031
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
3132
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -49,9 +50,19 @@ type base struct {
4950
client client.Client
5051
dyn dynamic.Interface
5152
scheme *runtime.Scheme
53+
resync time.Duration
5254
newClient func(ctx context.Context, conn *corev1alpha1.Connection) (client.Client, error)
5355
}
5456

57+
// resyncInterval is how often a Ready binding re-reconciles to refresh
58+
// conflictCount (conflicts are detected by the syncer, not via binding events).
59+
func (b *base) resyncInterval() time.Duration {
60+
if b.resync > 0 {
61+
return b.resync
62+
}
63+
return 30 * time.Second
64+
}
65+
5566
func (b *base) providerClient(ctx context.Context, conn *corev1alpha1.Connection) (client.Client, error) {
5667
if b.newClient != nil {
5768
return b.newClient(ctx, conn)
@@ -60,7 +71,9 @@ func (b *base) providerClient(ctx context.Context, conn *corev1alpha1.Connection
6071
}
6172

6273
// reconcileAccessor drives a binding (cluster or namespaced) toward Ready.
63-
func (b *base) reconcileAccessor(ctx context.Context, obj corev1alpha1.BindingAccessor) error {
74+
// namespace scopes conflict counting: "" for a ClusterBinding (cluster-wide),
75+
// the Binding's namespace otherwise.
76+
func (b *base) reconcileAccessor(ctx context.Context, obj corev1alpha1.BindingAccessor, namespace string) error {
6477
spec := obj.BindingSpecP()
6578
status := obj.BindingStatusP()
6679

@@ -99,7 +112,12 @@ func (b *base) reconcileAccessor(ctx context.Context, obj corev1alpha1.BindingAc
99112
if err != nil {
100113
return fmt.Errorf("pulling CRD %q: %w", api.Name, err)
101114
}
102-
boundAPIs = append(boundAPIs, corev1alpha1.BoundAPI{Name: exported.Name, CRDHash: hash})
115+
// Count instances we refused to sync due to a foreign provider target.
116+
var conflicts int32
117+
if gvr, ok, gerr := b.gvrFor(ctx, api.Name); gerr == nil && ok {
118+
conflicts = b.countConflicts(ctx, gvr, namespace)
119+
}
120+
boundAPIs = append(boundAPIs, corev1alpha1.BoundAPI{Name: exported.Name, CRDHash: hash, ConflictCount: conflicts})
103121
}
104122
sort.Slice(boundAPIs, func(i, j int) bool { return boundAPIs[i].Name < boundAPIs[j].Name })
105123
status.BoundAPIs = boundAPIs
@@ -110,6 +128,17 @@ func (b *base) reconcileAccessor(ctx context.Context, obj corev1alpha1.BindingAc
110128
return nil
111129
}
112130

131+
var totalConflicts int32
132+
for _, ba := range boundAPIs {
133+
totalConflicts += ba.ConflictCount
134+
}
135+
if totalConflicts > 0 {
136+
apimeta.SetStatusCondition(&status.Conditions, condition(obj, corev1alpha1.ConditionConflicts, metav1.ConditionTrue, corev1alpha1.ReasonForeignObjectExists,
137+
fmt.Sprintf("%d object(s) skipped due to foreign ownership; see object Events", totalConflicts)))
138+
} else {
139+
apimeta.SetStatusCondition(&status.Conditions, condition(obj, corev1alpha1.ConditionConflicts, metav1.ConditionFalse, corev1alpha1.ReasonAsExpected, "no conflicts"))
140+
}
141+
113142
apimeta.SetStatusCondition(&status.Conditions, condition(obj, corev1alpha1.ConditionSynced, metav1.ConditionTrue, corev1alpha1.ReasonAsExpected, "all APIs installed"))
114143
setReady(status, obj, metav1.ConditionTrue, corev1alpha1.ReasonAsExpected, "binding ready")
115144
return nil
@@ -209,14 +238,20 @@ func setReady(status *corev1alpha1.BindingStatus, obj client.Object, s metav1.Co
209238
// ----- ClusterBinding -----
210239

211240
// ClusterReconciler reconciles ClusterBinding objects.
212-
type ClusterReconciler struct{ base }
241+
type ClusterReconciler struct {
242+
base
243+
// Resync is how often a Ready binding re-reconciles to refresh conflictCount
244+
// (0 = default 30s).
245+
Resync time.Duration
246+
}
213247

214248
// SetupWithManager registers the ClusterBinding reconciler. It also watches
215249
// Connection so bindings re-reconcile when their connection becomes Ready or
216250
// its exported APIs change (level-triggered).
217251
func (r *ClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
218252
r.base.client = mgr.GetClient()
219253
r.base.scheme = mgr.GetScheme()
254+
r.base.resync = r.Resync
220255
dyn, err := dynamic.NewForConfig(mgr.GetConfig())
221256
if err != nil {
222257
return err
@@ -264,25 +299,31 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
264299
return ctrl.Result{}, r.base.client.Update(ctx, cb)
265300
}
266301
orig := cb.DeepCopy()
267-
rerr := r.base.reconcileAccessor(ctx, cb)
302+
rerr := r.base.reconcileAccessor(ctx, cb, "")
268303
if !equalBindingStatus(&orig.Status, &cb.Status) {
269304
if err := r.base.client.Status().Update(ctx, cb); err != nil {
270305
return ctrl.Result{}, err
271306
}
272307
}
273-
return ctrl.Result{}, rerr
308+
return ctrl.Result{RequeueAfter: r.base.resyncInterval()}, rerr
274309
}
275310

276311
// ----- Binding (namespaced) -----
277312

278313
// NamespacedReconciler reconciles Binding objects.
279-
type NamespacedReconciler struct{ base }
314+
type NamespacedReconciler struct {
315+
base
316+
// Resync is how often a Ready binding re-reconciles to refresh conflictCount
317+
// (0 = default 30s).
318+
Resync time.Duration
319+
}
280320

281321
// SetupWithManager registers the Binding reconciler. It also watches Connection
282322
// so bindings re-reconcile when their connection becomes Ready (level-triggered).
283323
func (r *NamespacedReconciler) SetupWithManager(mgr ctrl.Manager) error {
284324
r.base.client = mgr.GetClient()
285325
r.base.scheme = mgr.GetScheme()
326+
r.base.resync = r.Resync
286327
dyn, err := dynamic.NewForConfig(mgr.GetConfig())
287328
if err != nil {
288329
return err
@@ -331,13 +372,13 @@ func (r *NamespacedReconciler) Reconcile(ctx context.Context, req ctrl.Request)
331372
return ctrl.Result{}, r.base.client.Update(ctx, b)
332373
}
333374
orig := b.DeepCopy()
334-
rerr := r.base.reconcileAccessor(ctx, b)
375+
rerr := r.base.reconcileAccessor(ctx, b, b.Namespace)
335376
if !equalBindingStatus(&orig.Status, &b.Status) {
336377
if err := r.base.client.Status().Update(ctx, b); err != nil {
337378
return ctrl.Result{}, err
338379
}
339380
}
340-
return ctrl.Result{}, rerr
381+
return ctrl.Result{RequeueAfter: r.base.resyncInterval()}, rerr
341382
}
342383

343384
func equalBindingStatus(a, b *corev1alpha1.BindingStatus) bool {

v2/konnector/engine/connection/reconciler.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ type Reconciler struct {
5252
// Connection. Overridable in tests; defaults to a fresh client from the
5353
// resolved kubeconfig.
5454
NewProviderClient func(ctx context.Context, conn *corev1alpha1.Connection) (client.Client, error)
55+
// DiscoveryResync is how often a Ready Connection re-discovers exported APIs
56+
// (so a CRD labeled exported after connect is picked up). 0 = default 30s.
57+
DiscoveryResync time.Duration
58+
}
59+
60+
func (r *Reconciler) discoveryResync() time.Duration {
61+
if r.DiscoveryResync > 0 {
62+
return r.DiscoveryResync
63+
}
64+
return 30 * time.Second
5565
}
5666

5767
// SetupWithManager registers the reconciler with the consumer manager. It also
@@ -126,7 +136,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
126136
return ctrl.Result{}, err
127137
}
128138
}
129-
return ctrl.Result{}, reconcileErr
139+
// Periodically re-discover so a CRD labeled exported after connect is picked
140+
// up (the binding watches the Connection and reacts to exportedAPIs changes).
141+
result := ctrl.Result{}
142+
if reconcileErr == nil {
143+
result.RequeueAfter = r.discoveryResync()
144+
}
145+
return result, reconcileErr
130146
}
131147

132148
func (r *Reconciler) reconcile(ctx context.Context, conn *corev1alpha1.Connection) error {

v2/konnector/engine/sync/resolve.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ type Resolution struct {
3939
Ready bool
4040
// ConnectionName is the Connection the binding references.
4141
ConnectionName string
42+
// ConflictPolicy is the binding's conflict policy (Fail if unset).
43+
ConflictPolicy corev1alpha1.ConflictPolicy
4244
}
4345

4446
// ResolveConnection finds the binding that covers crdName for the given
@@ -61,6 +63,7 @@ func ResolveConnection(ctx context.Context, c client.Client, crdName, namespace
6163
Found: true,
6264
Ready: apimeta.IsStatusConditionTrue(cb.Status.Conditions, corev1alpha1.ConditionReady),
6365
ConnectionName: cb.Spec.ConnectionRef.Name,
66+
ConflictPolicy: cb.Spec.ConflictPolicy,
6467
}, nil
6568
}
6669
}
@@ -80,6 +83,7 @@ func ResolveConnection(ctx context.Context, c client.Client, crdName, namespace
8083
Found: true,
8184
Ready: apimeta.IsStatusConditionTrue(b.Status.Conditions, corev1alpha1.ConditionReady),
8285
ConnectionName: b.Spec.ConnectionRef.Name,
86+
ConflictPolicy: b.Spec.ConflictPolicy,
8387
}, nil
8488
}
8589
}

v2/konnector/engine/sync/syncer.go

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2828
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2929
"k8s.io/apimachinery/pkg/runtime/schema"
30+
"k8s.io/apimachinery/pkg/types"
3031
"k8s.io/client-go/dynamic/dynamiclister"
3132
"k8s.io/client-go/tools/record"
3233
ctrl "sigs.k8s.io/controller-runtime"
@@ -128,17 +129,18 @@ func (r *specReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
128129
switch {
129130
case getErr == nil:
130131
if owner, ours := ownershipOf(existing, r.localUID, string(obj.GetUID())); !ours {
131-
reason := conflictReason(owner)
132-
msg := fmt.Sprintf("provider object %s already exists and is %s; not overwriting (conflictPolicy: Fail)", client.ObjectKeyFromObject(obj), owner)
133-
log.Info("conflict: refusing to overwrite provider object", "owner", owner)
134-
// Surface on the object's own condition (best-effort: pruned if the
135-
// CRD's status schema has no conditions) AND as an Event (always).
136-
setObjCondition(obj, metav1.ConditionFalse, reason, msg)
137-
_ = r.consumerClient.Status().Update(ctx, obj)
138-
if r.recorder != nil {
139-
r.recorder.Event(obj, corev1.EventTypeWarning, reason, msg)
132+
// Adopt only takes an un-owned (markerless) object; it never steals
133+
// one already owned by another binding/consumer.
134+
if owner != ownerForeignUnmanaged || res.ConflictPolicy != corev1alpha1.ConflictPolicyAdopt {
135+
reason := conflictReason(owner)
136+
msg := fmt.Sprintf("provider object %s already exists and is %s; not overwriting (conflictPolicy: %s)", client.ObjectKeyFromObject(obj), owner, policyOrFail(res.ConflictPolicy))
137+
log.Info("conflict: refusing to overwrite provider object", "owner", owner)
138+
if err := r.markConflict(ctx, obj, reason, msg); err != nil {
139+
return reconcile.Result{}, err
140+
}
141+
return reconcile.Result{}, nil
140142
}
141-
return reconcile.Result{}, nil
143+
log.Info("adopting un-owned provider object", "key", client.ObjectKeyFromObject(obj))
142144
}
143145
case apierrors.IsNotFound(getErr):
144146
if r.scope == apiextensionsv1.NamespaceScoped {
@@ -150,6 +152,11 @@ func (r *specReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
150152
return reconcile.Result{}, fmt.Errorf("provider get: %w", getErr)
151153
}
152154

155+
// We are syncing — clear any stale conflict marker from a previous round.
156+
if err := r.clearConflict(ctx, obj); err != nil {
157+
return reconcile.Result{}, err
158+
}
159+
153160
// Spec consumer -> provider (SSA).
154161
patch := r.providerPatch(obj, r.localUID)
155162
if err := r.providerClient.Patch(ctx, patch, client.Apply, client.FieldOwner(fieldOwner), client.ForceOwnership); err != nil {
@@ -168,6 +175,36 @@ func (r *specReconciler) Reconcile(ctx context.Context, req reconcile.Request) (
168175
return reconcile.Result{RequeueAfter: statusResyncBackstop}, nil
169176
}
170177

178+
// markConflict records a conflict durably (an annotation that survives status
179+
// pruning, used by bindings to count conflicts), best-effort on the object's
180+
// own condition, and as an Event.
181+
func (r *specReconciler) markConflict(ctx context.Context, obj *unstructured.Unstructured, reason, msg string) error {
182+
if obj.GetAnnotations()[corev1alpha1.AnnotationConflict] != reason {
183+
p := fmt.Appendf(nil, `{"metadata":{"annotations":{%q:%q}}}`, corev1alpha1.AnnotationConflict, reason)
184+
if err := r.consumerClient.Patch(ctx, obj, client.RawPatch(types.MergePatchType, p)); client.IgnoreNotFound(err) != nil {
185+
return fmt.Errorf("marking conflict: %w", err)
186+
}
187+
}
188+
setObjCondition(obj, metav1.ConditionFalse, reason, msg)
189+
_ = r.consumerClient.Status().Update(ctx, obj)
190+
if r.recorder != nil {
191+
r.recorder.Event(obj, corev1.EventTypeWarning, reason, msg)
192+
}
193+
return nil
194+
}
195+
196+
// clearConflict removes the conflict marker once the object syncs cleanly.
197+
func (r *specReconciler) clearConflict(ctx context.Context, obj *unstructured.Unstructured) error {
198+
if obj.GetAnnotations()[corev1alpha1.AnnotationConflict] == "" {
199+
return nil
200+
}
201+
p := fmt.Appendf(nil, `{"metadata":{"annotations":{%q:null}}}`, corev1alpha1.AnnotationConflict)
202+
if err := r.consumerClient.Patch(ctx, obj, client.RawPatch(types.MergePatchType, p)); client.IgnoreNotFound(err) != nil {
203+
return fmt.Errorf("clearing conflict: %w", err)
204+
}
205+
return nil
206+
}
207+
171208
func (r *specReconciler) reconcileDelete(ctx context.Context, obj *unstructured.Unstructured) (reconcile.Result, error) {
172209
if !controllerutil.ContainsFinalizer(obj, corev1alpha1.FinalizerSyncer) {
173210
return reconcile.Result{}, nil
@@ -241,28 +278,42 @@ func newUnstructured(gvk schema.GroupVersionKind) *unstructured.Unstructured {
241278
return u
242279
}
243280

281+
// Ownership classifications returned by ownershipOf.
282+
const (
283+
ownerForeignUnmanaged = "foreign-unmanaged"
284+
ownerOurs = "ours"
285+
ownerByAnother = "owned-by-another"
286+
)
287+
244288
// ownershipOf reports the marker owner of a provider object and whether it is
245289
// ours (our consumer cluster + our consumer object UID).
246290
func ownershipOf(obj *unstructured.Unstructured, localUID, consumerObjUID string) (string, bool) {
247291
ann := obj.GetAnnotations()
248292
cluster := ann[corev1alpha1.AnnotationConsumerClusterUID]
249293
objUID := ann[corev1alpha1.AnnotationConsumerObjectUID]
250294
if cluster == "" && objUID == "" {
251-
return "foreign-unmanaged", false
295+
return ownerForeignUnmanaged, false
252296
}
253297
if cluster == localUID && objUID == consumerObjUID {
254-
return "ours", true
298+
return ownerOurs, true
255299
}
256-
return "owned-by-another", false
300+
return ownerByAnother, false
257301
}
258302

259303
func conflictReason(owner string) string {
260-
if owner == "foreign-unmanaged" {
304+
if owner == ownerForeignUnmanaged {
261305
return corev1alpha1.ReasonForeignObjectExists
262306
}
263307
return corev1alpha1.ReasonOwnedByAnother
264308
}
265309

310+
func policyOrFail(p corev1alpha1.ConflictPolicy) corev1alpha1.ConflictPolicy {
311+
if p == "" {
312+
return corev1alpha1.ConflictPolicyFail
313+
}
314+
return p
315+
}
316+
266317
func setObjCondition(obj *unstructured.Unstructured, status metav1.ConditionStatus, reason, msg string) {
267318
cond := map[string]any{
268319
"type": corev1alpha1.ConditionSynced,

0 commit comments

Comments
 (0)