diff --git a/pkg/state/filter.go b/pkg/state/filter.go index 9a0069b408..a10aa24e1c 100644 --- a/pkg/state/filter.go +++ b/pkg/state/filter.go @@ -18,6 +18,8 @@ limitations under the License. package state import ( + "os" + "strconv" "strings" "github.com/nmstate/kubernetes-nmstate/api/shared" @@ -220,6 +222,63 @@ func isUnmanaged(ifaceData map[string]interface{}) bool { return ifaceData["state"] == "ignore" } +const ( + // defaultInterfaceCountThreshold is the default number of interfaces + // above which verbose fields are stripped from VLAN interfaces to + // reduce the NodeNetworkState object size and avoid exceeding the + // etcd request size limit (1.5 MB). Can be overridden via the + // VLAN_FILTER_INTERFACE_COUNT_THRESHOLD environment variable. + defaultInterfaceCountThreshold = 500 + + // vlanInterfaceType is the nmstate type string for VLAN interfaces. + vlanInterfaceType = "vlan" +) + +// getInterfaceCountThreshold returns the interface count threshold, +// checking the VLAN_FILTER_INTERFACE_COUNT_THRESHOLD environment variable +// first, falling back to the default value. +func getInterfaceCountThreshold() int { + if val, ok := os.LookupEnv("VLAN_FILTER_INTERFACE_COUNT_THRESHOLD"); ok { + if n, err := strconv.Atoi(val); err == nil && n > 0 { + return n + } + } + return defaultInterfaceCountThreshold +} + +// vlanEssentialFields is the set of fields to preserve on VLAN interfaces +// when the interface count exceeds the threshold. All other fields are +// stripped to reduce serialized size. +var vlanEssentialFields = map[string]struct{}{ + "name": {}, + "type": {}, + "state": {}, + "ipv4": {}, + "ipv6": {}, + "vlan": {}, +} + +// stripVerboseVlanFields removes non-essential fields from VLAN interfaces +// when the total interface count exceeds the threshold. This prevents the +// NodeNetworkState from exceeding the etcd request size limit on nodes +// with large numbers of VLANs. +func stripVerboseVlanFields(interfaces []interfaceState) []interfaceState { + if len(interfaces) <= getInterfaceCountThreshold() { + return interfaces + } + for i := range interfaces { + if interfaces[i].Type != vlanInterfaceType { + continue + } + for key := range interfaces[i].Data { + if _, keep := vlanEssentialFields[key]; !keep { + delete(interfaces[i].Data, key) + } + } + } + return interfaces +} + func filterOut(currentState shared.State) (shared.State, error) { var state rootState if err := yaml.Unmarshal(currentState.Raw, &state); err != nil { @@ -227,6 +286,7 @@ func filterOut(currentState shared.State) (shared.State, error) { } state.Interfaces = filterOutInterfaces(state.Interfaces) + state.Interfaces = stripVerboseVlanFields(state.Interfaces) if state.Routes != nil { state.Routes.Running = filterOutRoutes(state.Routes.Running, state.Interfaces) state.Routes.Config = filterOutRoutes(state.Routes.Config, state.Interfaces) diff --git a/pkg/state/filter_test.go b/pkg/state/filter_test.go index da04a47957..3bb7e5ad38 100644 --- a/pkg/state/filter_test.go +++ b/pkg/state/filter_test.go @@ -18,10 +18,14 @@ limitations under the License. package state import ( + "fmt" + "os" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" nmstate "github.com/nmstate/kubernetes-nmstate/api/shared" + "sigs.k8s.io/yaml" ) var _ = Describe("FilterOut", func() { @@ -389,6 +393,167 @@ routes: Expect(returnedState).To(MatchYAML(filteredState)) }) }) + + Context("when the number of interfaces exceeds the threshold", func() { + It("should strip verbose fields from VLAN interfaces but keep essential ones", func() { + // Build a state with more than defaultInterfaceCountThreshold interfaces + // by generating VLAN interfaces + yamlStr := "interfaces:\n" + yamlStr += "- name: eth0\n state: up\n type: ethernet\n mtu: 1500\n mac-address: 00:11:22:33:44:55\n" + for i := 0; i < defaultInterfaceCountThreshold+10; i++ { + yamlStr += fmt.Sprintf(`- name: eth0.%d + type: vlan + state: up + mtu: 1400 + mac-address: 02:00:00:%02x:%02x:00 + ipv4: + enabled: true + address: + - ip: 192.168.%d.1 + prefix-length: 24 + vlan: + id: %d + base-iface: eth0 + lldp: + enabled: false + ethtool: + feature: + tx-checksum-ip-generic: true +`, i, i/256, i%256, i%256, i) + } + yamlStr += "routes:\n config: []\n running: []\n" + + state := nmstate.NewState(yamlStr) + result, err := filterOut(state) + Expect(err).ToNot(HaveOccurred()) + + // Parse the result to verify + var parsed rootState + err = yaml.Unmarshal(result.Raw, &parsed) + Expect(err).ToNot(HaveOccurred()) + + // Ethernet interface should be untouched + eth0 := parsed.Interfaces[0] + Expect(eth0.Name).To(Equal("eth0")) + Expect(eth0.Data).To(HaveKey("mtu")) + Expect(eth0.Data).To(HaveKey("mac-address")) + + // VLAN interfaces should have verbose fields stripped + vlan0 := parsed.Interfaces[1] + Expect(vlan0.Name).To(Equal("eth0.0")) + Expect(vlan0.Type).To(Equal("vlan")) + Expect(vlan0.Data).To(HaveKey("state")) + Expect(vlan0.Data).To(HaveKey("vlan")) + Expect(vlan0.Data).To(HaveKey("ipv4")) + // Verbose fields should be gone + Expect(vlan0.Data).NotTo(HaveKey("mtu")) + Expect(vlan0.Data).NotTo(HaveKey("mac-address")) + Expect(vlan0.Data).NotTo(HaveKey("lldp")) + Expect(vlan0.Data).NotTo(HaveKey("ethtool")) + }) + + It("should not strip fields when interface count is below threshold", func() { + state := nmstate.NewState(` +interfaces: +- name: eth0 + state: up + type: ethernet + mtu: 1500 +- name: eth0.100 + type: vlan + state: up + mtu: 1400 + mac-address: 02:00:00:00:64:00 + ipv4: + enabled: true + vlan: + id: 100 + base-iface: eth0 + lldp: + enabled: false +routes: + config: [] + running: [] +`) + result, err := filterOut(state) + Expect(err).ToNot(HaveOccurred()) + + var parsed rootState + err = yaml.Unmarshal(result.Raw, &parsed) + Expect(err).ToNot(HaveOccurred()) + + // VLAN should retain all fields when below threshold + vlan := parsed.Interfaces[1] + Expect(vlan.Data).To(HaveKey("mtu")) + Expect(vlan.Data).To(HaveKey("mac-address")) + Expect(vlan.Data).To(HaveKey("lldp")) + }) + }) + + Context("when the VLAN_FILTER_INTERFACE_COUNT_THRESHOLD env var is set", func() { + AfterEach(func() { + os.Unsetenv("VLAN_FILTER_INTERFACE_COUNT_THRESHOLD") + }) + + It("should use the env var value as the threshold", func() { + os.Setenv("VLAN_FILTER_INTERFACE_COUNT_THRESHOLD", "1") + + // With threshold=1, even 2 interfaces should trigger stripping + state := nmstate.NewState(` +interfaces: +- name: eth0 + state: up + type: ethernet + mtu: 1500 + mac-address: 00:11:22:33:44:55 +- name: eth0.100 + type: vlan + state: up + mtu: 1400 + mac-address: 02:00:00:00:64:00 + ipv4: + enabled: true + vlan: + id: 100 + base-iface: eth0 + lldp: + enabled: false +routes: + config: [] + running: [] +`) + result, err := filterOut(state) + Expect(err).ToNot(HaveOccurred()) + + var parsed rootState + err = yaml.Unmarshal(result.Raw, &parsed) + Expect(err).ToNot(HaveOccurred()) + + // VLAN should have verbose fields stripped + vlan := parsed.Interfaces[1] + Expect(vlan.Data).NotTo(HaveKey("mtu")) + Expect(vlan.Data).NotTo(HaveKey("mac-address")) + Expect(vlan.Data).NotTo(HaveKey("lldp")) + // Essential fields preserved + Expect(vlan.Data).To(HaveKey("state")) + Expect(vlan.Data).To(HaveKey("vlan")) + Expect(vlan.Data).To(HaveKey("ipv4")) + }) + + It("should fall back to default when env var is invalid", func() { + os.Setenv("VLAN_FILTER_INTERFACE_COUNT_THRESHOLD", "not-a-number") + + Expect(getInterfaceCountThreshold()).To(Equal(defaultInterfaceCountThreshold)) + }) + + It("should fall back to default when env var is zero or negative", func() { + os.Setenv("VLAN_FILTER_INTERFACE_COUNT_THRESHOLD", "0") + Expect(getInterfaceCountThreshold()).To(Equal(defaultInterfaceCountThreshold)) + + os.Setenv("VLAN_FILTER_INTERFACE_COUNT_THRESHOLD", "-5") + Expect(getInterfaceCountThreshold()).To(Equal(defaultInterfaceCountThreshold)) + }) + }) }) var _ = Describe("CountRoutes", func() { diff --git a/test/e2e/handler/nns_vlan_filtering_test.go b/test/e2e/handler/nns_vlan_filtering_test.go new file mode 100644 index 0000000000..75a83a3c77 --- /dev/null +++ b/test/e2e/handler/nns_vlan_filtering_test.go @@ -0,0 +1,465 @@ +/* +Copyright The Kubernetes NMState 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 handler + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/tidwall/gjson" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + nmstatenode "github.com/nmstate/kubernetes-nmstate/pkg/node" + testenv "github.com/nmstate/kubernetes-nmstate/test/env" + "github.com/nmstate/kubernetes-nmstate/test/runner" +) + +const ( + // vlanFilterTestThreshold is the threshold used during E2E tests. + // We set it low so we can trigger stripping with a small number of VLANs. + vlanFilterTestThreshold = "5" + + // Number of VLAN interfaces to create in the above-threshold test. + // Must exceed vlanFilterTestThreshold. + testVlanCount = 10 + + // handlerRolloutTimeout is the time to wait for the handler DaemonSet + // to complete a rollout after patching environment variables. + handlerRolloutTimeout = 5 * time.Minute + + // handlerRolloutInterval is the polling interval for rollout checks. + handlerRolloutInterval = 5 * time.Second + + // vlanFilterEnvVar is the name of the environment variable that + // controls the VLAN filtering threshold. + vlanFilterEnvVar = "VLAN_FILTER_INTERFACE_COUNT_THRESHOLD" +) + +// createVlansOnNode creates VLAN sub-interfaces on the given node using +// `ip link add`. Returns the list of created VLAN interface names. +func createVlansOnNode(node, parentIface string, count int) []string { + Byf("Creating %d VLAN interfaces on %s (parent: %s)", count, node, parentIface) + + // First bring up the parent interface + _, err := runner.RunAtNode(node, "sudo", "ip", "link", "set", parentIface, "up") + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Failed to bring up parent interface %s on %s", parentIface, node) + + names := make([]string, 0, count) + for i := 1; i <= count; i++ { + vlanID := fmt.Sprintf("%d", 3000+i) // Use high VLAN IDs to avoid conflicts + vlanName := fmt.Sprintf("%s.%s", parentIface, vlanID) + + _, err := runner.RunAtNode(node, + "sudo", "ip", "link", "add", + "link", parentIface, + "name", vlanName, + "type", "vlan", + "id", vlanID, + ) + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Failed to create VLAN %s on %s", vlanName, node) + + _, err = runner.RunAtNode(node, "sudo", "ip", "link", "set", vlanName, "up") + ExpectWithOffset(1, err).ToNot(HaveOccurred(), "Failed to bring up VLAN %s on %s", vlanName, node) + + names = append(names, vlanName) + } + + return names +} + +// deleteVlansOnNode removes VLAN sub-interfaces from the given node. +func deleteVlansOnNode(node, parentIface string, count int) { + Byf("Deleting %d VLAN interfaces on %s (parent: %s)", count, node, parentIface) + for i := 1; i <= count; i++ { + vlanID := fmt.Sprintf("%d", 3000+i) + vlanName := fmt.Sprintf("%s.%s", parentIface, vlanID) + // Ignore errors — interface may already be gone + runner.RunAtNode(node, "sudo", "ip", "link", "delete", vlanName) //nolint:errcheck + } +} + +// getHandlerDaemonSet returns the nmstate-handler DaemonSet. +func getHandlerDaemonSet() *appsv1.DaemonSet { + ds := &appsv1.DaemonSet{} + key := types.NamespacedName{ + Name: "nmstate-handler", + Namespace: "nmstate", + } + ExpectWithOffset(1, testenv.Client.Get(context.TODO(), key, ds)).To(Succeed()) + return ds +} + +// setHandlerEnvVar patches the nmstate-handler DaemonSet to add/update +// an environment variable on the handler container. Returns the original +// env vars for restoration. +func setHandlerEnvVar(envName, envValue string) []corev1.EnvVar { + ds := getHandlerDaemonSet() + + // Find the handler container + var containerIdx int + var found bool + for i, c := range ds.Spec.Template.Spec.Containers { + if c.Name == "nmstate-handler" || strings.Contains(c.Name, "handler") { + containerIdx = i + found = true + break + } + } + // If no container named "handler", use the first one + if !found { + containerIdx = 0 + } + + // Save original env vars + origEnv := make([]corev1.EnvVar, len(ds.Spec.Template.Spec.Containers[containerIdx].Env)) + copy(origEnv, ds.Spec.Template.Spec.Containers[containerIdx].Env) + + // Check if the env var already exists + envFound := false + for j, e := range ds.Spec.Template.Spec.Containers[containerIdx].Env { + if e.Name == envName { + ds.Spec.Template.Spec.Containers[containerIdx].Env[j].Value = envValue + envFound = true + break + } + } + if !envFound { + ds.Spec.Template.Spec.Containers[containerIdx].Env = append( + ds.Spec.Template.Spec.Containers[containerIdx].Env, + corev1.EnvVar{Name: envName, Value: envValue}, + ) + } + + Byf("Setting %s=%s on handler DaemonSet", envName, envValue) + ExpectWithOffset(1, testenv.Client.Update(context.TODO(), ds)).To(Succeed()) + + return origEnv +} + +// restoreHandlerEnvVars restores the handler DaemonSet to its original env vars. +func restoreHandlerEnvVars(origEnv []corev1.EnvVar) { + ds := getHandlerDaemonSet() + + var containerIdx int + var found bool + for i, c := range ds.Spec.Template.Spec.Containers { + if c.Name == "nmstate-handler" || strings.Contains(c.Name, "handler") { + containerIdx = i + found = true + break + } + } + if !found { + containerIdx = 0 + } + + ds.Spec.Template.Spec.Containers[containerIdx].Env = origEnv + + By("Restoring handler DaemonSet environment variables") + ExpectWithOffset(1, testenv.Client.Update(context.TODO(), ds)).To(Succeed()) +} + +// waitForHandlerRollout waits until all handler pods are ready and up-to-date. +func waitForHandlerRollout() { + By("Waiting for handler DaemonSet rollout to complete") + Eventually(func() bool { + ds := getHandlerDaemonSet() + return ds.Status.DesiredNumberScheduled == ds.Status.NumberReady && + ds.Status.DesiredNumberScheduled == ds.Status.UpdatedNumberScheduled && + ds.Status.DesiredNumberScheduled > 0 + }, handlerRolloutTimeout, handlerRolloutInterval).Should(BeTrue(), + "Handler DaemonSet rollout did not complete in time") + + // Additional wait for pods to be fully ready + Eventually(func() bool { + podList := corev1.PodList{} + err := testenv.Client.List(context.TODO(), &podList, + client.InNamespace("nmstate"), + client.MatchingLabels{"component": "kubernetes-nmstate-handler"}, + ) + if err != nil { + return false + } + for _, pod := range podList.Items { + if pod.Status.Phase != corev1.PodRunning { + return false + } + for _, cs := range pod.Status.ContainerStatuses { + if !cs.Ready { + return false + } + } + } + return true + }, handlerRolloutTimeout, handlerRolloutInterval).Should(BeTrue(), + "Not all handler pods are running and ready") +} + +var _ = Describe("[nns] NNS VLAN field filtering", func() { + var ( + testNode string + vlanNames []string + parentIface string + ) + + BeforeEach(func() { + testNode = nodes[0] + parentIface = firstSecondaryNic + }) + + Context("when a small number of VLANs are configured below the default threshold", func() { + const smallVlanCount = 3 + + BeforeEach(func() { + vlanNames = createVlansOnNode(testNode, parentIface, smallVlanCount) + }) + + AfterEach(func() { + deleteVlansOnNode(testNode, parentIface, smallVlanCount) + // Wait for NNS to reflect deletion + for _, name := range vlanNames { + Eventually(func() []string { + return interfacesNameForNode(testNode) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).ShouldNot(ContainElement(name)) + } + }) + + It("should show VLANs in NNS with all fields preserved including verbose ones", func() { + By("Waiting for NNS to include all created VLANs") + Eventually(func() []string { + return interfacesNameForNode(testNode) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).Should(ContainElements(vlanNames)) + + By("Verifying VLAN interfaces have all fields (below threshold)") + stateJSON := currentStateJSON(testNode) + for _, vlanName := range vlanNames { + path := fmt.Sprintf("interfaces.#(name==\"%s\")", vlanName) + vlanData := gjson.ParseBytes(stateJSON).Get(path) + Expect(vlanData.Exists()).To(BeTrue(), "VLAN %s should exist in NNS", vlanName) + + // Essential fields must be present + Expect(vlanData.Get("name").String()).To(Equal(vlanName)) + Expect(vlanData.Get("type").String()).To(Equal("vlan")) + Expect(vlanData.Get("state").String()).To(Equal("up")) + Expect(vlanData.Get("vlan.base-iface").String()).To(Equal(parentIface)) + + // Below threshold — verbose fields should also be present + Expect(vlanData.Get("mtu").Exists()).To(BeTrue(), + "VLAN %s should have mtu field below threshold", vlanName) + Expect(vlanData.Get("mac-address").Exists()).To(BeTrue(), + "VLAN %s should have mac-address field below threshold", vlanName) + } + + By("Verifying non-VLAN interfaces are unaffected") + ethPath := fmt.Sprintf("interfaces.#(name==\"%s\")", primaryNic) + ethData := gjson.ParseBytes(stateJSON).Get(ethPath) + Expect(ethData.Exists()).To(BeTrue(), "%s should exist in NNS", primaryNic) + Expect(ethData.Get("mtu").Exists()).To(BeTrue(), + "%s should have mtu field", primaryNic) + Expect(ethData.Get("mac-address").Exists()).To(BeTrue(), + "%s should have mac-address field", primaryNic) + }) + }) + + Context("when the interface count exceeds the VLAN filtering threshold", func() { + var origEnv []corev1.EnvVar + + BeforeEach(func() { + By("Lowering the VLAN filter threshold on the handler DaemonSet") + origEnv = setHandlerEnvVar(vlanFilterEnvVar, vlanFilterTestThreshold) + waitForHandlerRollout() + + By("Creating VLAN interfaces to exceed the lowered threshold") + vlanNames = createVlansOnNode(testNode, parentIface, testVlanCount) + }) + + AfterEach(func() { + By("Deleting test VLAN interfaces") + deleteVlansOnNode(testNode, parentIface, testVlanCount) + + By("Restoring original handler DaemonSet env vars") + restoreHandlerEnvVars(origEnv) + waitForHandlerRollout() + + // Wait for NNS to reflect deletion + for _, name := range vlanNames { + Eventually(func() []string { + return interfacesNameForNode(testNode) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).ShouldNot(ContainElement(name)) + } + }) + + It("should strip verbose fields from VLAN interfaces in NNS", func() { + By("Waiting for NNS to include all created VLANs") + Eventually(func() []string { + return interfacesNameForNode(testNode) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).Should(ContainElements(vlanNames)) + + By("Waiting for NNS to reflect filtered VLAN state") + // The handler needs to reconcile with the new threshold + // and produce a filtered NNS + Eventually(func() bool { + stateJSON := currentStateJSON(testNode) + vlanPath := fmt.Sprintf("interfaces.#(name==\"%s\")", vlanNames[0]) + vlanData := gjson.ParseBytes(stateJSON).Get(vlanPath) + if !vlanData.Exists() { + return false + } + // Check that verbose fields have been stripped + return !vlanData.Get("mtu").Exists() && !vlanData.Get("mac-address").Exists() + }, 2*nmstatenode.NetworkStateRefresh, time.Second).Should(BeTrue(), + "VLAN verbose fields should be stripped when interface count exceeds threshold") + + By("Verifying all VLAN interfaces have essential fields preserved") + stateJSON := currentStateJSON(testNode) + for _, vlanName := range vlanNames { + vlanPath := fmt.Sprintf("interfaces.#(name==\"%s\")", vlanName) + vlanData := gjson.ParseBytes(stateJSON).Get(vlanPath) + Expect(vlanData.Exists()).To(BeTrue(), "VLAN %s should exist in NNS", vlanName) + + // Essential fields must be preserved + Expect(vlanData.Get("name").String()).To(Equal(vlanName), + "VLAN %s should have name field", vlanName) + Expect(vlanData.Get("type").String()).To(Equal("vlan"), + "VLAN %s should have type=vlan", vlanName) + Expect(vlanData.Get("state").String()).ToNot(BeEmpty(), + "VLAN %s should have state field", vlanName) + Expect(vlanData.Get("vlan").Exists()).To(BeTrue(), + "VLAN %s should have vlan config preserved", vlanName) + Expect(vlanData.Get("vlan.base-iface").String()).To(Equal(parentIface), + "VLAN %s should have correct base-iface", vlanName) + } + + By("Verifying verbose fields are stripped from all VLAN interfaces") + for _, vlanName := range vlanNames { + vlanPath := fmt.Sprintf("interfaces.#(name==\"%s\")", vlanName) + vlanData := gjson.ParseBytes(stateJSON).Get(vlanPath) + + Expect(vlanData.Get("mtu").Exists()).To(BeFalse(), + "VLAN %s should NOT have mtu field when above threshold", vlanName) + Expect(vlanData.Get("mac-address").Exists()).To(BeFalse(), + "VLAN %s should NOT have mac-address field when above threshold", vlanName) + Expect(vlanData.Get("lldp").Exists()).To(BeFalse(), + "VLAN %s should NOT have lldp field when above threshold", vlanName) + Expect(vlanData.Get("ethtool").Exists()).To(BeFalse(), + "VLAN %s should NOT have ethtool field when above threshold", vlanName) + } + }) + + It("should preserve all fields on non-VLAN interfaces", func() { + By("Waiting for NNS to include all created VLANs") + Eventually(func() []string { + return interfacesNameForNode(testNode) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).Should(ContainElements(vlanNames)) + + By("Verifying non-VLAN interfaces retain all fields") + stateJSON := currentStateJSON(testNode) + + // Check the primary NIC (ethernet) + ethPath := fmt.Sprintf("interfaces.#(name==\"%s\")", primaryNic) + ethData := gjson.ParseBytes(stateJSON).Get(ethPath) + Expect(ethData.Exists()).To(BeTrue(), "%s should exist in NNS", primaryNic) + Expect(ethData.Get("type").String()).To(Equal("ethernet")) + Expect(ethData.Get("mtu").Exists()).To(BeTrue(), + "%s should still have mtu field", primaryNic) + Expect(ethData.Get("mac-address").Exists()).To(BeTrue(), + "%s should still have mac-address field", primaryNic) + + // Check the parent interface of the VLANs (also ethernet) + parentPath := fmt.Sprintf("interfaces.#(name==\"%s\")", parentIface) + parentData := gjson.ParseBytes(stateJSON).Get(parentPath) + Expect(parentData.Exists()).To(BeTrue(), "%s should exist in NNS", parentIface) + Expect(parentData.Get("mtu").Exists()).To(BeTrue(), + "%s should still have mtu field", parentIface) + Expect(parentData.Get("mac-address").Exists()).To(BeTrue(), + "%s should still have mac-address field", parentIface) + }) + + It("should produce a valid NNS object within etcd size limits", func() { + By("Waiting for NNS to include all created VLANs") + Eventually(func() []string { + return interfacesNameForNode(testNode) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).Should(ContainElements(vlanNames)) + + By("Verifying NNS object is valid and within size limits") + key := types.NamespacedName{Name: testNode} + nns := nodeNetworkState(key) + Expect(nns.Status.CurrentState.Raw).ToNot(BeEmpty(), + "NNS currentState should not be empty") + + // etcd limit is 1.5MB (1572864 bytes). Our NNS should be + // well under this even with many VLANs. + nnsSize := len(nns.Status.CurrentState.Raw) + Byf("NNS currentState size: %d bytes", nnsSize) + Expect(nnsSize).To(BeNumerically("<", 1500000), + "NNS currentState should be under 1.5 MB etcd limit") + + By("Verifying NNS has a recent successful update timestamp") + Expect(nns.Status.LastSuccessfulUpdateTime.Time).To( + BeTemporally(">", time.Now().Add(-5*time.Minute)), + "NNS should have been updated recently", + ) + }) + }) + + Context("when VLANs are created and deleted via NNCP", func() { + const nncpVlanID = "3099" + + BeforeEach(func() { + By("Creating a VLAN via NNCP") + updateDesiredStateAndWait(ifaceUpWithVlanUp(firstSecondaryNic, nncpVlanID)) + }) + + AfterEach(func() { + By("Removing VLAN via NNCP") + updateDesiredStateAndWait(vlanAbsent(firstSecondaryNic, nncpVlanID)) + resetDesiredStateForNodes() + }) + + It("should show the VLAN in NNS with correct fields and remove it on cleanup", func() { + expectedVlanName := fmt.Sprintf("%s.%s", firstSecondaryNic, nncpVlanID) + + By("Verifying VLAN appears in NNS for all worker nodes") + for _, node := range nodes { + Eventually(func() []string { + return interfacesNameForNode(node) + }, 2*nmstatenode.NetworkStateRefresh, time.Second).Should(ContainElement(expectedVlanName)) + } + + By("Verifying VLAN has correct metadata in NNS") + for _, node := range nodes { + stateJSON := currentStateJSON(node) + vlanPath := fmt.Sprintf("interfaces.#(name==\"%s\")", expectedVlanName) + vlanData := gjson.ParseBytes(stateJSON).Get(vlanPath) + Expect(vlanData.Exists()).To(BeTrue()) + Expect(vlanData.Get("type").String()).To(Equal("vlan")) + Expect(vlanData.Get("state").String()).To(Equal("up")) + Expect(vlanData.Get("vlan.id").Int()).To(Equal(int64(3099))) + Expect(vlanData.Get("vlan.base-iface").String()).To(Equal(firstSecondaryNic)) + } + }) + }) +})