From a7b8dd1a5c5eeca5cecac77132462263969ae69d Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 13:11:32 +0300 Subject: [PATCH 01/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm.go | 20 ++++++-- .../pkg/controller/kvbuilder/kvvm_utils.go | 12 ++++- .../pkg/controller/vm/internal/sync_kvvm.go | 46 +++++++++++++++++++ .../vmchange/comparator_pod_placement.go | 19 +++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 5ef04f9ed9..9e58030663 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -564,6 +564,15 @@ func (b *KVVM) ClearNetworkInterfaces() { b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces = nil } +func (b *KVVM) SetNetworkInterfaceAbsent(name string) { + for i, iface := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.Name == name { + b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces[i].State = virtv1.InterfaceStateAbsent + return + } + } +} + func (b *KVVM) SetNetworkInterface(name, macAddress string, acpiIndex int) { net := virtv1.Network{ Name: name, @@ -590,15 +599,16 @@ func (b *KVVM) SetNetworkInterface(name, macAddress string, acpiIndex int) { iface.MacAddress = macAddress } - ifaceExists := false - for _, i := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { - if i.Name == name { - ifaceExists = true + updated := false + for i, existing := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if existing.Name == name { + b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces[i] = iface + updated = true break } } - if !ifaceExists { + if !updated { b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces = append(b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces, iface) } } diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go index 594a6d7bdb..732f502d68 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go @@ -355,7 +355,17 @@ func ApplyMigrationVolumes(kvvm *KVVM, vm *v1alpha2.VirtualMachine, vdsByName ma } func setNetwork(kvvm *KVVM, networkSpec network.InterfaceSpecList) { - kvvm.ClearNetworkInterfaces() + desiredByName := make(map[string]struct{}, len(networkSpec)) + for _, n := range networkSpec { + desiredByName[n.InterfaceName] = struct{}{} + } + + for _, iface := range kvvm.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if _, wanted := desiredByName[iface.Name]; !wanted { + kvvm.SetNetworkInterfaceAbsent(iface.Name) + } + } + for _, n := range networkSpec { kvvm.SetNetworkInterface(n.InterfaceName, n.MAC, n.ID) } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 530551a938..3229bd7dec 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -658,6 +658,10 @@ func (h *SyncKvvmHandler) applyVMChangesToKVVM(ctx context.Context, s state.Virt return fmt.Errorf("unable to update KVVM using new VM spec: %w", err) } + if err := h.patchPodNetworkAnnotation(ctx, s); err != nil { + return fmt.Errorf("unable to patch pod network annotation: %w", err) + } + case vmchange.ActionNone: log.Info("No changes to underlying KVVM, update last-applied-spec annotation", "vm.name", current.GetName()) @@ -713,6 +717,48 @@ func (h *SyncKvvmHandler) isVMUnschedulable( return false } +func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state.VirtualMachineState) error { + log := logger.FromContext(ctx) + + pod, err := s.Pod(ctx) + if err != nil { + return err + } + if pod == nil { + return nil + } + + current := s.VirtualMachine().Current() + vmmacs, err := s.VirtualMachineMACAddresses(ctx) + if err != nil { + return err + } + + networkSpec := network.CreateNetworkSpec(current, vmmacs) + networkConfigStr, err := networkSpec.ToString() + if err != nil { + return fmt.Errorf("failed to serialize network spec: %w", err) + } + + currentAnnotation := pod.Annotations[annotations.AnnNetworksSpec] + if currentAnnotation == networkConfigStr { + return nil + } + + patch := client.MergeFrom(pod.DeepCopy()) + if pod.Annotations == nil { + pod.Annotations = make(map[string]string) + } + pod.Annotations[annotations.AnnNetworksSpec] = networkConfigStr + + if err := h.client.Patch(ctx, pod, patch); err != nil { + return fmt.Errorf("failed to patch pod %s network annotation: %w", pod.Name, err) + } + + log.Info("Patched pod network annotation", "pod", pod.Name, "networks", networkConfigStr) + return nil +} + // isPlacementPolicyChanged returns true if any of the Affinity, NodePlacement, or Toleration rules have changed. func (h *SyncKvvmHandler) isPlacementPolicyChanged(allChanges vmchange.SpecChanges) bool { for _, c := range allChanges.GetAll() { diff --git a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go index 7a4b90c24a..e6e6eff9d5 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go @@ -89,10 +89,10 @@ func compareNetworks(current, desired *v1alpha2.VirtualMachineSpec) []FieldChang desiredValue := NewValue(desired.Networks, desired.Networks == nil, false) action := ActionRestart - // During upgrade from 1.6.0 to 1.7.0, network interface IDs are auto-populated for all existing VMs in the cluster. - // This allows avoiding a virtual machine restart during the version upgrade. if isOnlyNetworkIDAutofillChange(current.Networks, desired.Networks) { action = ActionNone + } else if isOnlyNonMainNetworksChanged(current.Networks, desired.Networks) { + action = ActionApplyImmediate } return compareValues( @@ -104,6 +104,21 @@ func compareNetworks(current, desired *v1alpha2.VirtualMachineSpec) []FieldChang ) } +func isOnlyNonMainNetworksChanged(current, desired []v1alpha2.NetworksSpec) bool { + currentMain := getMainNetwork(current) + desiredMain := getMainNetwork(desired) + return reflect.DeepEqual(currentMain, desiredMain) +} + +func getMainNetwork(networks []v1alpha2.NetworksSpec) *v1alpha2.NetworksSpec { + for i := range networks { + if networks[i].Type == v1alpha2.NetworksTypeMain { + return &networks[i] + } + } + return nil +} + func isOnlyNetworkIDAutofillChange(current, desired []v1alpha2.NetworksSpec) bool { if len(current) != len(desired) { return false From 4357da06727d6e36cd4ec955de2e3461d0127764 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 15:45:27 +0300 Subject: [PATCH 02/27] wip Signed-off-by: Daniil Loktev --- build/components/versions.yml | 2 +- images/virt-artifact/werf.inc.yaml | 2 ++ images/virt-controller/werf.inc.yaml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/build/components/versions.yml b/build/components/versions.yml index f72a94e45d..d11de912ad 100644 --- a/build/components/versions.yml +++ b/build/components/versions.yml @@ -3,7 +3,7 @@ firmware: libvirt: v10.9.0 edk2: stable202411 core: - 3p-kubevirt: v1.6.2-v12n.21 + 3p-kubevirt: feat/core/network-hotplug-support 3p-containerized-data-importer: v1.60.3-v12n.17 distribution: 2.8.3 package: diff --git a/images/virt-artifact/werf.inc.yaml b/images/virt-artifact/werf.inc.yaml index f30560fba6..af8f96c8be 100644 --- a/images/virt-artifact/werf.inc.yaml +++ b/images/virt-artifact/werf.inc.yaml @@ -8,6 +8,7 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false +fromCacheVersion: "{{ now | date "Mon Jan 2 15:04:05 MST 2006" }}" fromImage: builder/src secrets: - id: SOURCE_REPO @@ -43,6 +44,7 @@ packages: image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false +fromCacheVersion: "{{ now | date "Mon Jan 2 15:04:05 MST 2006" }}" fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-alt-1.25" "builder/golang-alt-svace-1.25" }} mount: - fromPath: ~/go-pkg-cache diff --git a/images/virt-controller/werf.inc.yaml b/images/virt-controller/werf.inc.yaml index c1f7113305..d74b7eaf76 100644 --- a/images/virt-controller/werf.inc.yaml +++ b/images/virt-controller/werf.inc.yaml @@ -1,5 +1,6 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} +fromCacheVersion: "{{ now | date "Mon Jan 2 15:04:05 MST 2006" }}" fromImage: {{ .ModuleNamePrefix }}distroless git: {{- include "image mount points" . }} From c6eea115f150b18791a01b0a7140f25d4ea62cd5 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 16:46:08 +0300 Subject: [PATCH 03/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm_test.go | 93 ++++++++++++++++ .../vmchange/comparator_pod_placement.go | 10 +- .../pkg/controller/vmchange/compare_test.go | 100 ++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 8a14050251..c67eff2afc 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -22,7 +22,9 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + virtv1 "kubevirt.io/api/core/v1" + "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -145,3 +147,94 @@ func TestSetOsType(t *testing.T) { } }) } + +func newTestKVVM() *KVVM { + return NewEmptyKVVM(types.NamespacedName{Name: "test", Namespace: "default"}, KVVMOptions{ + EnableParavirtualization: true, + }) +} + +func TestSetNetworkInterfaceAbsent(t *testing.T) { + b := newTestKVVM() + b.SetNetworkInterface("default", "", 1) + b.SetNetworkInterface("veth_n12345678", "aa:bb:cc:dd:ee:ff", 2) + + b.SetNetworkInterfaceAbsent("veth_n12345678") + + for _, iface := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.Name == "veth_n12345678" { + if iface.State != virtv1.InterfaceStateAbsent { + t.Errorf("expected State %q, got %q", virtv1.InterfaceStateAbsent, iface.State) + } + return + } + } + t.Error("interface veth_n12345678 not found") +} + +func TestSetNetworkInterfaceReplacesExisting(t *testing.T) { + b := newTestKVVM() + b.SetNetworkInterface("veth_n12345678", "aa:bb:cc:dd:ee:ff", 2) + b.SetNetworkInterfaceAbsent("veth_n12345678") + + b.SetNetworkInterface("veth_n12345678", "aa:bb:cc:dd:ee:ff", 2) + + for _, iface := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.Name == "veth_n12345678" { + if iface.State != "" { + t.Errorf("expected empty State after re-add, got %q", iface.State) + } + return + } + } + t.Error("interface veth_n12345678 not found") +} + +func TestSetNetworkMarksRemovedAsAbsent(t *testing.T) { + b := newTestKVVM() + b.SetNetworkInterface("default", "", 1) + b.SetNetworkInterface("veth_n12345678", "aa:bb:cc:dd:ee:ff", 2) + + setNetwork(b, network.InterfaceSpecList{ + {InterfaceName: "default", MAC: "", ID: 1}, + }) + + found := false + for _, iface := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.Name == "veth_n12345678" { + found = true + if iface.State != virtv1.InterfaceStateAbsent { + t.Errorf("removed interface should have State %q, got %q", virtv1.InterfaceStateAbsent, iface.State) + } + } + if iface.Name == "default" && iface.State != "" { + t.Errorf("kept interface should have empty State, got %q", iface.State) + } + } + if !found { + t.Error("removed interface should be retained with absent state, not deleted") + } +} + +func TestSetNetworkAddsNewInterface(t *testing.T) { + b := newTestKVVM() + b.SetNetworkInterface("default", "", 1) + + setNetwork(b, network.InterfaceSpecList{ + {InterfaceName: "default", MAC: "", ID: 1}, + {InterfaceName: "veth_n12345678", MAC: "aa:bb:cc:dd:ee:ff", ID: 2}, + }) + + found := false + for _, iface := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.Name == "veth_n12345678" { + found = true + if iface.ACPIIndex != 2 { + t.Errorf("expected ACPIIndex 2, got %d", iface.ACPIIndex) + } + } + } + if !found { + t.Error("new interface should be added") + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go index e6e6eff9d5..e1f58cf533 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go @@ -107,7 +107,15 @@ func compareNetworks(current, desired *v1alpha2.VirtualMachineSpec) []FieldChang func isOnlyNonMainNetworksChanged(current, desired []v1alpha2.NetworksSpec) bool { currentMain := getMainNetwork(current) desiredMain := getMainNetwork(desired) - return reflect.DeepEqual(currentMain, desiredMain) + currentHasMain := currentMain != nil || len(current) == 0 + desiredHasMain := desiredMain != nil || len(desired) == 0 + if !currentHasMain || !desiredHasMain { + return false + } + if currentMain == nil || desiredMain == nil { + return true + } + return reflect.DeepEqual(*currentMain, *desiredMain) } func getMainNetwork(networks []v1alpha2.NetworksSpec) *v1alpha2.NetworksSpec { diff --git a/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go b/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go index 2ab26e66a8..779c384924 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go @@ -369,6 +369,106 @@ networks: requirePathOperation("networks", ChangeReplace), ), }, + { + "apply immediate when adding non-main network to nil networks", + ``, + ` +networks: +- type: Main + id: 1 +- type: ClusterNetwork + name: additional + id: 2 +`, + assertChanges( + actionRequired(ActionApplyImmediate), + requirePathOperation("networks", ChangeAdd), + ), + }, + { + "apply immediate when adding non-main network to existing main", + ` +networks: +- type: Main + id: 1 +`, + ` +networks: +- type: Main + id: 1 +- type: ClusterNetwork + name: additional + id: 2 +`, + assertChanges( + actionRequired(ActionApplyImmediate), + requirePathOperation("networks", ChangeReplace), + ), + }, + { + "apply immediate when removing non-main network", + ` +networks: +- type: Main + id: 1 +- type: Network + name: net1 + id: 2 +`, + ` +networks: +- type: Main + id: 1 +`, + assertChanges( + actionRequired(ActionApplyImmediate), + requirePathOperation("networks", ChangeReplace), + ), + }, + { + "restart when main network is removed", + ` +networks: +- type: Main + id: 1 +- type: Network + name: net1 + id: 2 +`, + ` +networks: +- type: Network + name: net1 + id: 2 +`, + assertChanges( + actionRequired(ActionRestart), + requirePathOperation("networks", ChangeReplace), + ), + }, + { + "restart when main network id changes", + ` +networks: +- type: Main + id: 1 +- type: Network + name: net1 + id: 2 +`, + ` +networks: +- type: Main + id: 5 +- type: Network + name: net1 + id: 2 +`, + assertChanges( + actionRequired(ActionRestart), + requirePathOperation("networks", ChangeReplace), + ), + }, } for _, tt := range tests { From aeedb02c1f0a45955c799b18db79ecb47b87cf16 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 17:47:08 +0300 Subject: [PATCH 04/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/vm/internal/sync_kvvm.go | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 3229bd7dec..3b6db2237c 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -745,17 +745,36 @@ func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state return nil } - patch := client.MergeFrom(pod.DeepCopy()) + podPatch := client.MergeFrom(pod.DeepCopy()) if pod.Annotations == nil { pod.Annotations = make(map[string]string) } pod.Annotations[annotations.AnnNetworksSpec] = networkConfigStr - if err := h.client.Patch(ctx, pod, patch); err != nil { + if err := h.client.Patch(ctx, pod, podPatch); err != nil { return fmt.Errorf("failed to patch pod %s network annotation: %w", pod.Name, err) } - log.Info("Patched pod network annotation", "pod", pod.Name, "networks", networkConfigStr) + + kvvmi, err := s.KVVMI(ctx) + if err != nil { + return err + } + if kvvmi == nil { + return nil + } + + vmiPatch := client.MergeFrom(kvvmi.DeepCopy()) + if kvvmi.Annotations == nil { + kvvmi.Annotations = make(map[string]string) + } + kvvmi.Annotations[annotations.AnnNetworksSpec] = networkConfigStr + + if err := h.client.Patch(ctx, kvvmi, vmiPatch); err != nil { + return fmt.Errorf("failed to patch VMI %s network annotation: %w", kvvmi.Name, err) + } + log.Info("Patched VMI network annotation", "vmi", kvvmi.Name, "networks", networkConfigStr) + return nil } From d16fed371c904130278f5d3800603824c2801d77 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 18:17:48 +0300 Subject: [PATCH 05/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm_utils.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go index 732f502d68..6bfb27ca75 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go @@ -360,13 +360,18 @@ func setNetwork(kvvm *KVVM, networkSpec network.InterfaceSpecList) { desiredByName[n.InterfaceName] = struct{}{} } + existingByName := make(map[string]struct{}) for _, iface := range kvvm.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + existingByName[iface.Name] = struct{}{} if _, wanted := desiredByName[iface.Name]; !wanted { kvvm.SetNetworkInterfaceAbsent(iface.Name) } } for _, n := range networkSpec { + if _, exists := existingByName[n.InterfaceName]; exists { + continue + } kvvm.SetNetworkInterface(n.InterfaceName, n.MAC, n.ID) } } From e7371387793c24155ba4d1b62ba5b30078c66209 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 18:54:55 +0300 Subject: [PATCH 06/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm_utils.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go index 6bfb27ca75..eb2ce6b4ca 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go @@ -372,6 +372,9 @@ func setNetwork(kvvm *KVVM, networkSpec network.InterfaceSpecList) { if _, exists := existingByName[n.InterfaceName]; exists { continue } + if n.InterfaceName == network.NameDefaultInterface { + continue + } kvvm.SetNetworkInterface(n.InterfaceName, n.MAC, n.ID) } } From afc481d7aecae9ffe191bc684da5dbe2c12978b8 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 19:23:24 +0300 Subject: [PATCH 07/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm.go | 11 ++++++----- .../pkg/controller/kvbuilder/kvvm_utils.go | 3 --- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 9e58030663..8cd1e84a37 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -587,14 +587,15 @@ func (b *KVVM) SetNetworkInterface(name, macAddress string, acpiIndex int) { }, true, ) - devPreset := DeviceOptionsPresets.Find(b.opts.EnableParavirtualization) - iface := virtv1.Interface{ - Name: name, - Model: devPreset.InterfaceModel, - ACPIIndex: acpiIndex, + Name: name, } iface.Bridge = &virtv1.InterfaceBridge{} + if name != "default" { + devPreset := DeviceOptionsPresets.Find(b.opts.EnableParavirtualization) + iface.Model = devPreset.InterfaceModel + iface.ACPIIndex = acpiIndex + } if macAddress != "" { iface.MacAddress = macAddress } diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go index eb2ce6b4ca..6bfb27ca75 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go @@ -372,9 +372,6 @@ func setNetwork(kvvm *KVVM, networkSpec network.InterfaceSpecList) { if _, exists := existingByName[n.InterfaceName]; exists { continue } - if n.InterfaceName == network.NameDefaultInterface { - continue - } kvvm.SetNetworkInterface(n.InterfaceName, n.MAC, n.ID) } } From 1bc8b68164a462921c5166ac4a6a54ba05134ce9 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 6 Apr 2026 19:53:43 +0300 Subject: [PATCH 08/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/vm/internal/sync_kvvm.go | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 3b6db2237c..f49ae126ea 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -654,14 +654,23 @@ func (h *SyncKvvmHandler) applyVMChangesToKVVM(ctx context.Context, s state.Virt h.recorder.Event(current, corev1.EventTypeNormal, v1alpha2.ReasonVMChangesApplied, message) log.Debug(message, "vm.name", current.GetName(), "changes", changes) - if err := h.updateKVVM(ctx, s); err != nil { - return fmt.Errorf("unable to update KVVM using new VM spec: %w", err) - } - if err := h.patchPodNetworkAnnotation(ctx, s); err != nil { return fmt.Errorf("unable to patch pod network annotation: %w", err) } + ready, err := h.isNetworkReadyOnPod(ctx, s) + if err != nil { + return fmt.Errorf("unable to check pod network status: %w", err) + } + if !ready { + log.Info("Waiting for SDN to configure network interfaces on the pod") + return nil + } + + if err := h.updateKVVM(ctx, s); err != nil { + return fmt.Errorf("unable to update KVVM using new VM spec: %w", err) + } + case vmchange.ActionNone: log.Info("No changes to underlying KVVM, update last-applied-spec annotation", "vm.name", current.GetName()) @@ -717,6 +726,22 @@ func (h *SyncKvvmHandler) isVMUnschedulable( return false } +func (h *SyncKvvmHandler) isNetworkReadyOnPod(ctx context.Context, s state.VirtualMachineState) (bool, error) { + pods, err := s.Pods(ctx) + if err != nil { + return false, err + } + if pods == nil || len(pods.Items) == 0 { + return false, nil + } + + errMsg, err := extractNetworkStatusFromPods(pods) + if err != nil { + return false, err + } + return errMsg == "", nil +} + func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state.VirtualMachineState) error { log := logger.FromContext(ctx) From b16d905f1a6e02021aa8efcd4741ea2224fbfc98 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 7 Apr 2026 16:44:20 +0300 Subject: [PATCH 09/27] wip Signed-off-by: Daniil Loktev --- .../pkg/common/network/types.go | 13 ++++--- .../pkg/controller/kvbuilder/kvvm.go | 3 +- .../pkg/controller/vm/internal/sync_kvvm.go | 39 ++++++++----------- .../vmchange/comparator_pod_placement.go | 17 +++----- 4 files changed, 33 insertions(+), 39 deletions(-) diff --git a/images/virtualization-artifact/pkg/common/network/types.go b/images/virtualization-artifact/pkg/common/network/types.go index 58d70914e5..2540b58db2 100644 --- a/images/virtualization-artifact/pkg/common/network/types.go +++ b/images/virtualization-artifact/pkg/common/network/types.go @@ -37,13 +37,16 @@ func HasMainNetworkStatus(networks []v1alpha2.NetworksStatus) bool { } func HasMainNetworkSpec(networks []v1alpha2.NetworksSpec) bool { - for _, network := range networks { - if network.Type == v1alpha2.NetworksTypeMain { - return true + return GetMainNetworkSpec(networks) != nil +} + +func GetMainNetworkSpec(networks []v1alpha2.NetworksSpec) *v1alpha2.NetworksSpec { + for i := range networks { + if networks[i].Type == v1alpha2.NetworksTypeMain { + return &networks[i] } } - - return false + return nil } type InterfaceSpec struct { diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 8cd1e84a37..2e20bdb582 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -31,6 +31,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common" "github.com/deckhouse/virtualization-controller/pkg/common/array" + "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization-controller/pkg/common/resource_builder" "github.com/deckhouse/virtualization-controller/pkg/common/vm" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -591,7 +592,7 @@ func (b *KVVM) SetNetworkInterface(name, macAddress string, acpiIndex int) { Name: name, } iface.Bridge = &virtv1.InterfaceBridge{} - if name != "default" { + if name != network.NameDefaultInterface { devPreset := DeviceOptionsPresets.Find(b.opts.EnableParavirtualization) iface.Model = devPreset.InterfaceModel iface.ACPIIndex = acpiIndex diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index f49ae126ea..30ab380a79 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -734,7 +734,6 @@ func (h *SyncKvvmHandler) isNetworkReadyOnPod(ctx context.Context, s state.Virtu if pods == nil || len(pods.Items) == 0 { return false, nil } - errMsg, err := extractNetworkStatusFromPods(pods) if err != nil { return false, err @@ -759,24 +758,12 @@ func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state return err } - networkSpec := network.CreateNetworkSpec(current, vmmacs) - networkConfigStr, err := networkSpec.ToString() + networkConfigStr, err := network.CreateNetworkSpec(current, vmmacs).ToString() if err != nil { return fmt.Errorf("failed to serialize network spec: %w", err) } - currentAnnotation := pod.Annotations[annotations.AnnNetworksSpec] - if currentAnnotation == networkConfigStr { - return nil - } - - podPatch := client.MergeFrom(pod.DeepCopy()) - if pod.Annotations == nil { - pod.Annotations = make(map[string]string) - } - pod.Annotations[annotations.AnnNetworksSpec] = networkConfigStr - - if err := h.client.Patch(ctx, pod, podPatch); err != nil { + if err := h.patchAnnotationIfChanged(ctx, pod, annotations.AnnNetworksSpec, networkConfigStr); err != nil { return fmt.Errorf("failed to patch pod %s network annotation: %w", pod.Name, err) } log.Info("Patched pod network annotation", "pod", pod.Name, "networks", networkConfigStr) @@ -789,13 +776,7 @@ func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state return nil } - vmiPatch := client.MergeFrom(kvvmi.DeepCopy()) - if kvvmi.Annotations == nil { - kvvmi.Annotations = make(map[string]string) - } - kvvmi.Annotations[annotations.AnnNetworksSpec] = networkConfigStr - - if err := h.client.Patch(ctx, kvvmi, vmiPatch); err != nil { + if err := h.patchAnnotationIfChanged(ctx, kvvmi, annotations.AnnNetworksSpec, networkConfigStr); err != nil { return fmt.Errorf("failed to patch VMI %s network annotation: %w", kvvmi.Name, err) } log.Info("Patched VMI network annotation", "vmi", kvvmi.Name, "networks", networkConfigStr) @@ -803,6 +784,20 @@ func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state return nil } +func (h *SyncKvvmHandler) patchAnnotationIfChanged(ctx context.Context, obj client.Object, key, value string) error { + if obj.GetAnnotations()[key] == value { + return nil + } + patch := client.MergeFrom(obj.DeepCopyObject().(client.Object)) + anns := obj.GetAnnotations() + if anns == nil { + anns = make(map[string]string) + } + anns[key] = value + obj.SetAnnotations(anns) + return h.client.Patch(ctx, obj, patch) +} + // isPlacementPolicyChanged returns true if any of the Affinity, NodePlacement, or Toleration rules have changed. func (h *SyncKvvmHandler) isPlacementPolicyChanged(allChanges vmchange.SpecChanges) bool { for _, c := range allChanges.GetAll() { diff --git a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go index e1f58cf533..b8f8de9b7c 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go @@ -19,6 +19,7 @@ package vmchange import ( "reflect" + "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -104,9 +105,12 @@ func compareNetworks(current, desired *v1alpha2.VirtualMachineSpec) []FieldChang ) } +// isOnlyNonMainNetworksChanged returns true when the Main network is unchanged +// between current and desired (so only non-Main networks differ). +// Empty networks list is equivalent to having an implicit default Main. func isOnlyNonMainNetworksChanged(current, desired []v1alpha2.NetworksSpec) bool { - currentMain := getMainNetwork(current) - desiredMain := getMainNetwork(desired) + currentMain := network.GetMainNetworkSpec(current) + desiredMain := network.GetMainNetworkSpec(desired) currentHasMain := currentMain != nil || len(current) == 0 desiredHasMain := desiredMain != nil || len(desired) == 0 if !currentHasMain || !desiredHasMain { @@ -118,15 +122,6 @@ func isOnlyNonMainNetworksChanged(current, desired []v1alpha2.NetworksSpec) bool return reflect.DeepEqual(*currentMain, *desiredMain) } -func getMainNetwork(networks []v1alpha2.NetworksSpec) *v1alpha2.NetworksSpec { - for i := range networks { - if networks[i].Type == v1alpha2.NetworksTypeMain { - return &networks[i] - } - } - return nil -} - func isOnlyNetworkIDAutofillChange(current, desired []v1alpha2.NetworksSpec) bool { if len(current) != len(desired) { return false From 93695f2e1ca07c3862183309b4ac6d961b1c2db6 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 7 Apr 2026 17:27:49 +0300 Subject: [PATCH 10/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm_utils.go | 5 -- .../pkg/controller/vm/internal/sync_kvvm.go | 69 +++++++++---------- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go index 6bfb27ca75..732f502d68 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go @@ -360,18 +360,13 @@ func setNetwork(kvvm *KVVM, networkSpec network.InterfaceSpecList) { desiredByName[n.InterfaceName] = struct{}{} } - existingByName := make(map[string]struct{}) for _, iface := range kvvm.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { - existingByName[iface.Name] = struct{}{} if _, wanted := desiredByName[iface.Name]; !wanted { kvvm.SetNetworkInterfaceAbsent(iface.Name) } } for _, n := range networkSpec { - if _, exists := existingByName[n.InterfaceName]; exists { - continue - } kvvm.SetNetworkInterface(n.InterfaceName, n.MAC, n.ID) } } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 30ab380a79..1c49703f4e 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -654,17 +654,21 @@ func (h *SyncKvvmHandler) applyVMChangesToKVVM(ctx context.Context, s state.Virt h.recorder.Event(current, corev1.EventTypeNormal, v1alpha2.ReasonVMChangesApplied, message) log.Debug(message, "vm.name", current.GetName(), "changes", changes) - if err := h.patchPodNetworkAnnotation(ctx, s); err != nil { - return fmt.Errorf("unable to patch pod network annotation: %w", err) - } + if hasNetworkChange(changes) { + if err := h.patchPodNetworkAnnotation(ctx, s); err != nil { + return fmt.Errorf("unable to patch pod network annotation: %w", err) + } - ready, err := h.isNetworkReadyOnPod(ctx, s) - if err != nil { - return fmt.Errorf("unable to check pod network status: %w", err) - } - if !ready { - log.Info("Waiting for SDN to configure network interfaces on the pod") - return nil + ready, err := h.isNetworkReadyOnPod(ctx, s) + if err != nil { + return fmt.Errorf("unable to check pod network status: %w", err) + } + if !ready { + msg := "Waiting for SDN to configure network interfaces on the pod" + log.Info(msg) + h.recorder.Event(current, corev1.EventTypeNormal, v1alpha2.ReasonVMChangesApplied, msg) + return nil + } } if err := h.updateKVVM(ctx, s); err != nil { @@ -726,6 +730,15 @@ func (h *SyncKvvmHandler) isVMUnschedulable( return false } +func hasNetworkChange(changes vmchange.SpecChanges) bool { + for _, c := range changes.GetAll() { + if c.Path == "networks" { + return true + } + } + return false +} + func (h *SyncKvvmHandler) isNetworkReadyOnPod(ctx context.Context, s state.VirtualMachineState) (bool, error) { pods, err := s.Pods(ctx) if err != nil { @@ -763,41 +776,23 @@ func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state return fmt.Errorf("failed to serialize network spec: %w", err) } - if err := h.patchAnnotationIfChanged(ctx, pod, annotations.AnnNetworksSpec, networkConfigStr); err != nil { - return fmt.Errorf("failed to patch pod %s network annotation: %w", pod.Name, err) - } - log.Info("Patched pod network annotation", "pod", pod.Name, "networks", networkConfigStr) - - kvvmi, err := s.KVVMI(ctx) - if err != nil { - return err - } - if kvvmi == nil { + if pod.Annotations[annotations.AnnNetworksSpec] == networkConfigStr { return nil } - if err := h.patchAnnotationIfChanged(ctx, kvvmi, annotations.AnnNetworksSpec, networkConfigStr); err != nil { - return fmt.Errorf("failed to patch VMI %s network annotation: %w", kvvmi.Name, err) + patch := client.MergeFrom(pod.DeepCopy()) + if pod.Annotations == nil { + pod.Annotations = make(map[string]string) + } + pod.Annotations[annotations.AnnNetworksSpec] = networkConfigStr + if err := h.client.Patch(ctx, pod, patch); err != nil { + return fmt.Errorf("failed to patch pod %s network annotation: %w", pod.Name, err) } - log.Info("Patched VMI network annotation", "vmi", kvvmi.Name, "networks", networkConfigStr) + log.Info("Patched pod network annotation", "pod", pod.Name, "networks", networkConfigStr) return nil } -func (h *SyncKvvmHandler) patchAnnotationIfChanged(ctx context.Context, obj client.Object, key, value string) error { - if obj.GetAnnotations()[key] == value { - return nil - } - patch := client.MergeFrom(obj.DeepCopyObject().(client.Object)) - anns := obj.GetAnnotations() - if anns == nil { - anns = make(map[string]string) - } - anns[key] = value - obj.SetAnnotations(anns) - return h.client.Patch(ctx, obj, patch) -} - // isPlacementPolicyChanged returns true if any of the Affinity, NodePlacement, or Toleration rules have changed. func (h *SyncKvvmHandler) isPlacementPolicyChanged(allChanges vmchange.SpecChanges) bool { for _, c := range allChanges.GetAll() { From 745bc07d292718dce12e347e23e9a795c3ce5c01 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Wed, 8 Apr 2026 12:48:37 +0300 Subject: [PATCH 11/27] wip Signed-off-by: Daniil Loktev --- .../pkg/common/network/spec.go | 8 ++++++++ .../pkg/controller/kvbuilder/kvvm.go | 12 +++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/images/virtualization-artifact/pkg/common/network/spec.go b/images/virtualization-artifact/pkg/common/network/spec.go index 4d1976cbc9..176fb7340f 100644 --- a/images/virtualization-artifact/pkg/common/network/spec.go +++ b/images/virtualization-artifact/pkg/common/network/spec.go @@ -30,6 +30,14 @@ func CreateNetworkSpec(vm *v1alpha2.VirtualMachine, vmmacs []*v1alpha2.VirtualMa macPool := NewMacAddressPool(vm, vmmacs) var specs InterfaceSpecList + if len(vm.Spec.Networks) == 0 { + specs = append(specs, createMainInterfaceSpec(v1alpha2.NetworksSpec{ + Type: v1alpha2.NetworksTypeMain, + ID: ptr.To(ReservedMainID), + })) + return specs + } + for _, net := range vm.Spec.Networks { if net.Type == v1alpha2.NetworksTypeMain { specs = append(specs, createMainInterfaceSpec(net)) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 2e20bdb582..9e58030663 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -31,7 +31,6 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common" "github.com/deckhouse/virtualization-controller/pkg/common/array" - "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization-controller/pkg/common/resource_builder" "github.com/deckhouse/virtualization-controller/pkg/common/vm" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -588,15 +587,14 @@ func (b *KVVM) SetNetworkInterface(name, macAddress string, acpiIndex int) { }, true, ) + devPreset := DeviceOptionsPresets.Find(b.opts.EnableParavirtualization) + iface := virtv1.Interface{ - Name: name, + Name: name, + Model: devPreset.InterfaceModel, + ACPIIndex: acpiIndex, } iface.Bridge = &virtv1.InterfaceBridge{} - if name != network.NameDefaultInterface { - devPreset := DeviceOptionsPresets.Find(b.opts.EnableParavirtualization) - iface.Model = devPreset.InterfaceModel - iface.ACPIIndex = acpiIndex - } if macAddress != "" { iface.MacAddress = macAddress } From 6e43337b54f290b42b305ca6da1c1f4ba12217f6 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Thu, 9 Apr 2026 10:31:55 +0300 Subject: [PATCH 12/27] wip Signed-off-by: Daniil Loktev --- images/virt-artifact/werf.inc.yaml | 2 -- images/virt-controller/werf.inc.yaml | 1 - 2 files changed, 3 deletions(-) diff --git a/images/virt-artifact/werf.inc.yaml b/images/virt-artifact/werf.inc.yaml index af8f96c8be..f30560fba6 100644 --- a/images/virt-artifact/werf.inc.yaml +++ b/images/virt-artifact/werf.inc.yaml @@ -8,7 +8,6 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false -fromCacheVersion: "{{ now | date "Mon Jan 2 15:04:05 MST 2006" }}" fromImage: builder/src secrets: - id: SOURCE_REPO @@ -44,7 +43,6 @@ packages: image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false -fromCacheVersion: "{{ now | date "Mon Jan 2 15:04:05 MST 2006" }}" fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-alt-1.25" "builder/golang-alt-svace-1.25" }} mount: - fromPath: ~/go-pkg-cache diff --git a/images/virt-controller/werf.inc.yaml b/images/virt-controller/werf.inc.yaml index d74b7eaf76..c1f7113305 100644 --- a/images/virt-controller/werf.inc.yaml +++ b/images/virt-controller/werf.inc.yaml @@ -1,6 +1,5 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} -fromCacheVersion: "{{ now | date "Mon Jan 2 15:04:05 MST 2006" }}" fromImage: {{ .ModuleNamePrefix }}distroless git: {{- include "image mount points" . }} From d86a51e3a0e1b589145eac59cd67f8b5547b0833 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Thu, 16 Apr 2026 13:18:45 +0300 Subject: [PATCH 13/27] add doc Signed-off-by: Daniil Loktev --- docs/USER_GUIDE.md | 3 ++- docs/USER_GUIDE.ru.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c943cd8aa5..cbb2ed3de4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -2991,7 +2991,8 @@ If you specify the main network, it must be the first entry in the `.spec.networ Important considerations when working with additional network interfaces: - The order of listing networks in `.spec.networks` determines the order in which interfaces are connected inside the virtual machine. -- Adding or removing additional networks takes effect only after the VM is rebooted. +- Adding or removing an additional network (`Network` or `ClusterNetwork`) on a running VM is applied live without reboot. ACPI indexes of existing interfaces are preserved across add/remove cycles, so interface names in the guest OS stay stable. +- Adding or removing the main network (`type: Main`) still requires a VM reboot, because it is tied to the pod's primary network interface and cannot be reconfigured on a running pod. - To preserve the order of network interfaces inside the guest operating system, it is recommended to add new networks to the end of the `.spec.networks` list (do not change the order of existing ones). - Network security policies (NetworkPolicy) do not apply to additional network interfaces. - Network parameters (IP addresses, gateways, DNS, etc.) for additional networks are configured manually from within the guest OS (for example, using Cloud-Init). diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index c22c8ebeb6..d46ad936e2 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -3025,7 +3025,8 @@ EOF Особенности и важные моменты работы с дополнительными сетевыми интерфейсами: - порядок перечисления сетей в `.spec.networks` определяет порядок подключения интерфейсов внутри виртуальной машины; -- добавление или удаление дополнительных сетей вступает в силу только после перезагрузки ВМ; +- добавление или удаление дополнительной сети (`Network` или `ClusterNetwork`) на работающей ВМ применяется без перезагрузки. ACPI-индексы существующих интерфейсов сохраняются при добавлении/удалении, поэтому имена интерфейсов в гостевой ОС остаются стабильными; +- добавление или удаление основной сети (`type: Main`) по-прежнему требует перезагрузки ВМ, так как она связана с основным сетевым интерфейсом пода и не может быть изменена на работающем поде; - чтобы сохранить порядок сетевых интерфейсов внутри гостевой операционной системы, рекомендуется добавлять новые сети в конец списка `.spec.networks` (не менять порядок уже существующих); - политики сетевой безопасности (NetworkPolicy) не применяются к дополнительным сетевым интерфейсам; - параметры сети (IP-адреса, шлюзы, DNS и т.д.) для дополнительных сетей настраиваются вручную изнутри гостевой ОС (например, с помощью Cloud-Init). From b5007c9e753cbefb37d6b6c80a165db6d09e0ec7 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Thu, 16 Apr 2026 16:38:16 +0300 Subject: [PATCH 14/27] add e2e Signed-off-by: Daniil Loktev --- test/e2e/vm/additional_network_interfaces.go | 112 +++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/test/e2e/vm/additional_network_interfaces.go b/test/e2e/vm/additional_network_interfaces.go index 3c5b529e7f..3900e02d6a 100644 --- a/test/e2e/vm/additional_network_interfaces.go +++ b/test/e2e/vm/additional_network_interfaces.go @@ -19,11 +19,13 @@ package vm import ( "context" "fmt" + "strconv" "strings" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/utils/ptr" crclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -235,6 +237,116 @@ var _ = Describe("VirtualMachineAdditionalNetworkInterfaces", func() { }) }) }) + + Describe("verifies hotplug and hotunplug of additional network interfaces", func() { + const countNonLoopbackInterfacesCmd = "ip -o link show | grep -v 'lo:' | wc -l" + + var ( + vdRoot *v1alpha2.VirtualDisk + testVM *v1alpha2.VirtualMachine + ) + + getIfaceCount := func() int { + GinkgoHelper() + output, err := f.SSHCommand(testVM.Name, testVM.Namespace, countNonLoopbackInterfacesCmd) + Expect(err).NotTo(HaveOccurred()) + count, err := strconv.Atoi(strings.TrimSpace(output)) + Expect(err).NotTo(HaveOccurred()) + return count + } + + expectNoRestartRequired := func() { + GinkgoHelper() + Consistently(func(g Gomega) { + err := f.Clients.GenericClient().Get(context.Background(), crclient.ObjectKeyFromObject(testVM), testVM) + g.Expect(err).NotTo(HaveOccurred()) + cond, _ := conditions.GetCondition(vmcondition.TypeAwaitingRestartToApplyConfiguration, testVM.Status.Conditions) + g.Expect(cond.Status).NotTo(Equal(metav1.ConditionTrue), + "VM should not require restart for non-Main network change") + }).WithTimeout(10 * time.Second).WithPolling(2 * time.Second).Should(Succeed()) + } + + It("should attach and detach ClusterNetwork on a running VM without reboot", func() { + var initialIfaceCount int + + By("Create VM with only Main network", func() { + ns := f.Namespace().Name + vdRoot = object.NewVDFromCVI("vd-root", ns, object.PrecreatedCVIUbuntu) + + testVM = vm.New( + vm.WithName("vm-hotplug"), + vm.WithNamespace(ns), + vm.WithBootloader(v1alpha2.EFI), + vm.WithCPU(1, ptr.To("5%")), + vm.WithMemory(resource.MustParse("512Mi")), + vm.WithRestartApprovalMode(v1alpha2.Manual), + vm.WithVirtualMachineClass(object.DefaultVMClass), + vm.WithLiveMigrationPolicy(v1alpha2.PreferSafeMigrationPolicy), + vm.WithProvisioningUserData(object.UbuntuCloudInit), + vm.WithBlockDeviceRefs(v1alpha2.BlockDeviceSpecRef{ + Kind: v1alpha2.VirtualDiskKind, + Name: vdRoot.Name, + }), + vm.WithNetwork(v1alpha2.NetworksSpec{Type: v1alpha2.NetworksTypeMain}), + ) + + err := f.CreateWithDeferredDeletion(context.Background(), vdRoot, testVM) + Expect(err).NotTo(HaveOccurred()) + + util.UntilObjectPhase(string(v1alpha2.MachineRunning), framework.LongTimeout, testVM) + util.UntilVMAgentReady(crclient.ObjectKeyFromObject(testVM), framework.LongTimeout) + util.UntilSSHReady(f, testVM, framework.LongTimeout) + + initialIfaceCount = getIfaceCount() + Expect(initialIfaceCount).To(BeNumerically(">=", 1), + "VM should have at least one non-loopback interface") + }) + + By("Hotplug: add a ClusterNetwork to spec.networks", func() { + err := f.Clients.GenericClient().Get(context.Background(), crclient.ObjectKeyFromObject(testVM), testVM) + Expect(err).NotTo(HaveOccurred()) + testVM.Spec.Networks = append(testVM.Spec.Networks, v1alpha2.NetworksSpec{ + Type: v1alpha2.NetworksTypeClusterNetwork, + Name: util.ClusterNetworkName(additionalInterfaceVLANID), + }) + err = f.Clients.GenericClient().Update(context.Background(), testVM) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Verify new interface appears in the guest OS", func() { + util.UntilConditionStatus(vmcondition.TypeNetworkReady.String(), "True", framework.LongTimeout, testVM) + Eventually(getIfaceCount). + WithTimeout(framework.LongTimeout). + WithPolling(3 * time.Second). + Should(Equal(initialIfaceCount+1), "new interface should appear after hotplug") + }) + + By("Verify VM did not ask for restart after hotplug", expectNoRestartRequired) + + By("Hotunplug: remove the ClusterNetwork from spec.networks", func() { + err := f.Clients.GenericClient().Get(context.Background(), crclient.ObjectKeyFromObject(testVM), testVM) + Expect(err).NotTo(HaveOccurred()) + testVM.Spec.Networks = []v1alpha2.NetworksSpec{testVM.Spec.Networks[0]} + err = f.Clients.GenericClient().Update(context.Background(), testVM) + Expect(err).NotTo(HaveOccurred()) + }) + + By("Verify interface disappears from the guest OS", func() { + Eventually(getIfaceCount). + WithTimeout(framework.LongTimeout). + WithPolling(3 * time.Second). + Should(Equal(initialIfaceCount), "interface should disappear after hotunplug") + }) + + By("Verify VM did not ask for restart after hotunplug", expectNoRestartRequired) + + By("Verify VM phase stayed Running throughout", func() { + err := f.Clients.GenericClient().Get(context.Background(), crclient.ObjectKeyFromObject(testVM), testVM) + Expect(err).NotTo(HaveOccurred()) + Expect(string(testVM.Status.Phase)).To(Equal(string(v1alpha2.MachineRunning))) + }) + }) + }) }) // buildVMWithNetworks creates a VM with optional Main + ClusterNetwork. From 4ec3be412f64d6ce66fae59b622f09a732c7d153 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Thu, 16 Apr 2026 19:06:59 +0300 Subject: [PATCH 15/27] fix linter errors Signed-off-by: Daniil Loktev --- .../pkg/controller/vmchange/compare_test.go | 5 +++++ test/e2e/vm/additional_network_interfaces.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go b/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go index 562ffcf3e8..0887d71bcb 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go @@ -498,6 +498,7 @@ networks: name: additional id: 2 `, + nil, assertChanges( actionRequired(ActionApplyImmediate), requirePathOperation("networks", ChangeAdd), @@ -518,6 +519,7 @@ networks: name: additional id: 2 `, + nil, assertChanges( actionRequired(ActionApplyImmediate), requirePathOperation("networks", ChangeReplace), @@ -538,6 +540,7 @@ networks: - type: Main id: 1 `, + nil, assertChanges( actionRequired(ActionApplyImmediate), requirePathOperation("networks", ChangeReplace), @@ -559,6 +562,7 @@ networks: name: net1 id: 2 `, + nil, assertChanges( actionRequired(ActionRestart), requirePathOperation("networks", ChangeReplace), @@ -582,6 +586,7 @@ networks: name: net1 id: 2 `, + nil, assertChanges( actionRequired(ActionRestart), requirePathOperation("networks", ChangeReplace), diff --git a/test/e2e/vm/additional_network_interfaces.go b/test/e2e/vm/additional_network_interfaces.go index 3900e02d6a..2165d8d79e 100644 --- a/test/e2e/vm/additional_network_interfaces.go +++ b/test/e2e/vm/additional_network_interfaces.go @@ -25,8 +25,8 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" crclient "sigs.k8s.io/controller-runtime/pkg/client" From 8ff26336bba86c6a05a3595887208dd07a394392 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Thu, 16 Apr 2026 21:32:07 +0300 Subject: [PATCH 16/27] fix linter errors Signed-off-by: Daniil Loktev --- test/e2e/vm/additional_network_interfaces.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/vm/additional_network_interfaces.go b/test/e2e/vm/additional_network_interfaces.go index 2165d8d79e..55d67ab83a 100644 --- a/test/e2e/vm/additional_network_interfaces.go +++ b/test/e2e/vm/additional_network_interfaces.go @@ -317,7 +317,7 @@ var _ = Describe("VirtualMachineAdditionalNetworkInterfaces", func() { util.UntilConditionStatus(vmcondition.TypeNetworkReady.String(), "True", framework.LongTimeout, testVM) Eventually(getIfaceCount). WithTimeout(framework.LongTimeout). - WithPolling(3 * time.Second). + WithPolling(3*time.Second). Should(Equal(initialIfaceCount+1), "new interface should appear after hotplug") }) @@ -334,7 +334,7 @@ var _ = Describe("VirtualMachineAdditionalNetworkInterfaces", func() { By("Verify interface disappears from the guest OS", func() { Eventually(getIfaceCount). WithTimeout(framework.LongTimeout). - WithPolling(3 * time.Second). + WithPolling(3*time.Second). Should(Equal(initialIfaceCount), "interface should disappear after hotunplug") }) From f990e8917369be013bcc844384ab475bb1fa08df Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Fri, 8 May 2026 17:20:21 +0300 Subject: [PATCH 17/27] add fromCacheVersion Signed-off-by: Daniil Loktev --- images/virt-artifact/werf.inc.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/images/virt-artifact/werf.inc.yaml b/images/virt-artifact/werf.inc.yaml index 6381ba5eae..e2a93c3b06 100644 --- a/images/virt-artifact/werf.inc.yaml +++ b/images/virt-artifact/werf.inc.yaml @@ -9,6 +9,7 @@ image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false fromImage: builder/src +fromCacheVersion: "2026-05-08" secrets: - id: SOURCE_REPO value: {{ $.SOURCE_REPO }} @@ -44,6 +45,7 @@ packages: image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-alt-1.25" "builder/golang-alt-svace-1.25" }} +fromCacheVersion: "2026-05-08" mount: {{- include "mount points for golang builds" . }} secrets: From 2d91ed3baaca5558d6f7ce717726ad9b25cc2785 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Fri, 8 May 2026 18:13:06 +0300 Subject: [PATCH 18/27] refactor isOnlyNonMainNetworksChanged Signed-off-by: Daniil Loktev --- .../vmchange/comparator_pod_placement.go | 16 ++---- .../pkg/controller/vmchange/compare_test.go | 56 +++++++++++++++++-- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go index b8f8de9b7c..f278b473e1 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/comparator_pod_placement.go @@ -109,17 +109,11 @@ func compareNetworks(current, desired *v1alpha2.VirtualMachineSpec) []FieldChang // between current and desired (so only non-Main networks differ). // Empty networks list is equivalent to having an implicit default Main. func isOnlyNonMainNetworksChanged(current, desired []v1alpha2.NetworksSpec) bool { - currentMain := network.GetMainNetworkSpec(current) - desiredMain := network.GetMainNetworkSpec(desired) - currentHasMain := currentMain != nil || len(current) == 0 - desiredHasMain := desiredMain != nil || len(desired) == 0 - if !currentHasMain || !desiredHasMain { - return false - } - if currentMain == nil || desiredMain == nil { - return true - } - return reflect.DeepEqual(*currentMain, *desiredMain) + return hasMainNetwork(current) == hasMainNetwork(desired) +} + +func hasMainNetwork(networks []v1alpha2.NetworksSpec) bool { + return len(networks) == 0 || network.GetMainNetworkSpec(networks) != nil } func isOnlyNetworkIDAutofillChange(current, desired []v1alpha2.NetworksSpec) bool { diff --git a/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go b/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go index 0887d71bcb..e253561ddd 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/compare_test.go @@ -569,20 +569,64 @@ networks: ), }, { - "restart when main network id changes", + "apply immediate when removing non-main network from VM without main", ` networks: -- type: Main - id: 1 -- type: Network +- type: ClusterNetwork + name: net1 + id: 2 +- type: ClusterNetwork + name: net2 + id: 3 +`, + ` +networks: +- type: ClusterNetwork + name: net1 + id: 2 +`, + nil, + assertChanges( + actionRequired(ActionApplyImmediate), + requirePathOperation("networks", ChangeReplace), + ), + }, + { + "apply immediate when adding non-main network to VM without main", + ` +networks: +- type: ClusterNetwork + name: net1 + id: 2 +`, + ` +networks: +- type: ClusterNetwork + name: net1 + id: 2 +- type: ClusterNetwork + name: net2 + id: 3 +`, + nil, + assertChanges( + actionRequired(ActionApplyImmediate), + requirePathOperation("networks", ChangeReplace), + ), + }, + { + "restart when only-non-main spec gains main network", + ` +networks: +- type: ClusterNetwork name: net1 id: 2 `, ` networks: - type: Main - id: 5 -- type: Network + id: 1 +- type: ClusterNetwork name: net1 id: 2 `, From b9dc54a93f75787ea978ac533e874e7a0afc032c Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 12 May 2026 12:02:52 +0300 Subject: [PATCH 19/27] add validation for adding non existing networks Signed-off-by: Daniil Loktev --- .../internal/validators/networks_validator.go | 75 +++++++++++++++- .../validators/networks_validator_test.go | 90 ++++++++++++++++++- .../pkg/controller/vm/vm_webhook.go | 2 +- 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go index 1ff3085efb..21cf707216 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go @@ -21,8 +21,13 @@ import ( "fmt" "k8s.io/apimachinery/pkg/api/equality" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/component-base/featuregate" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" commonnetwork "github.com/deckhouse/virtualization-controller/pkg/common/network" @@ -30,12 +35,19 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) +var ( + networkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "Network"} + clusterNetworkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "ClusterNetwork"} +) + type NetworksValidator struct { + client client.Client featureGate featuregate.FeatureGate } -func NewNetworksValidator(featureGate featuregate.FeatureGate) *NetworksValidator { +func NewNetworksValidator(c client.Client, featureGate featuregate.FeatureGate) *NetworksValidator { return &NetworksValidator{ + client: c, featureGate: featureGate, } } @@ -53,7 +65,7 @@ func (v *NetworksValidator) ValidateCreate(_ context.Context, vm *v1alpha2.Virtu return v.validateNetworksSpec(networksSpec) } -func (v *NetworksValidator) ValidateUpdate(_ context.Context, oldVM, newVM *v1alpha2.VirtualMachine) (admission.Warnings, error) { +func (v *NetworksValidator) ValidateUpdate(ctx context.Context, oldVM, newVM *v1alpha2.VirtualMachine) (admission.Warnings, error) { newNetworksSpec := newVM.Spec.Networks if len(newNetworksSpec) == 0 { return nil, nil @@ -68,12 +80,67 @@ func (v *NetworksValidator) ValidateUpdate(_ context.Context, oldVM, newVM *v1al } isChanged := !equality.Semantic.DeepEqual(newNetworksSpec, oldVM.Spec.Networks) - if isChanged { - return v.validateNetworksSpec(newNetworksSpec) + if !isChanged { + return nil, nil + } + + if warn, err := v.validateNetworksSpec(newNetworksSpec); err != nil { + return warn, err + } + + added := networksAdded(oldVM.Spec.Networks, newNetworksSpec) + if len(added) == 0 { + return nil, nil + } + return v.validateNetworksExist(ctx, newVM.Namespace, added) +} + +func (v *NetworksValidator) validateNetworksExist(ctx context.Context, namespace string, networks []v1alpha2.NetworksSpec) (admission.Warnings, error) { + if v.client == nil { + return nil, nil + } + for _, n := range networks { + switch n.Type { + case v1alpha2.NetworksTypeClusterNetwork: + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(clusterNetworkGVK) + err := v.client.Get(ctx, types.NamespacedName{Name: n.Name}, obj) + if k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("ClusterNetwork %q referenced in spec.networks does not exist", n.Name) + } + if err != nil { + return nil, fmt.Errorf("failed to verify ClusterNetwork %q: %w", n.Name, err) + } + case v1alpha2.NetworksTypeNetwork: + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(networkGVK) + err := v.client.Get(ctx, types.NamespacedName{Name: n.Name, Namespace: namespace}, obj) + if k8serrors.IsNotFound(err) { + return nil, fmt.Errorf("Network %q referenced in spec.networks does not exist in namespace %q", n.Name, namespace) + } + if err != nil { + return nil, fmt.Errorf("failed to verify Network %q: %w", n.Name, err) + } + } } return nil, nil } +func networksAdded(oldSpec, newSpec []v1alpha2.NetworksSpec) []v1alpha2.NetworksSpec { + oldKey := func(n v1alpha2.NetworksSpec) string { return n.Type + "/" + n.Name } + old := make(map[string]struct{}, len(oldSpec)) + for _, n := range oldSpec { + old[oldKey(n)] = struct{}{} + } + var added []v1alpha2.NetworksSpec + for _, n := range newSpec { + if _, ok := old[oldKey(n)]; !ok { + added = append(added, n) + } + } + return added +} + func isSingleMainNet(networks []v1alpha2.NetworksSpec) bool { return len(networks) == 1 && networks[0].Type == v1alpha2.NetworksTypeMain } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go index 08a6ddeced..952f0cc15e 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go @@ -20,7 +20,11 @@ import ( "fmt" "testing" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -83,7 +87,7 @@ func TestNetworksValidateCreate(t *testing.T) { if test.sdnEnabled { _ = setFromMap(map[string]bool{string(featuregates.SDN): true}) } - networkValidator := NewNetworksValidator(featureGate) + networkValidator := NewNetworksValidator(nil, featureGate) _, err := networkValidator.ValidateCreate(t.Context(), vm) if test.valid && err != nil { @@ -330,7 +334,7 @@ func TestNetworksValidateUpdate(t *testing.T) { string(featuregates.SDN): true, }) } - networkValidator := NewNetworksValidator(featureGate) + networkValidator := NewNetworksValidator(nil, featureGate) _, err := networkValidator.ValidateUpdate(t.Context(), oldVM, newVM) if test.valid && err != nil { @@ -348,3 +352,85 @@ func TestNetworksValidateUpdate(t *testing.T) { }) } } + +func newUnstructured(gvk schema.GroupVersionKind, name, namespace string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + u.SetName(name) + if namespace != "" { + u.SetNamespace(namespace) + } + return u +} + +func TestNetworksValidatesExistence(t *testing.T) { + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(clusterNetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(networkGVK, &unstructured.Unstructured{}) + + existingCN := newUnstructured(clusterNetworkGVK, "exists-cn", "") + existingNet := newUnstructured(networkGVK, "exists-net", "default") + + cli := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(existingCN, existingNet). + Build() + + featureGate, _, setFromMap, _ := featuregates.New() + _ = setFromMap(map[string]bool{string(featuregates.SDN): true}) + v := NewNetworksValidator(cli, featureGate) + + t.Run("create: missing networks are allowed (no existence check)", func(t *testing.T) { + vm := &v1alpha2.VirtualMachine{} + vm.Namespace = "default" + vm.Spec.Networks = []v1alpha2.NetworksSpec{mainNetwork, {Type: v1alpha2.NetworksTypeClusterNetwork, Name: "ghost"}} + if _, err := v.ValidateCreate(t.Context(), vm); err != nil { + t.Fatalf("ValidateCreate must not check network existence; got: %v", err) + } + }) + + t.Run("update: adding existing ClusterNetwork passes", func(t *testing.T) { + oldVM := &v1alpha2.VirtualMachine{} + oldVM.Namespace = "default" + oldVM.Spec.Networks = []v1alpha2.NetworksSpec{mainNetwork} + newVM := oldVM.DeepCopy() + newVM.Spec.Networks = append(newVM.Spec.Networks, v1alpha2.NetworksSpec{Type: v1alpha2.NetworksTypeClusterNetwork, Name: "exists-cn"}) + if _, err := v.ValidateUpdate(t.Context(), oldVM, newVM); err != nil { + t.Fatalf("expected no error; got: %v", err) + } + }) + + t.Run("update: adding existing Network passes", func(t *testing.T) { + oldVM := &v1alpha2.VirtualMachine{} + oldVM.Namespace = "default" + oldVM.Spec.Networks = []v1alpha2.NetworksSpec{mainNetwork} + newVM := oldVM.DeepCopy() + newVM.Spec.Networks = append(newVM.Spec.Networks, v1alpha2.NetworksSpec{Type: v1alpha2.NetworksTypeNetwork, Name: "exists-net"}) + if _, err := v.ValidateUpdate(t.Context(), oldVM, newVM); err != nil { + t.Fatalf("expected no error; got: %v", err) + } + }) + + t.Run("update: existing networks are not re-checked", func(t *testing.T) { + oldVM := &v1alpha2.VirtualMachine{} + oldVM.Namespace = "default" + oldVM.Spec.Networks = []v1alpha2.NetworksSpec{mainNetwork, {Type: v1alpha2.NetworksTypeClusterNetwork, Name: "ghost"}} + newVM := oldVM.DeepCopy() + // Add another non-Main network; existing "ghost" should not be re-checked. + newVM.Spec.Networks = append(newVM.Spec.Networks, v1alpha2.NetworksSpec{Type: v1alpha2.NetworksTypeClusterNetwork, Name: "exists-cn"}) + if _, err := v.ValidateUpdate(t.Context(), oldVM, newVM); err != nil { + t.Fatalf("expected no error for adding an existing network when prior spec had a missing one; got: %v", err) + } + }) + + t.Run("update: adding a missing network is rejected", func(t *testing.T) { + oldVM := &v1alpha2.VirtualMachine{} + oldVM.Namespace = "default" + oldVM.Spec.Networks = []v1alpha2.NetworksSpec{mainNetwork, {Type: v1alpha2.NetworksTypeClusterNetwork, Name: "exists-cn"}} + newVM := oldVM.DeepCopy() + newVM.Spec.Networks = append(newVM.Spec.Networks, v1alpha2.NetworksSpec{Type: v1alpha2.NetworksTypeClusterNetwork, Name: "ghost"}) + if _, err := v.ValidateUpdate(t.Context(), oldVM, newVM); err == nil { + t.Fatalf("expected error when adding a missing network") + } + }) +} diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go index a10bca439b..1406df6a2a 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go @@ -53,7 +53,7 @@ func NewValidator(client client.Client, service *service.BlockDeviceService, fea validators.NewAffinityValidator(), validators.NewTopologySpreadConstraintValidator(), validators.NewCPUCountValidator(), - validators.NewNetworksValidator(featureGate), + validators.NewNetworksValidator(client, featureGate), validators.NewFirstDiskValidator(client), validators.NewUSBDevicesValidator(client, featureGate), }, From c67e3dd53501ed79409803755038fc38fc0910c4 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 12 May 2026 12:26:12 +0300 Subject: [PATCH 20/27] fix main network removal Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm.go | 20 ++++++++++++++++++ .../pkg/controller/kvbuilder/kvvm_test.go | 21 +++++++++++++++++++ .../pkg/controller/kvbuilder/kvvm_utils.go | 9 ++++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index d00f8b89b8..f9e3a98a13 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -753,6 +753,26 @@ func (b *KVVM) SetNetworkInterfaceAbsent(name string) { } } +func (b *KVVM) RemoveNetworkInterface(name string) { + ifaces := b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces + filtered := ifaces[:0] + for _, iface := range ifaces { + if iface.Name != name { + filtered = append(filtered, iface) + } + } + b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces = filtered + + nets := b.Resource.Spec.Template.Spec.Networks + filteredNets := nets[:0] + for _, n := range nets { + if n.Name != name { + filteredNets = append(filteredNets, n) + } + } + b.Resource.Spec.Template.Spec.Networks = filteredNets +} + func (b *KVVM) SetNetworkInterface(name, macAddress string, acpiIndex int) { net := virtv1.Network{ Name: name, diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 0956d74c21..0c0d1b39f0 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -216,6 +216,27 @@ func TestSetNetworkMarksRemovedAsAbsent(t *testing.T) { } } +func TestSetNetworkRemovesDefaultEntirely(t *testing.T) { + b := newTestKVVM() + b.SetNetworkInterface("default", "", 1) + b.SetNetworkInterface("veth_n12345678", "aa:bb:cc:dd:ee:ff", 2) + + setNetwork(b, network.InterfaceSpecList{ + {InterfaceName: "veth_n12345678", MAC: "aa:bb:cc:dd:ee:ff", ID: 2}, + }) + + for _, iface := range b.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.Name == "default" { + t.Fatalf("default interface must be removed from KVVM template when Main is dropped (KubeVirt rejects State: absent on default)") + } + } + for _, n := range b.Resource.Spec.Template.Spec.Networks { + if n.Name == "default" { + t.Fatalf("default network entry must be removed alongside the interface") + } + } +} + func TestSetNetworkAddsNewInterface(t *testing.T) { b := newTestKVVM() b.SetNetworkInterface("default", "", 1) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go index 93d6c1df78..e0c7faa568 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_utils.go @@ -361,9 +361,14 @@ func setNetwork(kvvm *KVVM, networkSpec network.InterfaceSpecList) { } for _, iface := range kvvm.Resource.Spec.Template.Spec.Domain.Devices.Interfaces { - if _, wanted := desiredByName[iface.Name]; !wanted { - kvvm.SetNetworkInterfaceAbsent(iface.Name) + if _, wanted := desiredByName[iface.Name]; wanted { + continue + } + if iface.Name == network.NameDefaultInterface { + kvvm.RemoveNetworkInterface(iface.Name) + continue } + kvvm.SetNetworkInterfaceAbsent(iface.Name) } for _, n := range networkSpec { From 0bb955f8c7deb37ce2cd9d1a83ad69b7d35381bf Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 12 May 2026 13:40:47 +0300 Subject: [PATCH 21/27] add networks to rbac Signed-off-by: Daniil Loktev --- templates/virtualization-controller/rbac-for-us.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 8bc92dc654..fd9a6efacd 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -327,6 +327,15 @@ rules: - get - update - patch +- apiGroups: + - network.deckhouse.io + resources: + - networks + - clusternetworks + verbs: + - get + - list + - watch --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding From 532429704c67f192b1141f76b0055df85c92f008 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 19 May 2026 13:09:25 +0300 Subject: [PATCH 22/27] add handling of non-ready networks Signed-off-by: Daniil Loktev --- .../pkg/common/network/readiness.go | 86 ++++++++++++ .../pkg/common/testutil/testutil.go | 9 ++ .../pkg/controller/vm/internal/network.go | 54 +++++--- .../controller/vm/internal/network_test.go | 20 ++- .../pkg/controller/vm/internal/state/state.go | 5 + .../pkg/controller/vm/internal/sync_kvvm.go | 35 ++++- .../internal/validators/networks_validator.go | 10 +- .../validators/networks_validator_test.go | 9 +- .../vm/internal/watcher/network_watcher.go | 126 ++++++++++++++++++ .../pkg/controller/vm/vm_reconciler.go | 1 + 10 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 images/virtualization-artifact/pkg/common/network/readiness.go create mode 100644 images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go diff --git a/images/virtualization-artifact/pkg/common/network/readiness.go b/images/virtualization-artifact/pkg/common/network/readiness.go new file mode 100644 index 0000000000..6a742da4cd --- /dev/null +++ b/images/virtualization-artifact/pkg/common/network/readiness.go @@ -0,0 +1,86 @@ +/* +Copyright 2026 Flant JSC + +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 network + +import ( + "context" + "fmt" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var ( + NetworkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "Network"} + ClusterNetworkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "ClusterNetwork"} +) + +// SpecKey returns a stable "/" identifier for a NetworksSpec entry, +// used in log messages and condition text. +func SpecKey(ns v1alpha2.NetworksSpec) string { + return fmt.Sprintf("%s/%s", ns.Type, ns.Name) +} + +func IsNetworkSpecReady(ctx context.Context, c client.Client, namespace string, ns v1alpha2.NetworksSpec) (bool, error) { + if ns.Type == v1alpha2.NetworksTypeMain { + return true, nil + } + obj := &unstructured.Unstructured{} + key := types.NamespacedName{Name: ns.Name} + switch ns.Type { + case v1alpha2.NetworksTypeClusterNetwork: + obj.SetGroupVersionKind(ClusterNetworkGVK) + case v1alpha2.NetworksTypeNetwork: + obj.SetGroupVersionKind(NetworkGVK) + key.Namespace = namespace + default: + return false, nil + } + if err := c.Get(ctx, key, obj); err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return isReadyTrue(obj), nil +} + +func isReadyTrue(obj *unstructured.Unstructured) bool { + conds, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions") + if err != nil || !found { + return false + } + for _, c := range conds { + condMap, ok := c.(map[string]any) + if !ok { + continue + } + typ, _, _ := unstructured.NestedString(condMap, "type") + if typ != "Ready" { + continue + } + status, _, _ := unstructured.NestedString(condMap, "status") + return status == string(metav1.ConditionTrue) + } + return false +} diff --git a/images/virtualization-artifact/pkg/common/testutil/testutil.go b/images/virtualization-artifact/pkg/common/testutil/testutil.go index c4e139a0a7..d5964d1ed9 100644 --- a/images/virtualization-artifact/pkg/common/testutil/testutil.go +++ b/images/virtualization-artifact/pkg/common/testutil/testutil.go @@ -22,6 +22,7 @@ import ( "reflect" "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" apiruntime "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" virtv1 "kubevirt.io/api/core/v1" @@ -31,11 +32,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "github.com/deckhouse/deckhouse/pkg/log" + commonnetwork "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha3" ) +func registerNetworkGVKs(scheme *apiruntime.Scheme) { + scheme.AddKnownTypeWithName(commonnetwork.NetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(commonnetwork.ClusterNetworkGVK, &unstructured.Unstructured{}) +} + func NewFakeClientWithObjects(objs ...client.Object) (client.WithWatch, error) { scheme := apiruntime.NewScheme() for _, f := range []func(*apiruntime.Scheme) error{ @@ -50,6 +57,7 @@ func NewFakeClientWithObjects(objs ...client.Object) (client.WithWatch, error) { return nil, err } } + registerNetworkGVKs(scheme) var newObjs []client.Object for _, obj := range objs { if reflect.ValueOf(obj).IsNil() { @@ -79,6 +87,7 @@ func NewFakeClientWithInterceptorWithObjects(interceptor interceptor.Funcs, objs return nil, err } } + registerNetworkGVKs(scheme) var newObjs []client.Object for _, obj := range objs { if reflect.ValueOf(obj).IsNil() { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/network.go b/images/virtualization-artifact/pkg/controller/vm/internal/network.go index 9c852940cb..bb4dd1df2c 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/network.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/network.go @@ -73,29 +73,53 @@ func (h *NetworkInterfaceHandler) Handle(ctx context.Context, s state.VirtualMac }() if !hasOnlyDefaultNetwork(vm) { - if !h.featureGate.Enabled(featuregates.SDN) { - cb.Status(metav1.ConditionFalse).Reason(vmcondition.ReasonSDNModuleDisabled).Message("For additional network interfaces, please enable SDN module") - return reconcile.Result{}, nil - } - - pods, err := s.Pods(ctx) - if err != nil { + if err := h.evaluateAdditionalNetworks(ctx, s, vm, cb); err != nil { return reconcile.Result{}, err } + } + + return h.UpdateNetworkStatus(ctx, s, vm) +} + +// evaluateAdditionalNetworks sets cb based on whether additional networks are +// usable: requires SDN feature gate, then that referenced Networks/ClusterNetworks +// are Ready, and finally that SDN reports the per-pod interfaces healthy. +func (h *NetworkInterfaceHandler) evaluateAdditionalNetworks(ctx context.Context, s state.VirtualMachineState, vm *v1alpha2.VirtualMachine, cb *conditions.ConditionBuilder) error { + if !h.featureGate.Enabled(featuregates.SDN) { + cb.Status(metav1.ConditionFalse).Reason(vmcondition.ReasonSDNModuleDisabled).Message("For additional network interfaces, please enable SDN module") + return nil + } - errMsg, err := extractNetworkStatusFromPods(pods) + var pending []string + for _, ns := range vm.Spec.Networks { + ready, err := network.IsNetworkSpecReady(ctx, s.Client(), vm.Namespace, ns) if err != nil { - return reconcile.Result{}, err + return err } - - if errMsg != "" { - cb.Status(metav1.ConditionFalse).Reason(vmcondition.ReasonNetworkNotReady).Message(errMsg) - } else { - cb.Status(metav1.ConditionTrue).Reason(vmcondition.ReasonNetworkReady).Message("") + if !ready { + pending = append(pending, network.SpecKey(ns)) } } + if len(pending) > 0 { + cb.Status(metav1.ConditionFalse).Reason(vmcondition.ReasonNetworkNotReady). + Message(fmt.Sprintf("Waiting for the following networks to become Ready: %s. They will be attached automatically once available.", strings.Join(pending, ", "))) + return nil + } - return h.UpdateNetworkStatus(ctx, s, vm) + pods, err := s.Pods(ctx) + if err != nil { + return err + } + errMsg, err := extractNetworkStatusFromPods(pods) + if err != nil { + return err + } + if errMsg != "" { + cb.Status(metav1.ConditionFalse).Reason(vmcondition.ReasonNetworkNotReady).Message(errMsg) + return nil + } + cb.Status(metav1.ConditionTrue).Reason(vmcondition.ReasonNetworkReady).Message("") + return nil } func hasOnlyDefaultNetwork(vm *v1alpha2.VirtualMachine) bool { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/network_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/network_test.go index 0ce49c45f7..f74e4977e3 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/network_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/network_test.go @@ -23,11 +23,13 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" virtv1 "kubevirt.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + commonnetwork "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization-controller/pkg/common/testutil" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" @@ -37,6 +39,20 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" ) +func newReadyNetwork(name, namespace string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(commonnetwork.NetworkGVK) + u.SetName(name) + u.SetNamespace(namespace) + _ = unstructured.SetNestedSlice(u.Object, []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, "status", "conditions") + return u +} + var _ = Describe("NetworkInterfaceHandler", func() { const ( name = "vm" @@ -225,7 +241,7 @@ var _ = Describe("NetworkInterfaceHandler", func() { ] } ]` - fakeClient, resource, vmState = setupEnvironment(vm, vmPod, mac1) + fakeClient, resource, vmState = setupEnvironment(vm, vmPod, mac1, newReadyNetwork("test-network", namespace)) reconcile() newVM := &v1alpha2.VirtualMachine{} @@ -277,7 +293,7 @@ var _ = Describe("NetworkInterfaceHandler", func() { ] } ]` - fakeClient, resource, vmState = setupEnvironment(vm, vmPod, mac1) + fakeClient, resource, vmState = setupEnvironment(vm, vmPod, mac1, newReadyNetwork("test-network", namespace)) reconcile() newVM := &v1alpha2.VirtualMachine{} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go index fddc328bae..7a2ed6d969 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -41,6 +41,7 @@ import ( ) type VirtualMachineState interface { + Client() client.Client VirtualMachine() *reconciler.Resource[*v1alpha2.VirtualMachine, v1alpha2.VirtualMachineStatus] KVVM(ctx context.Context) (*virtv1.VirtualMachine, error) KVVMI(ctx context.Context) (*virtv1.VirtualMachineInstance, error) @@ -102,6 +103,10 @@ func (s *state) fill() { } } +func (s *state) Client() client.Client { + return s.client +} + func (s *state) Shared(fn func(s *Shared)) { fn(&s.shared) } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 94464ec91c..8b5b6b8c27 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -495,7 +495,11 @@ func MakeKVVMFromVMSpec(ctx context.Context, s state.VirtualMachineState) (*virt return nil, err } - networkSpec := network.CreateNetworkSpec(current, vmmacs) + filteredVM, err := filterReadyNetworks(ctx, s.Client(), current) + if err != nil { + return nil, err + } + networkSpec := network.CreateNetworkSpec(filteredVM, vmmacs) kvvmi, err := s.KVVMI(ctx) if err != nil { @@ -788,6 +792,28 @@ func (h *SyncKvvmHandler) isVMUnschedulable( return false } +func filterReadyNetworks(ctx context.Context, c client.Client, vm *v1alpha2.VirtualMachine) (*v1alpha2.VirtualMachine, error) { + if c == nil || vm == nil || len(vm.Spec.Networks) == 0 { + return vm, nil + } + kept := make([]v1alpha2.NetworksSpec, 0, len(vm.Spec.Networks)) + for _, ns := range vm.Spec.Networks { + ready, err := network.IsNetworkSpecReady(ctx, c, vm.Namespace, ns) + if err != nil { + return nil, fmt.Errorf("check readiness for network %s: %w", network.SpecKey(ns), err) + } + if ready { + kept = append(kept, ns) + } + } + if len(kept) == len(vm.Spec.Networks) { + return vm, nil + } + out := vm.DeepCopy() + out.Spec.Networks = kept + return out, nil +} + func hasNetworkChange(changes vmchange.SpecChanges) bool { for _, c := range changes.GetAll() { if c.Path == "networks" { @@ -829,7 +855,12 @@ func (h *SyncKvvmHandler) patchPodNetworkAnnotation(ctx context.Context, s state return err } - networkConfigStr, err := network.CreateNetworkSpec(current, vmmacs).ToString() + filteredVM, err := filterReadyNetworks(ctx, s.Client(), current) + if err != nil { + return err + } + + networkConfigStr, err := network.CreateNetworkSpec(filteredVM, vmmacs).ToString() if err != nil { return fmt.Errorf("failed to serialize network spec: %w", err) } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go index 21cf707216..21872742c5 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator.go @@ -23,7 +23,6 @@ import ( "k8s.io/apimachinery/pkg/api/equality" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/component-base/featuregate" "k8s.io/utils/ptr" @@ -35,11 +34,6 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -var ( - networkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "Network"} - clusterNetworkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "ClusterNetwork"} -) - type NetworksValidator struct { client client.Client featureGate featuregate.FeatureGate @@ -103,7 +97,7 @@ func (v *NetworksValidator) validateNetworksExist(ctx context.Context, namespace switch n.Type { case v1alpha2.NetworksTypeClusterNetwork: obj := &unstructured.Unstructured{} - obj.SetGroupVersionKind(clusterNetworkGVK) + obj.SetGroupVersionKind(commonnetwork.ClusterNetworkGVK) err := v.client.Get(ctx, types.NamespacedName{Name: n.Name}, obj) if k8serrors.IsNotFound(err) { return nil, fmt.Errorf("ClusterNetwork %q referenced in spec.networks does not exist", n.Name) @@ -113,7 +107,7 @@ func (v *NetworksValidator) validateNetworksExist(ctx context.Context, namespace } case v1alpha2.NetworksTypeNetwork: obj := &unstructured.Unstructured{} - obj.SetGroupVersionKind(networkGVK) + obj.SetGroupVersionKind(commonnetwork.NetworkGVK) err := v.client.Get(ctx, types.NamespacedName{Name: n.Name, Namespace: namespace}, obj) if k8serrors.IsNotFound(err) { return nil, fmt.Errorf("Network %q referenced in spec.networks does not exist in namespace %q", n.Name, namespace) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go index 952f0cc15e..347ee3591f 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/networks_validator_test.go @@ -26,6 +26,7 @@ import ( "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client/fake" + commonnetwork "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -365,11 +366,11 @@ func newUnstructured(gvk schema.GroupVersionKind, name, namespace string) *unstr func TestNetworksValidatesExistence(t *testing.T) { scheme := runtime.NewScheme() - scheme.AddKnownTypeWithName(clusterNetworkGVK, &unstructured.Unstructured{}) - scheme.AddKnownTypeWithName(networkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(commonnetwork.ClusterNetworkGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(commonnetwork.NetworkGVK, &unstructured.Unstructured{}) - existingCN := newUnstructured(clusterNetworkGVK, "exists-cn", "") - existingNet := newUnstructured(networkGVK, "exists-net", "default") + existingCN := newUnstructured(commonnetwork.ClusterNetworkGVK, "exists-cn", "") + existingNet := newUnstructured(commonnetwork.NetworkGVK, "exists-net", "default") cli := fake.NewClientBuilder(). WithScheme(scheme). diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go new file mode 100644 index 0000000000..b6e4951ff7 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go @@ -0,0 +1,126 @@ +/* +Copyright 2026 Flant JSC + +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 watcher + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/deckhouse/pkg/log" + commonnetwork "github.com/deckhouse/virtualization-controller/pkg/common/network" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +// NewNetworkWatcher enqueues VMs that reference a Network or ClusterNetwork +// when that network's Ready condition changes. +func NewNetworkWatcher(c client.Client) *NetworkWatcher { + return &NetworkWatcher{client: c} +} + +type NetworkWatcher struct { + client client.Client +} + +func (w *NetworkWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + if err := w.watchOne(mgr, ctr, commonnetwork.ClusterNetworkGVK, v1alpha2.NetworksTypeClusterNetwork, true); err != nil { + return err + } + return w.watchOne(mgr, ctr, commonnetwork.NetworkGVK, v1alpha2.NetworksTypeNetwork, false) +} + +func (w *NetworkWatcher) watchOne(mgr manager.Manager, ctr controller.Controller, gvk schema.GroupVersionKind, networkType string, clusterScoped bool) error { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + + if err := ctr.Watch( + source.Kind( + mgr.GetCache(), + obj, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o *unstructured.Unstructured) []reconcile.Request { + return w.enqueueVMsReferencingNetwork(ctx, o, networkType, clusterScoped) + }), + predicate.TypedFuncs[*unstructured.Unstructured]{ + CreateFunc: func(e event.TypedCreateEvent[*unstructured.Unstructured]) bool { return true }, + DeleteFunc: func(e event.TypedDeleteEvent[*unstructured.Unstructured]) bool { return true }, + UpdateFunc: func(e event.TypedUpdateEvent[*unstructured.Unstructured]) bool { + return readyConditionStatus(e.ObjectOld) != readyConditionStatus(e.ObjectNew) + }, + }, + ), + ); err != nil { + return fmt.Errorf("error setting watch on %s: %w", gvk.Kind, err) + } + return nil +} + +func (w *NetworkWatcher) enqueueVMsReferencingNetwork(ctx context.Context, obj *unstructured.Unstructured, networkType string, clusterScoped bool) []reconcile.Request { + var vms v1alpha2.VirtualMachineList + listOpts := &client.ListOptions{} + if !clusterScoped { + listOpts.Namespace = obj.GetNamespace() + } + if err := w.client.List(ctx, &vms, listOpts); err != nil { + log.Default().Error(fmt.Sprintf("network watcher: failed to list VMs: %s", err)) + return nil + } + + wantName := obj.GetName() + var requests []reconcile.Request + for _, vm := range vms.Items { + for _, ns := range vm.Spec.Networks { + if ns.Type == networkType && ns.Name == wantName { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + }) + break + } + } + } + return requests +} + +func readyConditionStatus(obj *unstructured.Unstructured) string { + conds, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions") + if err != nil || !found { + return "" + } + for _, c := range conds { + m, ok := c.(map[string]any) + if !ok { + continue + } + t, _, _ := unstructured.NestedString(m, "type") + if t != "Ready" { + continue + } + s, _, _ := unstructured.NestedString(m, "status") + return s + } + return "" +} diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go b/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go index de25ef3382..95b5f018c8 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go @@ -76,6 +76,7 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr watcher.NewVMOPWatcher(), watcher.NewVMMACWatcher(), watcher.NewSecretWatcher(mgr.GetClient()), + watcher.NewNetworkWatcher(mgr.GetClient()), } { err := w.Watch(mgr, ctr) if err != nil { From 6b51fd578420880c3cb6a4f57d0b317eaab6bac9 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 19 May 2026 18:28:00 +0300 Subject: [PATCH 23/27] add indexer for networks Signed-off-by: Daniil Loktev --- .../pkg/common/network/readiness.go | 2 - .../pkg/controller/indexer/indexer.go | 42 ++++++++++++++++--- .../pkg/controller/vm/internal/network.go | 2 +- .../vm/internal/watcher/network_watcher.go | 31 ++++++-------- 4 files changed, 50 insertions(+), 27 deletions(-) diff --git a/images/virtualization-artifact/pkg/common/network/readiness.go b/images/virtualization-artifact/pkg/common/network/readiness.go index 6a742da4cd..168d06b6f3 100644 --- a/images/virtualization-artifact/pkg/common/network/readiness.go +++ b/images/virtualization-artifact/pkg/common/network/readiness.go @@ -35,8 +35,6 @@ var ( ClusterNetworkGVK = schema.GroupVersionKind{Group: "network.deckhouse.io", Version: "v1alpha1", Kind: "ClusterNetwork"} ) -// SpecKey returns a stable "/" identifier for a NetworksSpec entry, -// used in log messages and condition text. func SpecKey(ns v1alpha2.NetworksSpec) string { return fmt.Sprintf("%s/%s", ns.Type, ns.Name) } diff --git a/images/virtualization-artifact/pkg/controller/indexer/indexer.go b/images/virtualization-artifact/pkg/controller/indexer/indexer.go index fe9abac103..ce24c64a40 100644 --- a/images/virtualization-artifact/pkg/controller/indexer/indexer.go +++ b/images/virtualization-artifact/pkg/controller/indexer/indexer.go @@ -33,12 +33,14 @@ const ( ) const ( - IndexFieldVMByClass = "spec.virtualMachineClassName" - IndexFieldVMByVD = "spec.blockDeviceRefs.VirtualDisk" - IndexFieldVMByVI = "spec.blockDeviceRefs.VirtualImage" - IndexFieldVMByCVI = "spec.blockDeviceRefs.ClusterVirtualImage" - IndexFieldVMByUSBDevice = "spec.usbDevices.name" - IndexFieldVMByNode = "status.node" + IndexFieldVMByClass = "spec.virtualMachineClassName" + IndexFieldVMByVD = "spec.blockDeviceRefs.VirtualDisk" + IndexFieldVMByVI = "spec.blockDeviceRefs.VirtualImage" + IndexFieldVMByCVI = "spec.blockDeviceRefs.ClusterVirtualImage" + IndexFieldVMByUSBDevice = "spec.usbDevices.name" + IndexFieldVMByNode = "status.node" + IndexFieldVMByNetwork = "spec.networks.Network.name" + IndexFieldVMByClusterNetwork = "spec.networks.ClusterNetwork.name" IndexFieldVDByVDSnapshot = "vd,spec.DataSource.ObjectRef.Name,.Kind=VirtualDiskSnapshot" IndexFieldVIByVDSnapshot = "vi,spec.DataSource.ObjectRef.Name,.Kind=VirtualDiskSnapshot" @@ -84,6 +86,8 @@ var IndexGetters = []IndexGetter{ IndexVMByVI, IndexVMByCVI, IndexVMByNode, + IndexVMByNetwork, + IndexVMByClusterNetwork, IndexVMByProvisioningSecret, IndexVMSnapshotByVM, IndexVMSnapshotByVDSnapshot, @@ -173,6 +177,32 @@ func IndexVMByNode() (obj client.Object, field string, extractValue client.Index } } +func IndexVMByNetwork() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &v1alpha2.VirtualMachine{}, IndexFieldVMByNetwork, func(object client.Object) []string { + return getNetworkNamesByType(object, v1alpha2.NetworksTypeNetwork) + } +} + +func IndexVMByClusterNetwork() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &v1alpha2.VirtualMachine{}, IndexFieldVMByClusterNetwork, func(object client.Object) []string { + return getNetworkNamesByType(object, v1alpha2.NetworksTypeClusterNetwork) + } +} + +func getNetworkNamesByType(obj client.Object, typ string) []string { + vm, ok := obj.(*v1alpha2.VirtualMachine) + if !ok || vm == nil { + return nil + } + var names []string + for _, n := range vm.Spec.Networks { + if n.Type == typ && n.Name != "" { + names = append(names, n.Name) + } + } + return names +} + func IndexVMByProvisioningSecret() (obj client.Object, field string, extractValue client.IndexerFunc) { return &v1alpha2.VirtualMachine{}, IndexFieldVMByProvisioningSecret, func(object client.Object) []string { vm, ok := object.(*v1alpha2.VirtualMachine) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/network.go b/images/virtualization-artifact/pkg/controller/vm/internal/network.go index bb4dd1df2c..16750a21da 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/network.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/network.go @@ -102,7 +102,7 @@ func (h *NetworkInterfaceHandler) evaluateAdditionalNetworks(ctx context.Context } if len(pending) > 0 { cb.Status(metav1.ConditionFalse).Reason(vmcondition.ReasonNetworkNotReady). - Message(fmt.Sprintf("Waiting for the following networks to become Ready: %s. They will be attached automatically once available.", strings.Join(pending, ", "))) + Message(fmt.Sprintf("Waiting for the following networks to become Ready: %s", strings.Join(pending, ", "))) return nil } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go index b6e4951ff7..436c910987 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/network_watcher.go @@ -34,6 +34,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/log" commonnetwork "github.com/deckhouse/virtualization-controller/pkg/common/network" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -48,13 +49,13 @@ type NetworkWatcher struct { } func (w *NetworkWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { - if err := w.watchOne(mgr, ctr, commonnetwork.ClusterNetworkGVK, v1alpha2.NetworksTypeClusterNetwork, true); err != nil { + if err := w.watchOne(mgr, ctr, commonnetwork.ClusterNetworkGVK, indexer.IndexFieldVMByClusterNetwork, true); err != nil { return err } - return w.watchOne(mgr, ctr, commonnetwork.NetworkGVK, v1alpha2.NetworksTypeNetwork, false) + return w.watchOne(mgr, ctr, commonnetwork.NetworkGVK, indexer.IndexFieldVMByNetwork, false) } -func (w *NetworkWatcher) watchOne(mgr manager.Manager, ctr controller.Controller, gvk schema.GroupVersionKind, networkType string, clusterScoped bool) error { +func (w *NetworkWatcher) watchOne(mgr manager.Manager, ctr controller.Controller, gvk schema.GroupVersionKind, indexField string, clusterScoped bool) error { obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) @@ -63,7 +64,7 @@ func (w *NetworkWatcher) watchOne(mgr manager.Manager, ctr controller.Controller mgr.GetCache(), obj, handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, o *unstructured.Unstructured) []reconcile.Request { - return w.enqueueVMsReferencingNetwork(ctx, o, networkType, clusterScoped) + return w.enqueueVMsReferencingNetwork(ctx, o, indexField, clusterScoped) }), predicate.TypedFuncs[*unstructured.Unstructured]{ CreateFunc: func(e event.TypedCreateEvent[*unstructured.Unstructured]) bool { return true }, @@ -79,28 +80,22 @@ func (w *NetworkWatcher) watchOne(mgr manager.Manager, ctr controller.Controller return nil } -func (w *NetworkWatcher) enqueueVMsReferencingNetwork(ctx context.Context, obj *unstructured.Unstructured, networkType string, clusterScoped bool) []reconcile.Request { +func (w *NetworkWatcher) enqueueVMsReferencingNetwork(ctx context.Context, obj *unstructured.Unstructured, indexField string, clusterScoped bool) []reconcile.Request { var vms v1alpha2.VirtualMachineList - listOpts := &client.ListOptions{} + listOpts := []client.ListOption{client.MatchingFields{indexField: obj.GetName()}} if !clusterScoped { - listOpts.Namespace = obj.GetNamespace() + listOpts = append(listOpts, client.InNamespace(obj.GetNamespace())) } - if err := w.client.List(ctx, &vms, listOpts); err != nil { + if err := w.client.List(ctx, &vms, listOpts...); err != nil { log.Default().Error(fmt.Sprintf("network watcher: failed to list VMs: %s", err)) return nil } - wantName := obj.GetName() - var requests []reconcile.Request + requests := make([]reconcile.Request, 0, len(vms.Items)) for _, vm := range vms.Items { - for _, ns := range vm.Spec.Networks { - if ns.Type == networkType && ns.Name == wantName { - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, - }) - break - } - } + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + }) } return requests } From 973a798f1078f7e1ab7f51f032266b37caa7e1c6 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Tue, 19 May 2026 19:21:14 +0300 Subject: [PATCH 24/27] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/vm/internal/sync_kvvm.go | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 8b5b6b8c27..2f35ada6a5 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -339,6 +339,15 @@ func (h *SyncKvvmHandler) syncKVVM(ctx context.Context, s state.VirtualMachineSt return true, nil case allChanges.IsEmpty(): + outOfSync, err := h.networksOutOfSync(ctx, s, kvvm) + if err != nil { + return false, fmt.Errorf("check network sync: %w", err) + } + if outOfSync { + if err := h.applyNetworkReadinessSync(ctx, s); err != nil { + return false, fmt.Errorf("apply network readiness sync: %w", err) + } + } return true, nil default: // Delay changes propagation to KVVM until user restarts VM. @@ -792,6 +801,52 @@ func (h *SyncKvvmHandler) isVMUnschedulable( return false } +func (h *SyncKvvmHandler) networksOutOfSync(ctx context.Context, s state.VirtualMachineState, kvvm *virtv1.VirtualMachine) (bool, error) { + if kvvm == nil { + return false, nil + } + filteredVM, err := filterReadyNetworks(ctx, s.Client(), s.VirtualMachine().Current()) + if err != nil { + return false, err + } + vmmacs, err := s.VirtualMachineMACAddresses(ctx) + if err != nil { + return false, err + } + desired := network.CreateNetworkSpec(filteredVM, vmmacs) + actual := make(map[string]struct{}) + for _, iface := range kvvm.Spec.Template.Spec.Domain.Devices.Interfaces { + if iface.State == virtv1.InterfaceStateAbsent { + continue + } + actual[iface.Name] = struct{}{} + } + if len(desired) != len(actual) { + return true, nil + } + for _, spec := range desired { + if _, ok := actual[spec.InterfaceName]; !ok { + return true, nil + } + } + return false, nil +} + +func (h *SyncKvvmHandler) applyNetworkReadinessSync(ctx context.Context, s state.VirtualMachineState) error { + if err := h.patchPodNetworkAnnotation(ctx, s); err != nil { + return fmt.Errorf("patch pod network annotation: %w", err) + } + ready, err := h.isNetworkReadyOnPod(ctx, s) + if err != nil { + return fmt.Errorf("check pod network status: %w", err) + } + if !ready { + logger.FromContext(ctx).Info("Waiting for SDN to configure network interfaces on the pod before updating KVVM") + return nil + } + return h.updateKVVM(ctx, s) +} + func filterReadyNetworks(ctx context.Context, c client.Client, vm *v1alpha2.VirtualMachine) (*v1alpha2.VirtualMachine, error) { if c == nil || vm == nil || len(vm.Spec.Networks) == 0 { return vm, nil From 124f8fcb884ed98ba0cc4ff5597f4244f674f3b7 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Wed, 20 May 2026 11:13:20 +0300 Subject: [PATCH 25/27] update cache version Signed-off-by: Daniil Loktev --- images/virt-artifact/werf.inc.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/images/virt-artifact/werf.inc.yaml b/images/virt-artifact/werf.inc.yaml index e2a93c3b06..b0e6f46a92 100644 --- a/images/virt-artifact/werf.inc.yaml +++ b/images/virt-artifact/werf.inc.yaml @@ -9,7 +9,7 @@ image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false fromImage: builder/src -fromCacheVersion: "2026-05-08" +fromCacheVersion: "2026-05-20" secrets: - id: SOURCE_REPO value: {{ $.SOURCE_REPO }} @@ -45,7 +45,7 @@ packages: image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-alt-1.25" "builder/golang-alt-svace-1.25" }} -fromCacheVersion: "2026-05-08" +fromCacheVersion: "2026-05-20" mount: {{- include "mount points for golang builds" . }} secrets: From b8757f1623e89ede94d78a35c1b7a74db836ae97 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Wed, 20 May 2026 11:52:49 +0300 Subject: [PATCH 26/27] update cache version Signed-off-by: Daniil Loktev --- images/virt-artifact/werf.inc.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/images/virt-artifact/werf.inc.yaml b/images/virt-artifact/werf.inc.yaml index b0e6f46a92..34e0e1b1a3 100644 --- a/images/virt-artifact/werf.inc.yaml +++ b/images/virt-artifact/werf.inc.yaml @@ -9,7 +9,7 @@ image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact final: false fromImage: builder/src -fromCacheVersion: "2026-05-20" +fromCacheVersion: "2026-05-20-1" secrets: - id: SOURCE_REPO value: {{ $.SOURCE_REPO }} @@ -45,7 +45,7 @@ packages: image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: false fromImage: {{ eq $.SVACE_ENABLED "false" | ternary "builder/golang-alt-1.25" "builder/golang-alt-svace-1.25" }} -fromCacheVersion: "2026-05-20" +fromCacheVersion: "2026-05-20-1" mount: {{- include "mount points for golang builds" . }} secrets: From c2192bd895fdc0ee3e57d1c4691734f4dc16debb Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Wed, 20 May 2026 20:40:26 +0300 Subject: [PATCH 27/27] fix vmmac removal when unplugging Signed-off-by: Daniil Loktev --- .../pkg/controller/vm/internal/network.go | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/network.go b/images/virtualization-artifact/pkg/controller/vm/internal/network.go index 16750a21da..5f90551939 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/network.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/network.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "strings" corev1 "k8s.io/api/core/v1" @@ -132,13 +133,6 @@ func (h *NetworkInterfaceHandler) Name() string { } func (h *NetworkInterfaceHandler) UpdateNetworkStatus(ctx context.Context, s state.VirtualMachineState, vm *v1alpha2.VirtualMachine) (reconcile.Result, error) { - // check that vmmacName is not removed when deleting a network interface from the spec, as it is still in use - if len(vm.Status.Networks) > len(vm.Spec.Networks) { - if vm.Status.Phase != v1alpha2.MachinePending && vm.Status.Phase != v1alpha2.MachineStopped { - return reconcile.Result{}, nil - } - } - if hasOnlyDefaultNetwork(vm) { vm.Status.Networks = []v1alpha2.NetworksStatus{ { @@ -155,6 +149,11 @@ func (h *NetworkInterfaceHandler) UpdateNetworkStatus(ctx context.Context, s sta return reconcile.Result{}, err } + kvvmi, err := s.KVVMI(ctx) + if err != nil { + return reconcile.Result{}, err + } + macAddressesByInterfaceName := make(map[string]string) if kvvm != nil && kvvm.Status.PrintableStatus != virtv1.VirtualMachineStatusUnschedulable { for _, i := range kvvm.Spec.Template.Spec.Domain.Devices.Interfaces { @@ -194,6 +193,26 @@ func (h *NetworkInterfaceHandler) UpdateNetworkStatus(ctx context.Context, s sta }) } + // Network hot-unplug: retain a status entry the user removed from spec until + // KubeVirt fully detaches and drops the iface from KVVMI. The next reconcile + // then drops the entry, vmmac becomes unattached, deletion handler releases the MAC. + for _, prev := range vm.Status.Networks { + if prev.Type == v1alpha2.NetworksTypeMain || prev.MAC == "" { + continue + } + if slices.ContainsFunc(networksStatus, func(ns v1alpha2.NetworksStatus) bool { + return ns.Type == prev.Type && ns.Name == prev.Name + }) { + continue + } + if kvvmi == nil || !slices.ContainsFunc(kvvmi.Spec.Domain.Devices.Interfaces, func(i virtv1.Interface) bool { + return strings.EqualFold(i.MacAddress, prev.MAC) + }) { + continue + } + networksStatus = append(networksStatus, prev) + } + vm.Status.Networks = networksStatus return reconcile.Result{}, nil }