diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..907e5f1ad --- /dev/null +++ b/test/README.md @@ -0,0 +1,22 @@ +# Tests + +This is the test catalog for the kbind slim core: every scenario, what it +asserts, and what is **not** covered yet. Keep it in sync when adding tests. + +## How the tests run + +- **e2e** (`test/e2e/`) run on **envtest** — real `kube-apiserver` + `etcd` + binaries as local processes (no kind, no Docker, no kubelet/nodes). Each test + starts **two** control planes: a *consumer* (pre-loaded with the core CRDs) and + a *provider*, and runs the real engine reconcilers in-process against the + consumer. See [e2e/framework/framework.go](e2e/framework/framework.go). +- **unit** tests live next to the code under `engine/*/` and need no cluster. + +```sh +make test # unit tests only (no external setup) +make test-e2e # downloads envtest assets and runs the e2e suite + +# one e2e test, verbose: +KUBEBUILDER_ASSETS="$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.21 use 1.34.1 -p path)" \ + go test -v ./test/e2e -run TestSlimCoreHappyCase +``` diff --git a/test/e2e/namespaced_binding_test.go b/test/e2e/namespaced_binding_test.go new file mode 100644 index 000000000..a881c6ccc --- /dev/null +++ b/test/e2e/namespaced_binding_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1alpha1 "github.com/kbind/kbind/sdk/apis/core/v1alpha1" + "github.com/kbind/kbind/test/e2e/framework" +) + +// TestSlimCoreNamespacedBinding exercises the namespaced Binding kind (the +// happy case uses ClusterBinding): it becomes Ready and pulls the CRD, and the +// syncer scopes instance sync to the Binding's namespace — instances elsewhere +// are not synced (ResolveConnection: a namespaced Binding covers only its own +// namespace). +func TestSlimCoreNamespacedBinding(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + gvr := env.InstallExportedWidgetCRD(t) + + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-provider"}, + Spec: corev1alpha1.ConnectionSpec{ + KubeconfigSecretRef: corev1alpha1.SecretKeyRef{Namespace: framework.KbindNamespace, Name: "demo-provider-kubeconfig", Key: "kubeconfig"}, + Schema: corev1alpha1.SchemaPolicy{Source: corev1alpha1.SchemaSourceCRD}, + }, + })) + // A namespaced Binding scoped to "default". + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Binding{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widgets"}, + Spec: corev1alpha1.BindingSpec{ + ConnectionRef: corev1alpha1.ConnectionRef{Name: "demo-provider"}, + APIs: []corev1alpha1.APIRef{{Name: widgetCRDName}}, + }, + })) + + t.Run("the namespaced Binding becomes Ready and pulls the CRD", func(t *testing.T) { + framework.WaitForConditionTrue(t, func() ([]metav1.Condition, error) { + b := &corev1alpha1.Binding{} + err := env.ConsumerClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "widgets"}, b) + return b.Status.Conditions, err + }, corev1alpha1.ConditionReady) + require.Eventually(t, func() bool { + crd := &apiextensionsv1.CustomResourceDefinition{} + return env.ConsumerClient.Get(ctx, client.ObjectKey{Name: widgetCRDName}, crd) == nil + }, 30*time.Second, 200*time.Millisecond, "the bound CRD should be pulled onto the consumer") + }) + + t.Run("an instance in the bound namespace syncs to the provider", func(t *testing.T) { + _, err := env.ConsumerDyn.Resource(gvr).Namespace("default").Create(ctx, nsWidget("default", "in-scope"), metav1.CreateOptions{}) + require.NoError(t, err) + require.Eventually(t, func() bool { + _, err := env.ProviderDyn.Resource(gvr).Namespace("default").Get(ctx, "in-scope", metav1.GetOptions{}) + return err == nil + }, wait.ForeverTestTimeout, 200*time.Millisecond, "an instance in the bound namespace should sync to the provider") + }) + + t.Run("an instance in another namespace is not synced", func(t *testing.T) { + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "other"}})) + _, err := env.ConsumerDyn.Resource(gvr).Namespace("other").Create(ctx, nsWidget("other", "out-of-scope"), metav1.CreateOptions{}) + require.NoError(t, err) + require.Never(t, func() bool { + _, err := env.ProviderDyn.Resource(gvr).Namespace("other").Get(ctx, "out-of-scope", metav1.GetOptions{}) + return err == nil + }, 4*time.Second, 300*time.Millisecond, "an instance outside the bound namespace must not sync") + }) +} + +func nsWidget(ns, name string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(framework.WidgetGVK()) + u.SetNamespace(ns) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, "small", "spec", "size") + return u +} diff --git a/test/e2e/related_resources_more_test.go b/test/e2e/related_resources_more_test.go new file mode 100644 index 000000000..fdd62a3ad --- /dev/null +++ b/test/e2e/related_resources_more_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2026 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1alpha1 "github.com/kbind/kbind/sdk/apis/core/v1alpha1" + "github.com/kbind/kbind/test/e2e/framework" +) + +// setupWidgetRelatedBinding creates the demo Connection (CRD schema source) and a +// Ready ClusterBinding for the Widget API carrying the given relatedResources +// rules. The Widget CRD must already be installed on the provider. +func setupWidgetRelatedBinding(t *testing.T, env *framework.Env, ctx context.Context, name string, rr []corev1alpha1.RelatedResource) { + t.Helper() + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-provider"}, + Spec: corev1alpha1.ConnectionSpec{ + KubeconfigSecretRef: corev1alpha1.SecretKeyRef{Namespace: framework.KbindNamespace, Name: "demo-provider-kubeconfig", Key: "kubeconfig"}, + Schema: corev1alpha1.SchemaPolicy{Source: corev1alpha1.SchemaSourceCRD}, + }, + })) + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.ClusterBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1alpha1.BindingSpec{ + ConnectionRef: corev1alpha1.ConnectionRef{Name: "demo-provider"}, + APIs: []corev1alpha1.APIRef{{Name: widgetCRDName}}, + RelatedResources: rr, + }, + })) + framework.WaitForConditionTrue(t, func() ([]metav1.Condition, error) { + cb := &corev1alpha1.ClusterBinding{} + err := env.ConsumerClient.Get(ctx, client.ObjectKey{Name: name}, cb) + return cb.Status.Conditions, err + }, corev1alpha1.ConditionReady) +} + +// TestSlimCoreRelatedConfigMaps covers ConfigMaps as a related resource +// (FromProvider): sync, GC on stop-matching, and the foreign-object guard. +func TestSlimCoreRelatedConfigMaps(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "configmaps", + Direction: corev1alpha1.FromProvider, + Selector: &corev1alpha1.RelatedResourceSelector{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}}}, + }}) + + key := client.ObjectKey{Namespace: "default", Name: "widget-config"} + + t.Run("a label-selected provider ConfigMap syncs to the consumer", func(t *testing.T) { + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widget-config", Labels: map[string]string{"app": "widget"}}, + Data: map[string]string{"region": "eu"}, + })) + require.Eventually(t, func() bool { + cm := &corev1.ConfigMap{} + if err := env.ConsumerClient.Get(ctx, key, cm); err != nil { + return false + } + return cm.Data["region"] == "eu" && + cm.Labels[corev1alpha1.LabelManaged] == "true" && + cm.Annotations[corev1alpha1.AnnotationRelatedBinding] != "" + }, 30*time.Second, 200*time.Millisecond, "the label-selected provider ConfigMap should sync to the consumer") + }) + + t.Run("the synced ConfigMap is GC'd when it stops matching", func(t *testing.T) { + cm := &corev1.ConfigMap{} + require.NoError(t, env.ProviderClient.Get(ctx, key, cm)) + delete(cm.Labels, "app") + require.NoError(t, env.ProviderClient.Update(ctx, cm)) + require.Eventually(t, func() bool { + c := &corev1.ConfigMap{} + return apierrors.IsNotFound(env.ConsumerClient.Get(ctx, key, c)) + }, 30*time.Second, 200*time.Millisecond, "the consumer copy should be GC'd once the ConfigMap stops matching") + }) + + t.Run("a foreign ConfigMap of the same name is not overwritten", func(t *testing.T) { + foreignKey := client.ObjectKey{Namespace: "default", Name: "foreign-config"} + // A pre-existing, unmanaged consumer ConfigMap. + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "foreign-config"}, + Data: map[string]string{"region": "FOREIGN"}, + })) + // The provider exports a same-named, matching ConfigMap. + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "foreign-config", Labels: map[string]string{"app": "widget"}}, + Data: map[string]string{"region": "PROVIDER"}, + })) + require.Never(t, func() bool { + cm := &corev1.ConfigMap{} + if err := env.ConsumerClient.Get(ctx, foreignKey, cm); err != nil { + return false + } + return cm.Data["region"] == "PROVIDER" || cm.Labels[corev1alpha1.LabelManaged] == "true" + }, 3*time.Second, 300*time.Millisecond, "the foreign consumer ConfigMap must not be overwritten or marked managed") + }) +} + +// TestSlimCoreRelatedReverseDirection covers FromConsumer (consumer -> provider): +// a consumer Secret is synced UP to the provider and GC'd when it stops matching. +func TestSlimCoreRelatedReverseDirection(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "secrets", + Direction: corev1alpha1.FromConsumer, + Selector: &corev1alpha1.RelatedResourceSelector{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}}}, + }}) + + key := client.ObjectKey{Namespace: "default", Name: "consumer-creds"} + + t.Run("a label-selected consumer Secret syncs up to the provider", func(t *testing.T) { + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "consumer-creds", Labels: map[string]string{"app": "widget"}}, + StringData: map[string]string{"token": "up3cr3t"}, + })) + require.Eventually(t, func() bool { + s := &corev1.Secret{} + if err := env.ProviderClient.Get(ctx, key, s); err != nil { + return false + } + return string(s.Data["token"]) == "up3cr3t" && + s.Labels[corev1alpha1.LabelManaged] == "true" && + s.Annotations[corev1alpha1.AnnotationRelatedBinding] != "" + }, 30*time.Second, 200*time.Millisecond, "the consumer Secret should sync up to the provider") + }) + + t.Run("the provider copy is GC'd when the consumer Secret stops matching", func(t *testing.T) { + s := &corev1.Secret{} + require.NoError(t, env.ConsumerClient.Get(ctx, key, s)) + delete(s.Labels, "app") + require.NoError(t, env.ConsumerClient.Update(ctx, s)) + require.Eventually(t, func() bool { + c := &corev1.Secret{} + return apierrors.IsNotFound(env.ProviderClient.Get(ctx, key, c)) + }, 30*time.Second, 200*time.Millisecond, "the provider copy should be GC'd once the consumer Secret stops matching") + }) +} + +// TestSlimCoreRelatedNamedSelector covers the named selector: only the listed +// objects are synced; others are left alone. +func TestSlimCoreRelatedNamedSelector(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "configmaps", + Direction: corev1alpha1.FromProvider, + Selector: &corev1alpha1.RelatedResourceSelector{Names: []string{"wanted"}}, + }}) + + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "wanted"}, Data: map[string]string{"k": "v"}, + })) + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "unwanted"}, Data: map[string]string{"k": "v"}, + })) + + require.Eventually(t, func() bool { + cm := &corev1.ConfigMap{} + return env.ConsumerClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "wanted"}, cm) == nil + }, 30*time.Second, 200*time.Millisecond, "the named ConfigMap should sync") + + // Once "wanted" has synced the reconcile loop has run, so a non-named object + // would already have synced if names were ignored. + require.Never(t, func() bool { + cm := &corev1.ConfigMap{} + return env.ConsumerClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "unwanted"}, cm) == nil + }, 3*time.Second, 300*time.Millisecond, "the non-named ConfigMap must not sync") +} + +// TestSlimCoreRelatedCleanupOnUnbind verifies that deleting the binding removes +// the related copies it created (cleanupRelated on the unbind finalizer). +func TestSlimCoreRelatedCleanupOnUnbind(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "secrets", + Direction: corev1alpha1.FromProvider, + Selector: &corev1alpha1.RelatedResourceSelector{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}}}, + }}) + + key := client.ObjectKey{Namespace: "default", Name: "widget-creds"} + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widget-creds", Labels: map[string]string{"app": "widget"}}, + StringData: map[string]string{"token": "s3cr3t"}, + })) + require.Eventually(t, func() bool { + s := &corev1.Secret{} + return env.ConsumerClient.Get(ctx, key, s) == nil + }, 30*time.Second, 200*time.Millisecond, "the related Secret should sync before unbind") + + require.NoError(t, env.ConsumerClient.Delete(ctx, &corev1alpha1.ClusterBinding{ObjectMeta: metav1.ObjectMeta{Name: "widgets"}})) + require.Eventually(t, func() bool { + s := &corev1.Secret{} + return apierrors.IsNotFound(env.ConsumerClient.Get(ctx, key, s)) + }, 30*time.Second, 200*time.Millisecond, "the related copy should be cleaned up on unbind") +}