Skip to content

Commit 6491561

Browse files
authored
test(mcp): use envtest built-in features to apply CRDs (#516)
Trying to speed up the test cases that require additional kubernetes APIs. Each time a test suite or test case requires an additional API (like OpenShift Routes or Kiali CRDs), provided by k8s.io/apiextensions, a fixed 2 seconds delay is introduced. There's no way to reduce that delay by using envtest features or APIServer CLI flags. The delays are introduced by kube-aggregator, apiextensions, and others: https://github.com/kubernetes/kube-aggregator/blob/a00232cf7e758c2771b3542033bec19e69e6451a/pkg/controllers/openapiv3/controller.go#L34 https://github.com/kubernetes/apiextensions-apiserver/blob/8db5ab628dd026827c1c9677944432db70c065c3/pkg/apiserver/customresource_handler.go#L381-L394 To mitigate this, we're going to declare all CRDs used in the test in the common_test.go TestMain function. This will make sure we only have a single delay at the beginning of the entire test suite instead of per test suite or test case. The CRDs are NOT served by default. For each test suite we'll be able to enable them or disable them by using the EnvTestEnableCRD and EnvTestDisableCRD functions. These will also ensure that the API server lists the new APIs or not. Signed-off-by: Marc Nuri <marc@marcnuri.com>
1 parent 82f3d41 commit 6491561

File tree

4 files changed

+155
-106
lines changed

4 files changed

+155
-106
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ require (
1010
github.com/google/jsonschema-go v0.3.0
1111
github.com/mark3labs/mcp-go v0.43.1
1212
github.com/modelcontextprotocol/go-sdk v1.1.0
13-
github.com/pkg/errors v0.9.1
1413
github.com/spf13/afero v1.15.0
1514
github.com/spf13/cobra v1.10.1
1615
github.com/spf13/pflag v1.0.10
@@ -104,6 +103,7 @@ require (
104103
github.com/opencontainers/go-digest v1.0.0 // indirect
105104
github.com/opencontainers/image-spec v1.1.1 // indirect
106105
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
106+
github.com/pkg/errors v0.9.1 // indirect
107107
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
108108
github.com/prometheus/client_golang v1.22.0 // indirect
109109
github.com/prometheus/client_model v0.6.1 // indirect

pkg/mcp/common_crd_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"golang.org/x/sync/errgroup"
9+
apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
10+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/types"
13+
"k8s.io/apimachinery/pkg/util/wait"
14+
"k8s.io/client-go/discovery"
15+
"k8s.io/utils/ptr"
16+
)
17+
18+
func CRD(group, version, resource, kind, singular string, namespaced bool) *apiextensionsv1spec.CustomResourceDefinition {
19+
scope := "Cluster"
20+
if namespaced {
21+
scope = "Namespaced"
22+
}
23+
crd := &apiextensionsv1spec.CustomResourceDefinition{
24+
TypeMeta: metav1.TypeMeta{
25+
APIVersion: apiextensionsv1spec.SchemeGroupVersion.String(),
26+
Kind: "CustomResourceDefinition",
27+
},
28+
ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s.%s", resource, group)},
29+
Spec: apiextensionsv1spec.CustomResourceDefinitionSpec{
30+
Group: group,
31+
Versions: []apiextensionsv1spec.CustomResourceDefinitionVersion{
32+
{
33+
Name: version,
34+
Served: false,
35+
Storage: true,
36+
Schema: &apiextensionsv1spec.CustomResourceValidation{
37+
OpenAPIV3Schema: &apiextensionsv1spec.JSONSchemaProps{
38+
Type: "object",
39+
XPreserveUnknownFields: ptr.To(true),
40+
},
41+
},
42+
},
43+
},
44+
Scope: apiextensionsv1spec.ResourceScope(scope),
45+
Names: apiextensionsv1spec.CustomResourceDefinitionNames{
46+
Plural: resource,
47+
Singular: singular,
48+
Kind: kind,
49+
ShortNames: []string{singular},
50+
},
51+
},
52+
}
53+
return crd
54+
}
55+
56+
func EnvTestEnableCRD(ctx context.Context, group, version, resource string) error {
57+
apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
58+
_, err := apiExtensionsV1Client.CustomResourceDefinitions().Patch(
59+
ctx,
60+
fmt.Sprintf("%s.%s", resource, group),
61+
types.JSONPatchType,
62+
[]byte(`[{"op": "replace", "path": "/spec/versions/0/served", "value": true}]`),
63+
metav1.PatchOptions{})
64+
if err != nil {
65+
return err
66+
}
67+
return EnvTestWaitForAPIResourceCondition(ctx, group, version, resource, true)
68+
}
69+
70+
func EnvTestDisableCRD(ctx context.Context, group, version, resource string) error {
71+
apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
72+
_, err := apiExtensionsV1Client.CustomResourceDefinitions().Patch(
73+
ctx,
74+
fmt.Sprintf("%s.%s", resource, group),
75+
types.JSONPatchType,
76+
[]byte(`[{"op": "replace", "path": "/spec/versions/0/served", "value": false}]`),
77+
metav1.PatchOptions{})
78+
if err != nil {
79+
return err
80+
}
81+
return EnvTestWaitForAPIResourceCondition(ctx, group, version, resource, false)
82+
}
83+
84+
func EnvTestWaitForAPIResourceCondition(ctx context.Context, group, version, resource string, shouldBeAvailable bool) error {
85+
discoveryClient, err := discovery.NewDiscoveryClientForConfig(envTestRestConfig)
86+
if err != nil {
87+
return fmt.Errorf("failed to create discovery client: %w", err)
88+
}
89+
90+
groupVersion := fmt.Sprintf("%s/%s", group, version)
91+
if group == "" {
92+
groupVersion = version
93+
}
94+
95+
return wait.PollUntilContextTimeout(ctx, 100*time.Millisecond, 10*time.Second, true, func(ctx context.Context) (bool, error) {
96+
resourceList, err := discoveryClient.ServerResourcesForGroupVersion(groupVersion)
97+
if err != nil {
98+
// If we're waiting for the resource to be unavailable and we get an error, it might be gone
99+
if !shouldBeAvailable {
100+
return true, nil
101+
}
102+
// Otherwise, keep polling
103+
return false, nil
104+
}
105+
106+
// Check if the resource exists in the list
107+
found := false
108+
for _, apiResource := range resourceList.APIResources {
109+
if apiResource.Name == resource {
110+
found = true
111+
break
112+
}
113+
}
114+
115+
// Return true if the condition is met
116+
if shouldBeAvailable {
117+
return found, nil
118+
}
119+
return !found, nil
120+
})
121+
}
122+
123+
// EnvTestInOpenShift sets up the kubernetes environment to seem to be running OpenShift
124+
func EnvTestInOpenShift(ctx context.Context) error {
125+
tasks, _ := errgroup.WithContext(ctx)
126+
tasks.Go(func() error { return EnvTestEnableCRD(ctx, "project.openshift.io", "v1", "projects") })
127+
tasks.Go(func() error { return EnvTestEnableCRD(ctx, "route.openshift.io", "v1", "routes") })
128+
return tasks.Wait()
129+
}
130+
131+
// EnvTestInOpenShiftClear clears the kubernetes environment so it no longer seems to be running OpenShift
132+
func EnvTestInOpenShiftClear(ctx context.Context) error {
133+
tasks, _ := errgroup.WithContext(ctx)
134+
tasks.Go(func() error { return EnvTestDisableCRD(ctx, "project.openshift.io", "v1", "projects") })
135+
tasks.Go(func() error { return EnvTestDisableCRD(ctx, "route.openshift.io", "v1", "routes") })
136+
return tasks.Wait()
137+
}

pkg/mcp/common_test.go

Lines changed: 16 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,20 @@ package mcp
22

33
import (
44
"context"
5-
"encoding/json"
6-
"fmt"
75
"os"
86
"path/filepath"
97
"runtime"
108
"testing"
11-
"time"
129

1310
"github.com/mark3labs/mcp-go/client/transport"
14-
"github.com/pkg/errors"
1511
"github.com/spf13/afero"
1612
"github.com/stretchr/testify/suite"
17-
"golang.org/x/sync/errgroup"
1813
corev1 "k8s.io/api/core/v1"
1914
rbacv1 "k8s.io/api/rbac/v1"
2015
apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
21-
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
2216
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23-
"k8s.io/apimachinery/pkg/watch"
2417
"k8s.io/client-go/kubernetes"
2518
"k8s.io/client-go/rest"
26-
toolswatch "k8s.io/client-go/tools/watch"
27-
"k8s.io/utils/ptr"
2819
"sigs.k8s.io/controller-runtime/pkg/envtest"
2920
"sigs.k8s.io/controller-runtime/tools/setup-envtest/env"
3021
"sigs.k8s.io/controller-runtime/tools/setup-envtest/remote"
@@ -49,6 +40,8 @@ func TestMain(m *testing.M) {
4940
// Set high rate limits to avoid client-side throttling in tests
5041
_ = os.Setenv("KUBE_CLIENT_QPS", "1000")
5142
_ = os.Setenv("KUBE_CLIENT_BURST", "2000")
43+
//// Enable control plane output to see API server logs
44+
//_ = os.Setenv("KUBEBUILDER_ATTACH_CONTROL_PLANE_OUTPUT", "true")
5245
envTestDir, err := store.DefaultStoreDir()
5346
if err != nil {
5447
panic(err)
@@ -73,7 +66,21 @@ func TestMain(m *testing.M) {
7366
versionDir := envTestEnv.Platform.BaseName(*envTestEnv.Version.AsConcrete())
7467
envTest = &envtest.Environment{
7568
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
69+
CRDs: []*apiextensionsv1spec.CustomResourceDefinition{
70+
CRD("project.openshift.io", "v1", "projects", "Project", "project", false),
71+
CRD("route.openshift.io", "v1", "routes", "Route", "route", true),
72+
},
7673
}
74+
// Configure API server for faster CRD establishment and test performance
75+
envTest.ControlPlane.GetAPIServer().Configure().
76+
// Increase concurrent request limits for faster parallel operations
77+
Set("max-requests-inflight", "1000").
78+
Set("max-mutating-requests-inflight", "500").
79+
// Speed up namespace cleanup with more workers
80+
Set("delete-collection-workers", "10") //.
81+
// Enable verbose logging for debugging
82+
//Set("v", "9")
83+
7784
adminSystemMasterBaseConfig, _ := envTest.Start()
7885
au := test.Must(envTest.AddUser(envTestUser, adminSystemMasterBaseConfig))
7986
envTestRestConfig = au.Config()
@@ -195,98 +202,3 @@ func (s *BaseMcpSuite) InitMcpClient(options ...transport.StreamableHTTPCOption)
195202
s.Require().NoError(err, "Expected no error creating MCP server")
196203
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(), options...)
197204
}
198-
199-
// EnvTestInOpenShift sets up the kubernetes environment to seem to be running OpenShift
200-
func EnvTestInOpenShift(ctx context.Context) error {
201-
crdTemplate := `
202-
{
203-
"apiVersion": "apiextensions.k8s.io/v1",
204-
"kind": "CustomResourceDefinition",
205-
"metadata": {"name": "%s"},
206-
"spec": {
207-
"group": "%s",
208-
"versions": [{
209-
"name": "v1","served": true,"storage": true,
210-
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
211-
}],
212-
"scope": "%s",
213-
"names": {"plural": "%s","singular": "%s","kind": "%s"}
214-
}
215-
}`
216-
tasks, _ := errgroup.WithContext(ctx)
217-
tasks.Go(func() error {
218-
return EnvTestCrdApply(ctx, fmt.Sprintf(crdTemplate, "projects.project.openshift.io", "project.openshift.io",
219-
"Cluster", "projects", "project", "Project"))
220-
})
221-
tasks.Go(func() error {
222-
return EnvTestCrdApply(ctx, fmt.Sprintf(crdTemplate, "routes.route.openshift.io", "route.openshift.io",
223-
"Namespaced", "routes", "route", "Route"))
224-
})
225-
return tasks.Wait()
226-
}
227-
228-
// EnvTestInOpenShiftClear clears the kubernetes environment so it no longer seems to be running OpenShift
229-
func EnvTestInOpenShiftClear(ctx context.Context) error {
230-
tasks, _ := errgroup.WithContext(ctx)
231-
tasks.Go(func() error { return EnvTestCrdDelete(ctx, "projects.project.openshift.io") })
232-
tasks.Go(func() error { return EnvTestCrdDelete(ctx, "routes.route.openshift.io") })
233-
return tasks.Wait()
234-
}
235-
236-
// EnvTestCrdWaitUntilReady waits for a CRD to be established
237-
func EnvTestCrdWaitUntilReady(ctx context.Context, name string) error {
238-
apiExtensionClient := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
239-
watcher, err := apiExtensionClient.CustomResourceDefinitions().Watch(ctx, metav1.ListOptions{
240-
FieldSelector: "metadata.name=" + name,
241-
})
242-
if err != nil {
243-
return fmt.Errorf("unable to watch CRDs: %w", err)
244-
}
245-
_, err = toolswatch.UntilWithoutRetry(ctx, watcher, func(event watch.Event) (bool, error) {
246-
for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions {
247-
if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue {
248-
return true, nil
249-
}
250-
}
251-
return false, nil
252-
})
253-
if err != nil {
254-
return fmt.Errorf("failed to wait for CRD: %w", err)
255-
}
256-
return nil
257-
}
258-
259-
// EnvTestCrdApply creates a CRD from the provided resource string and waits for it to be established
260-
func EnvTestCrdApply(ctx context.Context, resource string) error {
261-
apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
262-
var crd = &apiextensionsv1spec.CustomResourceDefinition{}
263-
err := json.Unmarshal([]byte(resource), crd)
264-
if err != nil {
265-
return fmt.Errorf("failed to create CRD %v", err)
266-
}
267-
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(ctx, crd, metav1.CreateOptions{})
268-
if err != nil {
269-
return fmt.Errorf("failed to create CRD %v", err)
270-
}
271-
return EnvTestCrdWaitUntilReady(ctx, crd.Name)
272-
}
273-
274-
// crdDelete deletes a CRD by name and waits for it to be removed
275-
func EnvTestCrdDelete(ctx context.Context, name string) error {
276-
apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
277-
err := apiExtensionsV1Client.CustomResourceDefinitions().Delete(ctx, name, metav1.DeleteOptions{
278-
GracePeriodSeconds: ptr.To(int64(0)),
279-
})
280-
iteration := 0
281-
for iteration < 100 {
282-
if _, derr := apiExtensionsV1Client.CustomResourceDefinitions().Get(ctx, name, metav1.GetOptions{}); derr != nil {
283-
break
284-
}
285-
time.Sleep(5 * time.Millisecond)
286-
iteration++
287-
}
288-
if err != nil {
289-
return errors.Wrap(err, "failed to delete CRD")
290-
}
291-
return nil
292-
}

pkg/mcp/resources_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ func (s *ResourcesSuite) TestResourcesCreateOrUpdate() {
401401
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(s.T().Context(), "customs.example.com", metav1.GetOptions{})
402402
s.Nilf(err, "custom resource definition not found")
403403
})
404-
s.Require().NoError(EnvTestCrdWaitUntilReady(s.T().Context(), "customs.example.com"))
404+
s.Require().NoError(EnvTestWaitForAPIResourceCondition(s.T().Context(), "example.com", "v1", "customs", true))
405405
})
406406

407407
s.Run("resources_create_or_update creates custom resource", func() {

0 commit comments

Comments
 (0)