Skip to content
2 changes: 1 addition & 1 deletion build/components/versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ firmware:
libvirt: v10.9.0
edk2: stable202411
core:
3p-kubevirt: v1.6.2-v12n.24
3p-kubevirt: feat/core/network-hotplug-support
3p-containerized-data-importer: v1.60.3-v12n.18
distribution: 2.8.3
package:
Expand Down
3 changes: 2 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion docs/USER_GUIDE.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -3025,7 +3025,8 @@ EOF
Особенности и важные моменты работы с дополнительными сетевыми интерфейсами:

- порядок перечисления сетей в `.spec.networks` определяет порядок подключения интерфейсов внутри виртуальной машины;
- добавление или удаление дополнительных сетей вступает в силу только после перезагрузки ВМ;
- добавление или удаление дополнительной сети (`Network` или `ClusterNetwork`) на работающей ВМ применяется без перезагрузки. ACPI-индексы существующих интерфейсов сохраняются при добавлении/удалении, поэтому имена интерфейсов в гостевой ОС остаются стабильными;
- добавление или удаление основной сети (`type: Main`) по-прежнему требует перезагрузки ВМ, так как она связана с основным сетевым интерфейсом пода и не может быть изменена на работающем поде;
- чтобы сохранить порядок сетевых интерфейсов внутри гостевой операционной системы, рекомендуется добавлять новые сети в конец списка `.spec.networks` (не менять порядок уже существующих);
- политики сетевой безопасности (NetworkPolicy) не применяются к дополнительным сетевым интерфейсам;
- параметры сети (IP-адреса, шлюзы, DNS и т.д.) для дополнительных сетей настраиваются вручную изнутри гостевой ОС (например, с помощью Cloud-Init).
Expand Down
8 changes: 8 additions & 0 deletions images/virtualization-artifact/pkg/common/network/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
13 changes: 8 additions & 5 deletions images/virtualization-artifact/pkg/common/network/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 15 additions & 5 deletions images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,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,
Expand All @@ -770,15 +779,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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,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 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 {
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 {
return fmt.Errorf("unable to update KVVM using new VM spec: %w", err)
}
Expand Down Expand Up @@ -749,6 +766,69 @@ 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 {
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)

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
}

networkConfigStr, err := network.CreateNetworkSpec(current, vmmacs).ToString()
if err != nil {
return fmt.Errorf("failed to serialize network spec: %w", err)
}

if pod.Annotations[annotations.AnnNetworksSpec] == 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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package vmchange
import (
"reflect"

"github.com/deckhouse/virtualization-controller/pkg/common/network"
"github.com/deckhouse/virtualization/api/core/v1alpha2"
)

Expand Down Expand Up @@ -89,10 +90,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(
Expand All @@ -104,6 +105,23 @@ 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 := 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)
}

func isOnlyNetworkIDAutofillChange(current, desired []v1alpha2.NetworksSpec) bool {
if len(current) != len(desired) {
return false
Expand Down
Loading
Loading