From f1f1f6e823fd3afe8bf154744b5b4c400c071788 Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 17 Jun 2026 19:53:58 +0200 Subject: [PATCH 1/2] feat: add envtest mode for fast in-process e2e testing Add envtest mode for fast in-process e2e testing. This alternative testing mode runs the same test cases as cluster mode but executes them in-process using controller-runtime's envtest framework. Run with: make test-e2e-envtest PROVIDER=cisco-nxos-gnmi New files: - envtest_suite_test.go: Ginkgo suite with //go:build envtest tag - envtest_test.go: Provider-based test runner - testutil/envtest.go: EnvtestEnvironment with in-process gNMI server Signed-off-by: Pujol --- go.mod | 2 + go.sum | 2 + test/e2e/envtest_suite_test.go | 42 +++ test/e2e/envtest_test.go | 634 +++++++++++++++++++++++++++++++++ test/e2e/testutil/envtest.go | 186 ++++++++++ 5 files changed, 866 insertions(+) create mode 100644 test/e2e/envtest_suite_test.go create mode 100644 test/e2e/envtest_test.go create mode 100644 test/e2e/testutil/envtest.go diff --git a/go.mod b/go.mod index 7e41b4f9..22aa5b0a 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/ironcore-dev/gnmi-test-server v0.0.0-00010101000000-000000000000 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -90,6 +91,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect diff --git a/go.sum b/go.sum index 4ae727d6..cf17074f 100644 --- a/go.sum +++ b/go.sum @@ -191,10 +191,12 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= diff --git a/test/e2e/envtest_suite_test.go b/test/e2e/envtest_suite_test.go new file mode 100644 index 00000000..c8cfa522 --- /dev/null +++ b/test/e2e/envtest_suite_test.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build envtest + +package e2e + +import ( + "fmt" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/ironcore-dev/network-operator/test/e2e/testutil" +) + +// TestE2E runs the e2e test suite in envtest mode. +func TestEnvtest(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting network-operator tests in ENVTEST mode\n") + RunSpecs(t, "e2e suite (envtest)") +} + +// BeforeSuite initializes the envtest environment. +// Envtest runs in-process, so no special parallel handling is needed. +var _ = BeforeSuite(func(ctx SpecContext) { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + SetDefaultEventuallyTimeout(testutil.DefaultTimeout) + SetDefaultEventuallyPollingInterval(time.Second) + + initTestEnv(ctx) +}) + +// AfterSuite cleans up the envtest environment. +var _ = AfterSuite(func(ctx SpecContext) { + fmt.Fprintf(GinkgoWriter, "Tearing down test environment...\n") + cleanupTestEnv(ctx) +}) diff --git a/test/e2e/envtest_test.go b/test/e2e/envtest_test.go new file mode 100644 index 00000000..852089b6 --- /dev/null +++ b/test/e2e/envtest_test.go @@ -0,0 +1,634 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build envtest + +package e2e + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/tools/txtar" + + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + nx "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" + "github.com/ironcore-dev/network-operator/internal/controller/core" + "github.com/ironcore-dev/network-operator/internal/resourcelock" + "github.com/ironcore-dev/network-operator/test/e2e/testutil" +) + +// reconcileTestNamespacePrefix is used with GenerateName to create unique test namespaces. +// This isolates tests from other resources in the cluster (e.g., from the deployed operator). +const reconcileTestNamespacePrefix = "reconcile-test-" + +// testEnv is the envtest test environment. +var testEnv *testutil.EnvtestEnvironment + +func init() { + _, _ = fmt.Fprintf(GinkgoWriter, "Starting network-operator tests in ENVTEST mode\n") +} + +// initTestEnv initializes the envtest environment. +func initTestEnv(ctx SpecContext) { + By("initializing envtest environment") + testEnv = testutil.NewEnvtestEnvironment() + Expect(testEnv.Setup(ctx)).To(Succeed()) +} + +// cleanupTestEnv performs envtest-specific cleanup (none needed). +func cleanupTestEnv(_ SpecContext) { + // No additional cleanup needed for envtest +} + +// ============================================================================ +// Provider test helpers +// ============================================================================ + +// ProviderTestContext holds the context for a provider-specific test run. +type ProviderTestContext struct { + Provider testutil.ProviderType + Manager ctrl.Manager + Locker *resourcelock.ResourceLocker + Namespace string + CancelFunc context.CancelFunc +} + +// SetupProviderTest creates a new manager with controllers for the given provider. +// The manager only watches the specified namespace to avoid conflicts with other controllers. +// Call the returned cleanup function in AfterEach/AfterAll. +func SetupProviderTest(providerCfg testutil.ProviderConfig, k8sClient client.Client, restCfg *rest.Config, namespace string) *ProviderTestContext { + GinkgoHelper() + + providerCtx, providerCancel := context.WithCancel(context.Background()) //nolint:gosec // cancel stored in ProviderTestContext + + mgr, err := ctrl.NewManager(restCfg, ctrl.Options{ + Scheme: k8sClient.Scheme(), + Logger: GinkgoLogr, + Cache: cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Ignore events during tests + recorder := events.NewFakeRecorder(0) + go func() { + for range recorder.Events { //nolint:revive // intentionally drain events + } + }() + + locker, err := resourcelock.NewResourceLocker(mgr.GetClient(), namespace, 15*time.Second, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + err = mgr.Add(locker) + Expect(err).NotTo(HaveOccurred()) + + providerFunc := providerCfg.NewProvider + + // Register all controllers + registerControllers(providerCtx, mgr, recorder, providerFunc, locker) + + go func() { + defer GinkgoRecover() + err = mgr.Start(providerCtx) + if providerCtx.Err() == nil { + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + } + }() + + return &ProviderTestContext{ + Provider: providerCfg.Name, + Manager: mgr, + Locker: locker, + Namespace: namespace, + CancelFunc: providerCancel, + } +} + +// TeardownProviderTest stops the manager for a provider test. +func TeardownProviderTest(ptc *ProviderTestContext) { + if ptc != nil && ptc.CancelFunc != nil { + ptc.CancelFunc() + } +} + +// registerControllers registers all controllers with the manager. +func registerControllers(ctx context.Context, mgr ctrl.Manager, recorder *events.FakeRecorder, providerFunc testutil.ProviderFactory, locker *resourcelock.ResourceLocker) { + var err error + + err = (&core.PrefixSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.RoutingPolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.InterfaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VLANReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VRFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NTPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DNSReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.LLDPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BannerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.OSPFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.PIMReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NetworkVirtualizationEdgeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.EVPNInstanceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + // NX-OS specific controllers + err = (&nx.VPCDomainReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPPeerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.SyslogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.SNMPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.ManagementAccessReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.AccessControlListReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DHCPRelayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.ISISReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) +} + +// CreateTestDevice creates a Device pointing to the gNMI server with a generated name. +func CreateTestDevice(ctx context.Context, c client.Client, gnmiAddr, namespace string) (*v1alpha1.Device, error) { + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: namespace, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: gnmiAddr, + }, + }, + } + if err := c.Create(ctx, device); err != nil { + return nil, err + } + + // Set the device status to Running so that dependent resources can reconcile + device.Status.Phase = v1alpha1.DevicePhaseRunning + if err := c.Status().Update(ctx, device); err != nil { + return nil, err + } + + return device, nil +} + +// ============================================================================ +// Reconciliation tests +// ============================================================================ + +var _ = Describe("gNMI requests tests", func() { + // Resolve provider during tree construction so we can generate individual It nodes + projectDir, err := testutil.GetProjectDir() + if err != nil { + Fail(fmt.Sprintf("Failed to get project directory: %v", err)) + } + + provider := os.Getenv("E2E_PROVIDER") + providerNames := make([]string, len(testutil.SupportedProviders)) + for i, cfg := range testutil.SupportedProviders { + providerNames[i] = string(cfg.Name) + } + + // If provider is invalid, create a failing test with clear message + if provider == "" { + It("requires E2E_PROVIDER to be set", func() { + Fail(fmt.Sprintf("E2E_PROVIDER not set. Please set E2E_PROVIDER to one of: %s", strings.Join(providerNames, ", "))) + }) + return + } + + providerIdx := slices.IndexFunc(testutil.SupportedProviders, func(cfg testutil.ProviderConfig) bool { + return string(cfg.Name) == provider + }) + if providerIdx < 0 { + It("requires valid E2E_PROVIDER", func() { + Fail(fmt.Sprintf("E2E_PROVIDER=%q is not a supported provider. Valid values: %s", provider, strings.Join(providerNames, ", "))) + }) + return + } + + providerCfg := testutil.SupportedProviders[providerIdx] + testdataDir := filepath.Join(projectDir, "test", "e2e", "testdata", string(providerCfg.Name)) + + if _, err := os.Stat(testdataDir); os.IsNotExist(err) { + It("requires testdata directory", func() { + Fail(fmt.Sprintf("Testdata directory does not exist for provider %q: %s", provider, testdataDir)) + }) + return + } + + // Discover test files during tree construction + testFiles, err := filepath.Glob(filepath.Join(testdataDir, "*.txt")) + if err != nil || len(testFiles) == 0 { + It("requires test files", func() { + if err != nil { + Fail(fmt.Sprintf("Failed to glob testdata: %v", err)) + } + Fail(fmt.Sprintf("No test files found in %s", testdataDir)) + }) + return + } + + Describe(fmt.Sprintf("Provider: %s", providerCfg.Name), Ordered, func() { + var ptc *ProviderTestContext + var device *v1alpha1.Device + var testNamespace string + + BeforeAll(func(ctx SpecContext) { + By("creating dedicated test namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: reconcileTestNamespacePrefix, + }, + } + Expect(testEnv.Client().Create(ctx, ns)).To(Succeed()) + testNamespace = ns.Name + + By(fmt.Sprintf("setting up %s provider", providerCfg.Name)) + ptc = SetupProviderTest(providerCfg, testEnv.Client(), testEnv.RESTConfig(), testNamespace) + }) + + AfterAll(func(ctx SpecContext) { + By(fmt.Sprintf("tearing down %s provider manager", providerCfg.Name)) + TeardownProviderTest(ptc) + + By("deleting test namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + } + _ = testEnv.Client().Delete(ctx, ns) + }) + + AfterEach(func(ctx SpecContext) { + By("cleaning up resources") + cleanupAllResources(testEnv.Client(), testNamespace) + + if device == nil { + return + } + + By("deleting test device") + Expect(client.IgnoreNotFound(testEnv.Client().Delete(ctx, device))).To(Succeed()) + Eventually(func(g Gomega) { + err := testEnv.Client().Get(ctx, client.ObjectKeyFromObject(device), &v1alpha1.Device{}) + g.Expect(client.IgnoreNotFound(err)).To(Succeed()) + g.Expect(err).To(HaveOccurred(), "Device should be deleted") + }).Should(Succeed()) + device = nil + + By("clearing gNMI state for next test") + Expect(testEnv.ClearGNMIState(ctx)).To(Succeed()) + }) + + // Generate individual It nodes for each test file + for _, testFile := range testFiles { + testFile := testFile // capture for closure + testName := filepath.Base(testFile) + testName = testName[:len(testName)-4] // remove .txt + + It("should reconcile "+testName, func(ctx SpecContext) { + By("parsing testdata file") + a, err := txtar.ParseFile(testFile) + Expect(err).NotTo(HaveOccurred(), "Failed to parse test file: %s", testFile) + Expect(len(a.Files)).To(BeNumerically(">=", 2), "Expected at least 2 files (resource(s) and state)") + + var state, preload []byte + var resources []txtar.File + for _, f := range a.Files { + switch f.Name { + case "state/expect": + state = f.Data + case "state/preload": + preload = f.Data + default: + resources = append(resources, f) + } + } + Expect(state).NotTo(BeEmpty(), "Expected '-- state/expect --' section in testdata") + Expect(resources).NotTo(BeEmpty(), "Expected at least one resource in testdata") + + // Preload gNMI state BEFORE creating Device (e.g., bootTime for Device controller) + if len(preload) > 0 { + By("preloading gNMI state") + Expect(testEnv.PreloadGNMIState(ctx, preload)).To(Succeed(), "Failed to preload gNMI state") + } + + By("creating test device") + device, err = testutil.CreateTestDevice(ctx, testEnv.Client(), testEnv.GNMIAddress(), testNamespace) + Expect(err).NotTo(HaveOccurred()) + + By(fmt.Sprintf("creating %d resource(s) from testdata", len(resources))) + for _, res := range resources { + obj := createResourceFromTxtar(ctx, testEnv.Client(), res, device.Name, testNamespace) + waitForResource(ctx, testEnv.Client(), obj) + } + + By("verifying gNMI state matches expected JSON") + gnmiState, err := testEnv.GetGNMIState(ctx) + Expect(err).NotTo(HaveOccurred()) + + err = testutil.CompareJSON(string(gnmiState), string(state)) + Expect(err).NotTo(HaveOccurred(), "gNMI state does not match expected JSON") + }) + } + }) +}) + +// createResourceFromTxtar creates a K8s resource from txtar file data. +// The file name format is "kind/name" (e.g., "prefixset/my-prefixset"). +// It substitutes "device" in deviceRef.name with the actual device name. +func createResourceFromTxtar(ctx SpecContext, c client.Client, res txtar.File, deviceName, namespace string) client.Object { + obj := &unstructured.Unstructured{} + Expect(yaml.Unmarshal(res.Data, obj)).To(Succeed(), "Failed to unmarshal %s", res.Name) + + // Set the namespace + obj.SetNamespace(namespace) + + // Update deviceRef.name to use the actual device name + if spec, ok := obj.Object["spec"].(map[string]any); ok { + if deviceRef, ok := spec["deviceRef"].(map[string]any); ok { + deviceRef["name"] = deviceName + } + } + // Also update the device label + labels := obj.GetLabels() + if labels != nil { + if _, ok := labels[v1alpha1.DeviceLabel]; ok { + labels[v1alpha1.DeviceLabel] = deviceName + obj.SetLabels(labels) + } + } + + Expect(c.Create(ctx, obj)).To(Succeed(), "Failed to create %s", res.Name) + return obj +} + +// waitForResource waits for a resource to be in Ready=True or Configured=True. +// If Configured condition exists, it checks it, otherwise it falls back to Ready condition. +// Skips config-only --controller-less-- resources that don't have status conditions (e.g., InterfaceConfig). +func waitForResource(ctx SpecContext, c client.Client, obj client.Object) { + key := client.ObjectKeyFromObject(obj) + gvk := obj.GetObjectKind().GroupVersionKind() + + // Add as needed. + switch gvk.Kind { + case "InterfaceConfig", "LLDPConfig", "BGPConfig", "NVEConfig", "ManagementAccessConfig": + return + } + + Eventually(func(g Gomega) { + r := &unstructured.Unstructured{} + r.SetGroupVersionKind(gvk) + g.Expect(c.Get(ctx, key, r)).To(Succeed()) + + conditions, err := testutil.ExtractConditions(r) + g.Expect(err).NotTo(HaveOccurred()) + + conditionToCheck := string(v1alpha1.ReadyCondition) + if apimeta.FindStatusCondition(conditions, string(v1alpha1.ConfiguredCondition)) != nil { + conditionToCheck = string(v1alpha1.ConfiguredCondition) + } + + g.Expect(apimeta.IsStatusConditionTrue(conditions, conditionToCheck)).To(BeTrue()) + }).Should(Succeed()) +} + +// cleanupAllResources deletes all test resources and lets the controller handle finalizer cleanup. +// Uses a background context with timeout to ensure cleanup completes even on interrupt. +// Deletion order: CoreResources first (have finalizers), then ConfigResources, then Device. +func cleanupAllResources(c client.Client, namespace string) { + // Use background context with timeout - cleanup must complete even on Ctrl+C + cleanupCtx, cancel := context.WithTimeout(context.Background(), testutil.LongTimeout) + defer cancel() + + deleteResources := func(gvks []schema.GroupVersionKind) { + for _, gvk := range gvks { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + + if err := c.List(cleanupCtx, list, client.InNamespace(namespace)); err != nil { + if apimeta.IsNoMatchError(err) { + continue // CRD not installed, skip + } + Expect(err).NotTo(HaveOccurred(), "Failed to list %s", gvk.Kind) + } + + // Delete all resources - controller will handle finalizer removal + for _, item := range list.Items { + Expect(client.IgnoreNotFound(c.Delete(cleanupCtx, &item))).To(Succeed()) + } + } + } + + // Delete core resources first (have finalizers that need Device + configs) + deleteResources(testutil.CoreResources) + // Then delete config resources + deleteResources(testutil.ConfigResources) +} diff --git a/test/e2e/testutil/envtest.go b/test/e2e/testutil/envtest.go new file mode 100644 index 00000000..24484076 --- /dev/null +++ b/test/e2e/testutil/envtest.go @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "context" + "os" + "path/filepath" + "slices" + + gnmitestserver "github.com/ironcore-dev/gnmi-test-server/testserver" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// EnvtestEnvironment implements TestEnvironment using envtest and an in-process gNMI server. +type EnvtestEnvironment struct { + testEnv *envtest.Environment + restConfig *rest.Config + k8sClient client.Client + gnmiServer *gnmitestserver.Server + gnmiAddr string + cancel context.CancelFunc +} + +// NewEnvtestEnvironment creates a new envtest-based test environment. +func NewEnvtestEnvironment() *EnvtestEnvironment { + return &EnvtestEnvironment{} +} + +// Setup initializes envtest and starts the in-process gNMI server. +func (e *EnvtestEnvironment) Setup(ctx context.Context) error { + ctx, e.cancel = context.WithCancel(ctx) + + // Start in-process gNMI test server with NX-OS behavior + var err error + e.gnmiServer, e.gnmiAddr, _, err = gnmitestserver.NewTestServer(ctx, gnmitestserver.WithNXOSBehavior()) + if err != nil { + return err + } + + // Register schemes + if err := corev1.AddToScheme(scheme.Scheme); err != nil { + return err + } + if err := v1alpha1.AddToScheme(scheme.Scheme); err != nil { + return err + } + if err := nxv1alpha1.AddToScheme(scheme.Scheme); err != nil { + return err + } + + // Start envtest + e.testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Detect test binary directory for IDEs + if dir := detectTestBinaryDir(); dir != "" { + e.testEnv.BinaryAssetsDirectory = dir + } + + e.restConfig, err = e.testEnv.Start() + if err != nil { + return err + } + + e.k8sClient, err = client.New(e.restConfig, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return err + } + + // Wait for default namespace to be ready + for { + var ns corev1.Namespace + if err := e.k8sClient.Get(ctx, client.ObjectKey{Name: metav1.NamespaceDefault}, &ns); err == nil { + break + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + + return nil +} + +// Teardown stops envtest and the gNMI server. +func (e *EnvtestEnvironment) Teardown(_ context.Context) error { + if e.cancel != nil { + e.cancel() + } + + if e.gnmiServer != nil { + if err := e.gnmiServer.Close(); err != nil { + return err + } + } + + if e.testEnv != nil { + if err := e.testEnv.Stop(); err != nil { + return err + } + } + + return nil +} + +// Client returns the Kubernetes client. +func (e *EnvtestEnvironment) Client() client.Client { + return e.k8sClient +} + +// RESTConfig returns the REST config. +func (e *EnvtestEnvironment) RESTConfig() *rest.Config { + return e.restConfig +} + +// GNMIAddress returns the in-process gNMI server address. +func (e *EnvtestEnvironment) GNMIAddress() string { + return e.gnmiAddr +} + +// GetGNMIState fetches state directly from the in-process server. +func (e *EnvtestEnvironment) GetGNMIState(_ context.Context) ([]byte, error) { + return e.gnmiServer.GetState() +} + +// ClearGNMIState clears state directly on the in-process server. +func (e *EnvtestEnvironment) ClearGNMIState(_ context.Context) error { + e.gnmiServer.ClearState() + return nil +} + +// PreloadGNMIState replaces the in-process gNMI server state with the given JSON. +// This resets the server to a clean state for test isolation. +func (e *EnvtestEnvironment) PreloadGNMIState(_ context.Context, jsonData []byte) error { + if len(jsonData) == 0 { + return nil + } + // Replace entire state by directly setting Buf (thread-safe with Lock) + e.gnmiServer.State.Lock() + e.gnmiServer.State.Buf = jsonData + e.gnmiServer.State.Unlock() + return nil +} + +// IsEnvtest returns true for envtest mode. +func (e *EnvtestEnvironment) IsEnvtest() bool { + return true +} + +// detectTestBinaryDir locates the envtest binary directory. +// The structure is: bin/k8s/k8s/--/ +// We need to find the version directory that contains etcd/kube-apiserver. +func detectTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + return "" + } + // Find a version directory (e.g., "1.35.0-darwin-arm64") + idx := slices.IndexFunc(entries, func(e os.DirEntry) bool { + if !e.IsDir() { + return false + } + // Check if this directory contains etcd + etcdPath := filepath.Join(basePath, e.Name(), "etcd") + _, err := os.Stat(etcdPath) + return err == nil + }) + if idx >= 0 { + return filepath.Join(basePath, entries[idx].Name()) + } + return "" +} From bce5bb055bf1b42eea4f55cd0a6a1a2579891f3b Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 18 Jun 2026 10:04:34 +0200 Subject: [PATCH 2/2] build: add test-e2e-envtest target for envtest mode Add test-e2e-envtest target that runs e2e tests in envtest mode (no cluster required). Uses setup-envtest to provide kubebuilder assets. Signed-off-by: Pujol --- Makefile | 5 +++++ Makefile.maker.yaml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Makefile b/Makefile index 73ed4b96..6a9221c8 100644 --- a/Makefile +++ b/Makefile @@ -107,6 +107,11 @@ test-e2e-cluster: FORCE install-ginkgo @printf "\e[1;36m>> ginkgo -procs=$(GINKGO_PROCS) -tags=cluster -timeout=20m -v ./test/e2e/ (PROVIDER=$(PROVIDER))\e[0m\n" @KIND_CLUSTER=$(KIND_CLUSTER) E2E_PROVIDER=$(PROVIDER) ginkgo -procs=$(GINKGO_PROCS) -tags=cluster -timeout=20m -v ./test/e2e/ +# Run gNMI controller tests in envtest mode (no cluster required). +test-e2e-envtest: FORCE install-setup-envtest + @printf "\e[1;36m>> go test ./test/e2e/ -tags=envtest -v -ginkgo.v (PROVIDER=$(PROVIDER))\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.32 -p path) E2E_PROVIDER=$(PROVIDER) go test ./test/e2e/ -tags=envtest -v -ginkgo.v + docker-build: FORCE @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(IMG) .\e[0m\n" @$(CONTAINER_TOOL) build --build-arg=BININFO_BUILD_DATE=$(BININFO_BUILD_DATE) --build-arg=BININFO_COMMIT_HASH=$(BININFO_COMMIT_HASH) --build-arg=BININFO_VERSION=$(BININFO_VERSION) --tag=$(IMG) . diff --git a/Makefile.maker.yaml b/Makefile.maker.yaml index fe8639dd..3ecaefc5 100644 --- a/Makefile.maker.yaml +++ b/Makefile.maker.yaml @@ -149,6 +149,11 @@ verbatim: | @printf "\e[1;36m>> ginkgo -procs=$(GINKGO_PROCS) -tags=cluster -timeout=20m -v ./test/e2e/ (PROVIDER=$(PROVIDER))\e[0m\n" @KIND_CLUSTER=$(KIND_CLUSTER) E2E_PROVIDER=$(PROVIDER) ginkgo -procs=$(GINKGO_PROCS) -tags=cluster -timeout=20m -v ./test/e2e/ + # Run gNMI controller tests in envtest mode (no cluster required). + test-e2e-envtest: FORCE install-setup-envtest + @printf "\e[1;36m>> go test ./test/e2e/ -tags=envtest -v -ginkgo.v (PROVIDER=$(PROVIDER))\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.32 -p path) E2E_PROVIDER=$(PROVIDER) go test ./test/e2e/ -tags=envtest -v -ginkgo.v + docker-build: FORCE @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(IMG) .\e[0m\n" @$(CONTAINER_TOOL) build --build-arg=BININFO_BUILD_DATE=$(BININFO_BUILD_DATE) --build-arg=BININFO_COMMIT_HASH=$(BININFO_COMMIT_HASH) --build-arg=BININFO_VERSION=$(BININFO_VERSION) --tag=$(IMG) .