Skip to content

Commit ad37e6b

Browse files
committed
3tier test
1 parent da26ad9 commit ad37e6b

1 file changed

Lines changed: 337 additions & 0 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/*
2+
Copyright 2026 The Kube Bind Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package e2e
18+
19+
import (
20+
"fmt"
21+
"path"
22+
"strings"
23+
"testing"
24+
"time"
25+
26+
"github.com/kcp-dev/logicalcluster/v3"
27+
kcpclientset "github.com/kcp-dev/sdk/client/clientset/versioned/cluster"
28+
kcptestinghelpers "github.com/kcp-dev/sdk/testing/helpers"
29+
"github.com/stretchr/testify/require"
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
32+
"k8s.io/apimachinery/pkg/runtime/schema"
33+
"k8s.io/apimachinery/pkg/util/wait"
34+
"k8s.io/cli-runtime/pkg/genericclioptions"
35+
"k8s.io/client-go/rest"
36+
37+
kcpboostrapdeploy "github.com/kube-bind/kube-bind/contrib/kcp/deploy"
38+
kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2"
39+
"github.com/kube-bind/kube-bind/test/e2e/framework"
40+
)
41+
42+
// TestKCP3TierClusterScope tests the 3-tier kcp flow with cluster-scoped resources.
43+
func TestKCP3TierClusterScope(t *testing.T) {
44+
testKcp3TierIntegration(t, "3cc", kubebindv1alpha2.ClusterScope)
45+
}
46+
47+
// TestKCP3TierNamespacedScope tests the 3-tier kcp flow with namespaced resources.
48+
func TestKCP3TierNamespacedScope(t *testing.T) {
49+
testKcp3TierIntegration(t, "3nc", kubebindv1alpha2.NamespacedScope)
50+
}
51+
52+
// testKcp3TierIntegration tests the 3-tier kcp flow:
53+
//
54+
// :root:provider — owns the APIResourceSchema + APIExport (source of truth)
55+
// |
56+
// | (APIBinding: :root:backend binds from :root:provider)
57+
// v
58+
// :root:backend — binds resources from provider; apibindingtemplate controller
59+
// auto-creates APIServiceExportTemplates; apiresourceschema
60+
// controller copies schemas with kube-bind.io/exported=true label
61+
// |
62+
// | (kube-bind serves the binding API)
63+
// v
64+
// consumer — binds resources via kube-bind
65+
func testKcp3TierIntegration(t *testing.T, name string, scope kubebindv1alpha2.InformerScope) {
66+
t.Helper()
67+
t.Logf("Testing 3-tier kcp integration with informer scope %s", scope)
68+
69+
// dex
70+
framework.StartDex(t)
71+
72+
// kcp bootstrap (creates :root:kube-bind workspace with APIExport etc.)
73+
bootstrapKCP(t, framework.ClientConfig(t))
74+
75+
suffix := framework.RandomString(4)
76+
77+
cfg := framework.ClientConfig(t)
78+
cfg.Host = strings.Split(cfg.Host, "/clusters/")[0]
79+
kcpClusterClient, err := kcpclientset.NewForConfig(cfg)
80+
require.NoError(t, err, "failed to create kcp client")
81+
82+
// --- Provider workspace ---
83+
t.Log("Create provider workspace")
84+
providerWsName := fmt.Sprintf("%s-provider-%s", name, suffix)
85+
providerWsPath := logicalcluster.NewPath("root").Join(providerWsName)
86+
providerCfg, _ := framework.NewWorkspace(t, framework.ClientConfig(t), framework.WithStaticName(providerWsName))
87+
88+
t.Log("Applying APIResourceSchema and APIExport to provider workspace")
89+
// Apply the cowboys APIResourceSchema and a cowboys-only APIExport.
90+
providerFiles := []string{
91+
"examples/apiresourceschema-cowboys.yaml",
92+
"examples/apiresourceschema-sheriffs.yaml",
93+
}
94+
for _, f := range providerFiles {
95+
data, err := kcpboostrapdeploy.Examples.ReadFile(f)
96+
require.NoError(t, err, "failed to read provider file %s", f)
97+
framework.ApplyManifest(t, providerCfg, data)
98+
}
99+
// Apply the combined APIExport (cowboys + sheriffs).
100+
data, err := kcpboostrapdeploy.Examples.ReadFile("examples/apiexport.yaml")
101+
require.NoError(t, err)
102+
framework.ApplyManifest(t, providerCfg, data)
103+
104+
// --- Consumer workspace ---
105+
t.Log("Create consumer workspace")
106+
consumerWsName := fmt.Sprintf("%s-consumer-%s", name, suffix)
107+
consumerCfg, consumerKubeconfigPath := framework.NewWorkspace(t, framework.ClientConfig(t), framework.WithStaticName(consumerWsName))
108+
109+
t.Log("Start konnector for consumer workspace")
110+
framework.StartKonnector(t, consumerCfg, "--kubeconfig="+consumerKubeconfigPath)
111+
112+
// --- Backend (kube-bind process + backend workspace) ---
113+
backendAddr := bootstrapBackend(t, framework.ClientConfig(t), scope)
114+
115+
// --- Backend workspace ---
116+
// The backend workspace binds both kube-bind.io and the provider's cowboys APIExport.
117+
// The apibindingtemplate and apiresourceschema controllers will automatically:
118+
// 1. Create APIServiceExportTemplates from the cowboys APIBinding
119+
// 2. Copy APIResourceSchemas with kube-bind.io/exported=true label
120+
t.Log("Create backend workspace")
121+
backendWsName := fmt.Sprintf("%s-backend-%s", name, suffix)
122+
backendWsPath := logicalcluster.NewPath("root").Join(backendWsName)
123+
backendCfg, _ := framework.NewWorkspace(t, framework.ClientConfig(t), framework.WithStaticName(backendWsName))
124+
125+
t.Log("Bind kube-bind.io APIExport to backend workspace")
126+
createApiBinding(t,
127+
kcpClusterClient,
128+
backendWsPath,
129+
generateApiBinding(t, logicalcluster.NewPath("root").Join("kube-bind"), "kube-bind.io", "kube-bind.io",
130+
"clusterrolebindings.rbac.authorization.k8s.io",
131+
"clusterroles.rbac.authorization.k8s.io",
132+
"customresourcedefinitions.apiextensions.k8s.io",
133+
"serviceaccounts.core",
134+
"configmaps.core",
135+
"secrets.core",
136+
"namespaces.core",
137+
"roles.rbac.authorization.k8s.io",
138+
"rolebindings.rbac.authorization.k8s.io",
139+
"subjectaccessreviews.authorization.k8s.io",
140+
"apiresourceschemas.apis.kcp.io",
141+
"apibindings.apis.kcp.io",
142+
),
143+
)
144+
145+
t.Log("Bind provider's cowboys-stable APIExport to backend workspace")
146+
createApiBinding(t,
147+
kcpClusterClient,
148+
backendWsPath,
149+
generateApiBinding(t, providerWsPath, "cowboys-stable", "cowboys-stable"),
150+
)
151+
152+
// --- Wait for dynamic template creation ---
153+
// The apibindingtemplate controller should automatically create an
154+
// APIServiceExportTemplate named after the APIBinding (cowboys-stable).
155+
t.Log("Waiting for APIServiceExportTemplate to be auto-created by apibindingtemplate controller")
156+
templateGVR := schema.GroupVersionResource{Group: "kube-bind.io", Version: "v1alpha2", Resource: "apiserviceexporttemplates"}
157+
templateDynClient := framework.DynamicClient(t, backendCfg).Resource(templateGVR)
158+
kcptestinghelpers.Eventually(t, func() (bool, string) {
159+
templates, err := templateDynClient.List(t.Context(), metav1.ListOptions{})
160+
if err != nil {
161+
return false, fmt.Sprintf("Error listing templates: %v", err)
162+
}
163+
for _, tmpl := range templates.Items {
164+
if tmpl.GetName() == "cowboys-stable" {
165+
return true, ""
166+
}
167+
}
168+
return false, fmt.Sprintf("Template 'cowboys-stable' not found, got %d templates", len(templates.Items))
169+
}, wait.ForeverTestTimeout, time.Second)
170+
171+
// --- Wait for APIResourceSchema copy ---
172+
// The apiresourceschema controller should copy the bound schemas with the
173+
// kube-bind.io/exported=true label.
174+
t.Log("Waiting for APIResourceSchema copies with exported label")
175+
waitForExportedSchemas(t, backendCfg, kcpClusterClient, backendWsPath)
176+
177+
// --- Get logical cluster ID for login ---
178+
t.Log("Get logical cluster of backend workspace")
179+
backendCluster, err := kcpClusterClient.Cluster(backendWsPath).CoreV1alpha1().LogicalClusters().Get(t.Context(), "cluster", metav1.GetOptions{})
180+
require.NoError(t, err)
181+
require.NotEmpty(t, backendCluster.Status.URL, "backend cluster URL is empty")
182+
183+
backendClusterSplit := strings.Split(backendCluster.Status.URL, "/")
184+
require.GreaterOrEqual(t, len(backendClusterSplit), 2, "Unexpected URL format: %s", backendCluster.Status.URL)
185+
require.Equal(t, "clusters", backendClusterSplit[len(backendClusterSplit)-2], "Unexpected URL format: %s", backendCluster.Status.URL)
186+
backendClusterID := backendClusterSplit[len(backendClusterSplit)-1]
187+
require.NotEmpty(t, backendClusterID, "Retrieved cluster id is empty")
188+
189+
// --- Binding ---
190+
var templateRef, kind, resource string
191+
switch scope {
192+
case kubebindv1alpha2.ClusterScope:
193+
kind = "Sheriff"
194+
resource = "sheriffs"
195+
templateRef = "cowboys-stable" // template is named after the APIBinding
196+
case kubebindv1alpha2.NamespacedScope:
197+
kind = "Cowboy"
198+
resource = "cowboys"
199+
templateRef = "cowboys-stable" // template is named after the APIBinding
200+
default:
201+
require.Fail(t, "unhandled scope %q", scope)
202+
}
203+
204+
kubeBindConfig := path.Join(framework.WorkDir, "kube-bind-config-kcp-3tier.yaml")
205+
206+
iostreams, _, _, _ := genericclioptions.NewTestIOStreams()
207+
authURLDryRunCh := make(chan string, 1)
208+
go framework.SimulateBrowser(t, authURLDryRunCh)
209+
framework.Login(t, iostreams, authURLDryRunCh, kubeBindConfig, fmt.Sprintf("http://%s/api/exports", backendAddr), backendClusterID)
210+
211+
t.Logf("Performing binding using template %s", templateRef)
212+
performBinding(t, consumerCfg, templateRef, resource, kubeBindConfig)
213+
214+
// --- Resource sync ---
215+
t.Log("Testing resource creation and synchronization...")
216+
testKCP3TierResourceSync(t, consumerCfg, backendCfg, scope, kind, resource)
217+
}
218+
219+
// waitForExportedSchemas waits for APIResourceSchemas with the kube-bind.io/exported=true
220+
// label to appear in the backend workspace. These are created by the apiresourceschema controller.
221+
func waitForExportedSchemas(t *testing.T, backendCfg *rest.Config, _ *kcpclientset.ClusterClientset, _ logicalcluster.Path) {
222+
t.Helper()
223+
224+
schemaGVR := schema.GroupVersionResource{Group: "apis.kcp.io", Version: "v1alpha1", Resource: "apiresourceschemas"}
225+
dynClient := framework.DynamicClient(t, backendCfg).Resource(schemaGVR)
226+
227+
kcptestinghelpers.Eventually(t, func() (bool, string) {
228+
schemas, err := dynClient.List(t.Context(), metav1.ListOptions{
229+
LabelSelector: "kube-bind.io/exported=true",
230+
})
231+
if err != nil {
232+
return false, fmt.Sprintf("Error listing schemas: %v", err)
233+
}
234+
if len(schemas.Items) == 0 {
235+
return false, "No exported APIResourceSchemas found yet"
236+
}
237+
// Verify at least one schema has the expected structure.
238+
for _, s := range schemas.Items {
239+
group, _, _ := unstructured.NestedString(s.Object, "spec", "group")
240+
if group == "wildwest.dev" {
241+
return true, ""
242+
}
243+
}
244+
return false, fmt.Sprintf("Found %d schemas but none for wildwest.dev group", len(schemas.Items))
245+
}, wait.ForeverTestTimeout, time.Second)
246+
}
247+
248+
func testKCP3TierResourceSync(t *testing.T, consumerCfg, backendCfg *rest.Config, scope kubebindv1alpha2.InformerScope, kind, resource string) {
249+
serviceGVR := schema.GroupVersionResource{Group: "wildwest.dev", Version: "v1alpha1", Resource: resource}
250+
251+
consumerClient := testKcpClient(t, consumerCfg, scope, serviceGVR, "default")
252+
backendClient := testKcpClient(t, backendCfg, scope, serviceGVR, "")
253+
254+
t.Run("instance created downstream syncs upstream", func(t *testing.T) {
255+
t.Logf("Creating %s instance on consumer side", kind)
256+
257+
resourceInstance := &unstructured.Unstructured{
258+
Object: map[string]any{
259+
"apiVersion": "wildwest.dev/v1alpha1",
260+
"kind": kind,
261+
"metadata": map[string]any{
262+
"name": "test-3tier-" + resource,
263+
},
264+
"spec": map[string]any{
265+
"intent": "draw",
266+
},
267+
},
268+
}
269+
270+
kcptestinghelpers.Eventually(t, func() (bool, string) {
271+
_, err := consumerClient.Create(t.Context(), resourceInstance, metav1.CreateOptions{})
272+
return err == nil, fmt.Sprintf("Error creating %s instance: %v", resource, err)
273+
}, wait.ForeverTestTimeout, time.Millisecond*100)
274+
275+
t.Logf("Waiting for %s instance to be synced to backend side", resource)
276+
var instances *unstructured.UnstructuredList
277+
kcptestinghelpers.Eventually(t, func() (bool, string) {
278+
var err error
279+
instances, err = backendClient.List(t.Context(), metav1.ListOptions{})
280+
return err == nil && len(instances.Items) >= 1, fmt.Sprintf("Error listing %s instances: %v", resource, err)
281+
}, wait.ForeverTestTimeout, time.Millisecond*100)
282+
283+
require.GreaterOrEqual(t, len(instances.Items), 1, "Expected at least one %s instance on backend side", resource)
284+
})
285+
286+
t.Run("instance spec updated downstream syncs upstream", func(t *testing.T) {
287+
t.Logf("Updating %s spec on consumer side", resource)
288+
289+
require.Eventually(t, func() bool {
290+
obj, err := consumerClient.Get(t.Context(), "test-3tier-"+resource, metav1.GetOptions{})
291+
if err != nil {
292+
return false
293+
}
294+
295+
unstructured.SetNestedField(obj.Object, "holster", "spec", "intent") //nolint:errcheck
296+
_, err = consumerClient.Update(t.Context(), obj, metav1.UpdateOptions{})
297+
return err == nil
298+
}, wait.ForeverTestTimeout, 5*time.Second, "waiting for %s spec to be updated on consumer side", resource)
299+
300+
t.Logf("Waiting for %s spec update to sync to backend side", resource)
301+
require.Eventually(t, func() bool {
302+
instances, err := backendClient.List(t.Context(), metav1.ListOptions{})
303+
if err != nil || len(instances.Items) == 0 {
304+
return false
305+
}
306+
307+
for _, item := range instances.Items {
308+
intent, found, err := unstructured.NestedString(item.Object, "spec", "intent")
309+
if err == nil && found && intent == "holster" {
310+
return true
311+
}
312+
}
313+
return false
314+
}, wait.ForeverTestTimeout, 5*time.Second, "waiting for %s spec update to sync to backend side", resource)
315+
})
316+
317+
t.Run("instance deleted downstream is deleted upstream", func(t *testing.T) {
318+
t.Logf("Deleting %s instance on consumer side", resource)
319+
320+
err := consumerClient.Delete(t.Context(), "test-3tier-"+resource, metav1.DeleteOptions{})
321+
require.NoError(t, err, "Failed to delete %s on consumer side", resource)
322+
323+
t.Logf("Waiting for %s instance to be deleted on backend side", resource)
324+
require.Eventually(t, func() bool {
325+
instances, err := backendClient.List(t.Context(), metav1.ListOptions{})
326+
if err != nil {
327+
return false
328+
}
329+
for _, item := range instances.Items {
330+
if item.GetName() == "test-3tier-"+resource {
331+
return false
332+
}
333+
}
334+
return true
335+
}, wait.ForeverTestTimeout, 5*time.Second, "waiting for %s instance to be deleted on backend side", resource)
336+
})
337+
}

0 commit comments

Comments
 (0)