From 0f8cf047359597454c51a62b71ae1f30b7e34d9e Mon Sep 17 00:00:00 2001 From: Nick Caballero Date: Thu, 18 Jun 2026 10:38:53 -0400 Subject: [PATCH] vcsim: support cloud-init for vm customization Signed-off-by: Nick Caballero --- go.mod | 2 +- simulator/virtual_machine.go | 114 +++++++++++++++++++++++++- simulator/virtual_machine_test.go | 132 ++++++++++++++++++++++++++++++ vcsim/go.mod | 5 +- vcsim/go.sum | 2 + 5 files changed, 251 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 113dcfbd0..0722b4c5b 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/vmware/vmw-guestinfo v0.0.0-20220317130741-510905f0efa3 github.com/xlab/treeprint v1.2.0 golang.org/x/text v0.38.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -19,5 +20,4 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/simulator/virtual_machine.go b/simulator/virtual_machine.go index 861e59dc6..cbc49a397 100644 --- a/simulator/virtual_machine.go +++ b/simulator/virtual_machine.go @@ -15,12 +15,14 @@ import ( "path/filepath" "reflect" "slices" + "sort" "strconv" "strings" "sync/atomic" "time" "github.com/google/uuid" + "gopkg.in/yaml.v3" "github.com/vmware/govmomi/internal" "github.com/vmware/govmomi/object" @@ -2953,7 +2955,7 @@ func (vm *VirtualMachine) customize(ctx *Context) types.BaseMethodFault { {Name: "config.tools.pendingCustomization", Val: ""}, } - if len(vm.Guest.Net) != len(vm.imc.NicSettingMap) { + if !customizationUsesCloudInitNetworkConfig(vm.imc) && len(vm.Guest.Net) != len(vm.imc.NicSettingMap) { fault := &types.NicSettingMismatch{ NumberOfNicsInSpec: int32(len(vm.imc.NicSettingMap)), NumberOfNicsInVM: int32(len(vm.Guest.Net)), @@ -2977,12 +2979,15 @@ func (vm *VirtualMachine) customize(ctx *Context) types.BaseMethodFault { hostname := "" address := "" + guestNetChanged := false switch c := vm.imc.Identity.(type) { case *types.CustomizationLinuxPrep: hostname = customizeName(vm, c.HostName) case *types.CustomizationSysprep: hostname = customizeName(vm, c.UserData.ComputerName) + case *types.CustomizationCloudinitPrep: + hostname, address, guestNetChanged = vm.applyCloudInitCustomization(c) } cards := object.VirtualDeviceList(vm.Config.Hardware.Device).SelectByType((*types.VirtualEthernetCard)(nil)) @@ -3030,6 +3035,9 @@ func (vm *VirtualMachine) customize(ctx *Context) types.BaseMethodFault { } if len(vm.imc.NicSettingMap) != 0 { + guestNetChanged = true + } + if guestNetChanged { changes = append(changes, types.PropertyChange{Name: "guest.net", Val: vm.Guest.Net}) } if hostname != "" { @@ -3051,6 +3059,108 @@ func (vm *VirtualMachine) customize(ctx *Context) types.BaseMethodFault { return nil } +func customizationUsesCloudInitNetworkConfig(spec *types.CustomizationSpec) bool { + if spec == nil || len(spec.NicSettingMap) != 0 { + return false + } + _, ok := spec.Identity.(*types.CustomizationCloudinitPrep) + return ok +} + +type cloudInitMetadata struct { + Hostname string `yaml:"hostname"` + LocalHostname string `yaml:"local-hostname"` + Network struct { + Ethernets map[string]cloudInitEthernet `yaml:"ethernets"` + } `yaml:"network"` +} + +type cloudInitEthernet struct { + Addresses []string `yaml:"addresses"` + Nameservers struct { + Addresses []string `yaml:"addresses"` + Search []string `yaml:"search"` + } `yaml:"nameservers"` +} + +func (vm *VirtualMachine) applyCloudInitCustomization(prep *types.CustomizationCloudinitPrep) (string, string, bool) { + if prep == nil || strings.TrimSpace(prep.Metadata) == "" { + return "", "", false + } + + var metadata cloudInitMetadata + if err := yaml.Unmarshal([]byte(prep.Metadata), &metadata); err != nil { + vm.logPrintf("cloud-init metadata parse failed: %s", err) + return "", "", false + } + + hostname := metadata.LocalHostname + if hostname == "" { + hostname = metadata.Hostname + } + + ethernets := metadata.Network.Ethernets + if len(ethernets) == 0 { + return hostname, "", false + } + + names := make([]string, 0, len(ethernets)) + for name := range ethernets { + names = append(names, name) + } + sort.Strings(names) + + address := "" + changed := false + for i, name := range names { + if i >= len(vm.Guest.Net) { + break + } + + config := ethernets[name] + nic := &vm.Guest.Net[i] + + if len(config.Addresses) != 0 { + nic.IpAddress = cloudInitIPAddresses(config.Addresses) + nic.IpConfig = &types.NetIpConfigInfo{ + IpAddress: make([]types.NetIpConfigInfoIpAddress, len(nic.IpAddress)), + } + for j, ip := range nic.IpAddress { + nic.IpConfig.IpAddress[j].IpAddress = ip + } + if address == "" { + address = nic.IpAddress[0] + } + changed = true + } + + if len(config.Nameservers.Addresses) != 0 || len(config.Nameservers.Search) != 0 { + if nic.DnsConfig == nil { + nic.DnsConfig = new(types.NetDnsConfigInfo) + } + nic.DnsConfig.IpAddress = config.Nameservers.Addresses + nic.DnsConfig.SearchDomain = config.Nameservers.Search + changed = true + } + } + + return hostname, address, changed +} + +func cloudInitIPAddresses(addresses []string) []string { + ips := make([]string, 0, len(addresses)) + for _, address := range addresses { + ip, _, err := net.ParseCIDR(address) + if err == nil { + ips = append(ips, ip.String()) + continue + } + value, _, _ := strings.Cut(address, "/") + ips = append(ips, value) + } + return ips +} + func (vm *VirtualMachine) customizationInfo(status types.GuestInfoCustomizationStatus, err string) *types.GuestInfoCustomizationInfo { info := &types.GuestInfoCustomizationInfo{ CustomizationStatus: string(status), @@ -3085,7 +3195,7 @@ func (vm *VirtualMachine) setPendingCustomization(ctx *Context, spec *types.Cust if vm.Config.Tools.PendingCustomization != "" { return new(types.CustomizationPending) } - if len(vm.Guest.Net) != len(spec.NicSettingMap) { + if !customizationUsesCloudInitNetworkConfig(spec) && len(vm.Guest.Net) != len(spec.NicSettingMap) { return &types.NicSettingMismatch{ NumberOfNicsInSpec: int32(len(spec.NicSettingMap)), NumberOfNicsInVM: int32(len(vm.Guest.Net)), diff --git a/simulator/virtual_machine_test.go b/simulator/virtual_machine_test.go index 8fdd30e20..7853afe45 100644 --- a/simulator/virtual_machine_test.go +++ b/simulator/virtual_machine_test.go @@ -655,6 +655,138 @@ func TestCloneVmPowerOnAndCustomization(t *testing.T) { }, m) } +func TestCloneVmCloudInitCustomizationSkipsNicSettingMismatch(t *testing.T) { + m := VPX() + defer m.Remove() + + Test(func(ctx context.Context, c *vim25.Client) { + finder := find.NewFinder(c, false) + dc, err := finder.DefaultDatacenter(ctx) + if err != nil { + t.Fatal(err) + } + finder.SetDatacenter(dc) + + folders, err := dc.Folders(ctx) + if err != nil { + t.Fatal(err) + } + + vmm := m.Map().Any("VirtualMachine").(*VirtualMachine) + vm := object.NewVirtualMachine(c, vmm.Reference()) + + devices, err := vm.Device(ctx) + if err != nil { + t.Fatal(err) + } + nics := devices.SelectByType((*types.VirtualEthernetCard)(nil)) + if len(nics) == 0 { + t.Fatal("expected source VM to have at least one NIC") + } + if err := vm.RemoveDevice(ctx, false, nics...); err != nil { + t.Fatal(err) + } + + var source mo.VirtualMachine + if err := vm.Properties(ctx, vm.Reference(), []string{"guest.net"}, &source); err != nil { + t.Fatal(err) + } + if len(source.Guest.Net) != 0 { + t.Fatalf("expected source VM to have no NICs; got %d", len(source.Guest.Net)) + } + + network, err := finder.Network(ctx, "VM Network") + if err != nil { + t.Fatal(err) + } + backing, err := network.EthernetCardBackingInfo(ctx) + if err != nil { + t.Fatal(err) + } + + var added object.VirtualDeviceList + for _, cardType := range []string{"vmxnet3", "e1000"} { + nic, err := added.CreateEthernetCard(cardType, backing) + if err != nil { + t.Fatal(err) + } + added = append(added, nic) + } + deviceChange, err := added.ConfigSpec(types.VirtualDeviceConfigSpecOperationAdd) + if err != nil { + t.Fatal(err) + } + + config := types.VirtualMachineCloneSpec{ + PowerOn: true, + Config: &types.VirtualMachineConfigSpec{ + DeviceChange: deviceChange, + }, + Customization: &types.CustomizationSpec{ + Identity: &types.CustomizationCloudinitPrep{ + Metadata: `instance-id: clone-cloud-init +local-hostname: clone-cloud-init +network: + version: 2 + ethernets: + eth0: + addresses: + - 192.168.1.120/24 + eth1: + addresses: + - 192.168.1.121/24 +`, + }, + }, + } + + cloneTask, err := vm.Clone(ctx, folders.VmFolder, "cloned-vm-cloud-init-customization", config) + if err != nil { + t.Fatal(err) + } + info, err := cloneTask.WaitForResult(ctx, nil) + if err != nil { + if taskErr, ok := err.(task.Error); ok { + if _, ok := taskErr.Fault().(*types.NicSettingMismatch); ok { + t.Fatalf("unexpected NicSettingMismatch for cloud-init network customization: %v", err) + } + } + t.Fatal(err) + } + + clone := object.NewVirtualMachine(c, info.Result.(types.ManagedObjectReference)) + var moVM mo.VirtualMachine + if err := clone.Properties(ctx, clone.Reference(), []string{ + "runtime.powerState", + "guest", + "config.tools", + }, &moVM); err != nil { + t.Fatal(err) + } + if moVM.Runtime.PowerState != types.VirtualMachinePowerStatePoweredOn { + t.Fatalf("expected clone to be powered on; got %s", moVM.Runtime.PowerState) + } + if len(moVM.Guest.Net) != len(added) { + t.Fatalf("expected clone to have %d guest NICs; got %d", len(added), len(moVM.Guest.Net)) + } + if moVM.Guest.HostName != "clone-cloud-init" { + t.Fatalf("expected guest hostname %q; got %q", "clone-cloud-init", moVM.Guest.HostName) + } + if moVM.Guest.IpAddress != "192.168.1.120" { + t.Fatalf("expected guest IP %q; got %q", "192.168.1.120", moVM.Guest.IpAddress) + } + for i, ip := range []string{"192.168.1.120", "192.168.1.121"} { + if got := moVM.Guest.Net[i].IpAddress; !reflect.DeepEqual(got, []string{ip}) { + t.Fatalf("expected guest NIC %d IPs %v; got %v", i, []string{ip}, got) + } + } + if moVM.Config.Tools.PendingCustomization != "" { + t.Fatalf("expected pending customization to be cleared; got %q", moVM.Config.Tools.PendingCustomization) + } + assertGuestCustomizationInfo(ctx, t, clone, types.GuestInfoCustomizationStatusTOOLSDEPLOYPKG_SUCCEEDED, true, true) + }, m) +} + func TestCustomizeVmCustomizationInfo(t *testing.T) { m := VPX() defer m.Remove() diff --git a/vcsim/go.mod b/vcsim/go.mod index f98b7d371..b9e696899 100644 --- a/vcsim/go.mod +++ b/vcsim/go.mod @@ -9,4 +9,7 @@ require ( github.com/vmware/govmomi v0.0.0-00010101000000-000000000000 ) -require golang.org/x/text v0.38.0 // indirect +require ( + golang.org/x/text v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/vcsim/go.sum b/vcsim/go.sum index 89ce1f55f..0e1055958 100644 --- a/vcsim/go.sum +++ b/vcsim/go.sum @@ -14,5 +14,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=