diff --git a/engine/binding/reconciler.go b/engine/binding/reconciler.go index f28a17a6f..19ad07cd4 100644 --- a/engine/binding/reconciler.go +++ b/engine/binding/reconciler.go @@ -40,6 +40,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/kbind/kbind/engine/crdpull" + "github.com/kbind/kbind/engine/openapi" "github.com/kbind/kbind/engine/remote" corev1alpha1 "github.com/kbind/kbind/sdk/apis/core/v1alpha1" ) @@ -110,13 +111,31 @@ func (b *base) reconcileAccessor(ctx context.Context, obj corev1alpha1.BindingAc } var hash string if conn.Status.ActiveSchemaSource == corev1alpha1.SchemaSourceOpenAPI { - // CRD-less provider: the Connection synthesized + installed the CRD. - // The binding just consumes it (no provider apiextensions to read). + // CRD-less provider (kcp): install the CRD for THIS bound API only + // (unless pullPolicy: None, where the user manages CRDs). The Connection + // no longer installs the whole discovery set, so the provider's built-in + // groups (e.g. *.kcp.io) never reach the consumer — only what a binding + // references does. crd := &apiextensionsv1.CustomResourceDefinition{} - if err := b.client.Get(ctx, client.ObjectKey{Name: api.Name}, crd); err != nil { + err := b.client.Get(ctx, client.ObjectKey{Name: api.Name}, crd) + if apierrors.IsNotFound(err) && conn.Spec.Schema.PullPolicy != corev1alpha1.PullPolicyNone { + cfg, cerr := remote.RestConfigFromConnection(ctx, b.client, conn) + if cerr != nil { + return fmt.Errorf("provider rest config: %w", cerr) + } + synth, serr := openapi.SynthesizeCRD(ctx, cfg, api.Name) + if serr != nil { + return fmt.Errorf("synthesizing CRD %q: %w", api.Name, serr) + } + if _, ierr := crdpull.Install(ctx, b.client, synth, conn.Name, conn.Spec.Schema.UpdatePolicy != corev1alpha1.UpdatePolicyOnce); ierr != nil { + return fmt.Errorf("installing synthesized CRD %q: %w", api.Name, ierr) + } + err = b.client.Get(ctx, client.ObjectKey{Name: api.Name}, crd) + } + if err != nil { if apierrors.IsNotFound(err) { - // The API is exported, but the Connection has not installed the - // synthesized CRD yet — a transient wait, not "not exported". + // Exported but the CRD is not present yet (pullPolicy: None until + // the user applies it) — a transient wait, not "not exported". pendingSchema = append(pendingSchema, api.Name) continue } diff --git a/engine/connection/reconciler.go b/engine/connection/reconciler.go index 1ebdda949..9c25450e0 100644 --- a/engine/connection/reconciler.go +++ b/engine/connection/reconciler.go @@ -221,14 +221,32 @@ func (r *Reconciler) reconcile(ctx context.Context, conn *corev1alpha1.Connectio setCondition(conn, corev1alpha1.ConditionPermissionDenied, metav1.ConditionFalse, corev1alpha1.ReasonAsExpected, "no RBAC denials") // pullPolicy: All — eagerly install every exported CRD on the consumer, - // without waiting for a binding. (Bound/None defer to the binding.) + // without waiting for a binding. (Bound/None defer to the binding.) The CRD + // source pulls the provider CRD object; the OpenAPI source has no CRD object + // to pull, so it synthesizes and installs. if conn.Spec.Schema.PullPolicy == corev1alpha1.PullPolicyAll { - for i := range exported { - if _, _, err := crdpull.Pull(ctx, r.Client, providerClient, exported[i].Name, conn.Name, crdpull.Options{ - Create: true, - Update: conn.Spec.Schema.UpdatePolicy != corev1alpha1.UpdatePolicyOnce, - }); err != nil { - return fmt.Errorf("eager-pulling CRD %q: %w", exported[i].Name, err) + if source == corev1alpha1.SchemaSourceOpenAPI { + cfg, err := remote.RestConfigFromConnection(ctx, r.Client, conn) + if err != nil { + return fmt.Errorf("provider rest config: %w", err) + } + crds, err := openapi.SynthesizeCRDs(ctx, cfg) + if err != nil { + return fmt.Errorf("synthesizing CRDs: %w", err) + } + for _, crd := range crds { + if _, err := crdpull.Install(ctx, r.Client, crd, conn.Name, conn.Spec.Schema.UpdatePolicy != corev1alpha1.UpdatePolicyOnce); err != nil { + return fmt.Errorf("eager-installing CRD %q: %w", crd.Name, err) + } + } + } else { + for i := range exported { + if _, _, err := crdpull.Pull(ctx, r.Client, providerClient, exported[i].Name, conn.Name, crdpull.Options{ + Create: true, + Update: conn.Spec.Schema.UpdatePolicy != corev1alpha1.UpdatePolicyOnce, + }); err != nil { + return fmt.Errorf("eager-pulling CRD %q: %w", exported[i].Name, err) + } } } } @@ -460,10 +478,12 @@ func (r *Reconciler) bindingsReferencing(ctx context.Context, name string) (int, // discoverExportedCRDs lists provider CRDs carrying the exported label and maps // them to ExportedAPI entries. // discoverAndInstall resolves the schema source and returns the active source + -// exported APIs. For CRD it lists label-gated provider CRDs (the binding pulls -// them later); for OpenAPI it synthesizes CRDs from discovery + /openapi/v3 and -// installs them on the consumer (CRD-less providers like kcp). Auto probes CRD -// first and falls back to OpenAPI. +// exported APIs. It NEVER installs here: installation is gated by pullPolicy +// (All: eager in Reconcile; Bound: the binding installs only referenced APIs; +// None: never), identically for both sources. For CRD it lists label-gated +// provider CRDs; for OpenAPI it synthesizes candidate CRDs from discovery + +// /openapi/v3 (CRD-less providers like kcp) purely to populate exportedAPIs. +// Auto probes CRD first and falls back to OpenAPI. func (r *Reconciler) discoverAndInstall(ctx context.Context, conn *corev1alpha1.Connection, providerClient client.Client) (corev1alpha1.SchemaSource, []corev1alpha1.ExportedAPI, error) { src := conn.Spec.Schema.Source if src == "" { @@ -481,7 +501,12 @@ func (r *Reconciler) discoverAndInstall(ctx context.Context, conn *corev1alpha1. // Auto with no labeled CRDs → fall through to OpenAPI. } - // OpenAPI: synthesize CRDs from discovery + /openapi/v3 and install them. + // OpenAPI: synthesize candidate CRDs from discovery + /openapi/v3 to populate + // exportedAPIs. Do NOT install here — installation is pullPolicy-gated (see + // Reconcile for All, the binding for Bound). This keeps the provider's + // built-in groups (e.g. kcp's *.kcp.io) off the consumer unless a binding + // references them, while still letting the binding go Ready (the API is in + // exportedAPIs) so status + related-resource sync work. cfg, err := remote.RestConfigFromConnection(ctx, r.Client, conn) if err != nil { return "", nil, err @@ -492,9 +517,6 @@ func (r *Reconciler) discoverAndInstall(ctx context.Context, conn *corev1alpha1. } exported := make([]corev1alpha1.ExportedAPI, 0, len(crds)) for _, crd := range crds { - if _, err := crdpull.Install(ctx, r.Client, crd, conn.Name, conn.Spec.Schema.UpdatePolicy != corev1alpha1.UpdatePolicyOnce); err != nil { - return "", nil, fmt.Errorf("installing synthesized CRD %q: %w", crd.Name, err) - } versions := make([]string, len(crd.Spec.Versions)) for i, v := range crd.Spec.Versions { versions[i] = v.Name diff --git a/engine/openapi/openapi.go b/engine/openapi/openapi.go index 148032410..40f858928 100644 --- a/engine/openapi/openapi.go +++ b/engine/openapi/openapi.go @@ -140,6 +140,22 @@ func SynthesizeCRDs(ctx context.Context, cfg *rest.Config) ([]*apiextensionsv1.C return out, nil } +// SynthesizeCRD synthesizes the single CRD named ".", for +// on-demand install of one bound API (schema.source: OpenAPI, pullPolicy: Bound). +// Returns an error if the provider does not export that resource. +func SynthesizeCRD(ctx context.Context, cfg *rest.Config, name string) (*apiextensionsv1.CustomResourceDefinition, error) { + crds, err := SynthesizeCRDs(ctx, cfg) + if err != nil { + return nil, err + } + for _, crd := range crds { + if crd.Name == name { + return crd, nil + } + } + return nil, fmt.Errorf("provider does not export %q", name) +} + func buildCRD(gr schema.GroupResource, versions []crdVersionInfo, preferredVer string) *apiextensionsv1.CustomResourceDefinition { // Keep deterministic order so that we don't trigger updates without actually changing the schema. sort.SliceStable(versions, func(i, j int) bool { diff --git a/test/e2e/schema_source_test.go b/test/e2e/schema_source_test.go index 608f4fa84..5a2526e44 100644 --- a/test/e2e/schema_source_test.go +++ b/test/e2e/schema_source_test.go @@ -53,21 +53,23 @@ func TestSlimCoreOpenAPISource(t *testing.T) { return conn.Status.Conditions, err }, corev1alpha1.ConditionReady) - t.Run("Connection synthesizes and installs the CRD via OpenAPI", func(t *testing.T) { + t.Run("Connection discovers via OpenAPI but installs no unbound CRD (pullPolicy: Bound)", func(t *testing.T) { conn := &corev1alpha1.Connection{} require.NoError(t, env.ConsumerClient.Get(ctx, client.ObjectKey{Name: "demo-provider"}, conn)) require.Equal(t, corev1alpha1.SchemaSourceOpenAPI, conn.Status.ActiveSchemaSource) _, ok := conn.Status.ExportsAPI(widgetCRDName) require.True(t, ok, "OpenAPI discovery should export %s", widgetCRDName) + // Under the default pullPolicy: Bound the Connection populates + // exportedAPIs but installs nothing — install is deferred to a binding. + // (This keeps a provider's built-in groups, e.g. kcp's *.kcp.io, off the + // consumer unless a binding references them.) crd := &apiextensionsv1.CustomResourceDefinition{} - require.Eventually(t, func() bool { - return env.ConsumerClient.Get(ctx, client.ObjectKey{Name: widgetCRDName}, crd) == nil - }, 30*time.Second, 200*time.Millisecond, "the synthesized CRD should be installed on the consumer") - require.Equal(t, "true", crd.Labels[corev1alpha1.LabelManaged]) + err := env.ConsumerClient.Get(ctx, client.ObjectKey{Name: widgetCRDName}, crd) + require.True(t, apierrors.IsNotFound(err), "the synthesized CRD must not be installed before a binding references it") }) - t.Run("a binding syncs an instance over the synthesized CRD", func(t *testing.T) { + t.Run("a binding installs the synthesized CRD and syncs an instance", func(t *testing.T) { require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.ClusterBinding{ ObjectMeta: metav1.ObjectMeta{Name: "widgets"}, Spec: corev1alpha1.BindingSpec{ @@ -81,6 +83,13 @@ func TestSlimCoreOpenAPISource(t *testing.T) { return cb.Status.Conditions, err }, corev1alpha1.ConditionReady) + // The binding installed the synthesized CRD on demand (pullPolicy: Bound). + crd := &apiextensionsv1.CustomResourceDefinition{} + require.Eventually(t, func() bool { + return env.ConsumerClient.Get(ctx, client.ObjectKey{Name: widgetCRDName}, crd) == nil + }, 30*time.Second, 200*time.Millisecond, "the binding should install the synthesized CRD") + require.Equal(t, "true", crd.Labels[corev1alpha1.LabelManaged]) + consumerWidgets := env.ConsumerDyn.Resource(gvr).Namespace(instanceNS) providerWidgets := env.ProviderDyn.Resource(gvr).Namespace(instanceNS) require.Eventually(t, func() bool {