使用 govmomi 创建 VMware 虚拟机

使用 govmomi 创建 VMware 虚拟机vsphere golang sdk govmomi 使用指南:这是 govmomi 的基础操作,建议你在看本篇文章前先看看。 之所以使用 govmomi 原因有很多,有性能的原因,go 性能强出 Python 太多;有个人的原因,本人比较喜欢 go;当然还有公司的原因,公司的…

之前我写了两篇文章,都是关于 VMware api 的:

之所以使用 govmomi 原因有很多,有性能的原因,go 性能强出 Python 太多;有个人的原因,本人比较喜欢 go;当然还有公司的原因,公司的运维平台是 go 写的。

当然,无论是 govmomi 也好,pyvmomi 也好,还是其他各种 vmomi 也好,都是 VMware 自身 api 的封装,使用起来都是大同小异,适合自己的就是最好的,没必要纠结太多。

需要说明的是,本文创建虚拟机是将 ovf 模板放入内容库后,通过内容库部署的,而非直接通过模板创建。另外,6.7 的内容库除了支持 ovf 这种模板类型之外,还支持 vm-template,我没有来得及研究,有兴趣的童鞋可以研究看看。

OK,正文开始。

内容库

内容库是 VMware 6.0 新增的功能,使用它可以跨 vsphere 共享 iso 文件、虚拟机模板等,当你有多个 vsphere 时,使用起来会很便利。要使用内容库,你首先得创建一个内容库(library),给它一个名称,然后就可以上传文件啊模板啊到这个库中,每个文件或者模板称为一个 item。

当你在一个 vsphere 中创建内容库后,其他 vsphere 就订阅该内容库了,这样内容库中的文件就会同步到所有订阅它的内容库中,通过这种方式来保证多个 vsphere 内容库中文件的一致性。

内容库的使用这里就不演示了,网上教程很多,随便就能找到。这里假设你已经有了一个内容库,并且里面已经有了一个 ovf 模板。要想使用内容库,我们就必须先找到这个内容库对象,再通过它来找到其中的 ovf 模板这个对象。

想必通过之前的文章你已经知道了怎么安装和登录 vsphere 了,那这里就直接登录了:

const (
    ip = ""
    user = ""
    password = ""
)

u := &url.URL{
    Scheme: "https",
    Host:   ip,
    Path:   "/sdk",
}

ctx := context.Background()
u.User = url.UserPassword(user, password)
client, err := govmomi.NewClient(ctx, u, true)
if err != nil {
    fmt.Fprintf(os.Stderr, "Login to vsphere failed, %v", err)
    os.Exit(1)
}

本文需要导入的库有这些:

import (
	"context"
	"fmt"
	"github.com/vmware/govmomi"
	"github.com/vmware/govmomi/object"
	"github.com/vmware/govmomi/vapi/library"
	"github.com/vmware/govmomi/vapi/rest"
	"github.com/vmware/govmomi/vapi/vcenter"
	"net/url"
	"os"
)

后面就不贴出来了,基本 IDE 都会自动补全。

这里的 client 就可以用来操作整个 vsphere 了,不过通过它无法直接操作内容库,我们必须先要通过它来获得 rest client:

rc := rest.NewClient(client.Client)
if err := rc.Login(ctx, url.UserPassword(user, password)); err != nil {
    fmt.Fprintf(os.Stderr, "rc.Login failed, %v", err)
    os.Exit(1)
}

这就相当于又重新登录了一次,不知道为什么 VMware 这么设计,直接通过 client 不好么?有了 rc 之后,就可以操作内容库了:

func getLibraryItem(ctx context.Context, rc *rest.Client) (*library.Item, error) {
	const (
		libraryName = ""
		libraryItemName = ""
		libraryItemType = "ovf"
	)

	// 需要通过 rc 来获得 library.Manager 对象
	m := library.NewManager(rc)
	// 通过内容库的名称来查找内容库
	libraries, err := m.FindLibrary(ctx, library.Find{Name: libraryName})
	if err != nil {
		fmt.Printf("Find library by name %s failed, %v", libraryName, err)
		return nil, err
	}

	// 判断是否找到
	if len(libraries) == 0 {
		fmt.Printf("Library %s was not found", libraryName)
		return nil, fmt.Errorf("library %s was not found", libraryName)
	}

	if len(libraries) > 1 {
		fmt.Printf("There are multiple libraries with the name %s", libraryName)
		return nil, fmt.Errorf("there are multiple libraries with the name %s", libraryName)
	}

	// 在内容库中通过 ovf 模板的名称来找到 ovf 模板
	items, err := m.FindLibraryItems(ctx, library.FindItem{Name: libraryItemName,
		Type: libraryItemType, LibraryID: libraries[0]})

	if err != nil {
		fmt.Printf("Find library item by name %s failed", libraryItemName)
		return nil, fmt.Errorf("find library item by name %s failed", libraryItemName)
	}

	if len(items) == 0 {
		fmt.Printf("Library item %s was not found", libraryItemName)
		return nil, fmt.Errorf("library item %s was not found", libraryItemName)
	}

	if len(items) > 1 {
		fmt.Printf("There are multiple library items with the name %s", libraryItemName)
		return nil, fmt.Errorf("there are multiple library items with the name %s", libraryItemName)
	}

	item, err := m.GetLibraryItem(ctx, items[0])
	if err != nil {
		fmt.Printf("Get library item by %s failed, %v", items[0], err)
		return nil, err
	}

	return item, nil
}

可以看到,找到 ovf 模板还是挺绕的。如果你确保你的内容库以及里面的 ovf 不会被删除重建的话,你可以首先找到这个 ovf 模板,记下它的 id。等下次要查找的时候,直接调用 m.GetLibraryItem() 就可以了。这样就不用找库,再通过库来找 item 了。

资源类型

模板有了,现在要做的就是确定要将虚拟机部署在哪。vsphere 中资源是有层级的,最外层就是数据中心,所有的资源都得属于某个数据中心,而一个 vsphere 中可以存在多个数据中心。

所以首先你得确认你要将虚拟机部署到哪个数据中心,然后再确定放在哪个集群的哪个资源池、哪个存储、哪个网络、哪个文件夹等,等这些都确定了就可以部署了。

因此我们首先要通过名称来找到这些资源,从数据中心开始。

数据中心

其实创建虚拟机不需要用到数据中心,但是由于其他的资源都在数据中心下面,所以你可以了解了解,当然你不看也没啥影响。

// 通过这个 finder 你可以列出 VMware 中的所有资源
finder := find.NewFinder(client.Client)
dcs, err := finder.DatacenterList(ctx, "*")
if err != nil {
    fmt.Fprintf(os.Stderr, "Failed to list data center at vc %s, %v\n", ip, err)
    os.Exit(1)
}

for _, dc := range dcs {
    // 这个唯一名称是 vshpere 中的 id,大概类似于数据库中的自增 id,同一个 vsphere 中唯一,多个 vsphere 不唯一
    dcUniqName := dc.Reference().Value
    // 类型就是 DataCenter
    dcType := dc.Reference().Type
    // 数据中心的名称
    dcName := dc.Name()
    // 数据中心的路径,VMware 中的资源类似于 linux 的文件系统,从根开始,每个资源都有它唯一的路径
    // 如果你知道一个资源的 path,那么你就可以直接通过这个路径找到这个资源,后续会提到
    dcPath := dc.InventoryPath

    fmt.Printf("id => %s\nname => %s\npath => %s\ntype => %s\n", dcUniqName, dcName, dcPath, dcType)
}

这就列出了所有的数据中心了。

集群

集群中有很多资源,有资源池和 esxi(也就是宿主机,VMware 中称为 HostSystem)。在有 vsan 或者集中存储的环境,你可以将虚拟机直接放在其中的集群资源池上(没有划分资源池也不要紧,集群默认就是一个资源池);如果没有,那么就只能将虚拟机直接部署到宿主机上了。

因此你要么将虚拟机放到资源池中,要么放在宿主机上。而这种两种资源都属于集群,因此我们首先获取集群。当然其实你不获取集群也没有关系,可以直接获取资源池或者 host。我这里只是将获取集群的方式列出来,其实所有资源都是这么获取的:

// 集群的名称为 ClusterComputeResource,不要搞错了
clusters, err := finder.ClusterComputeResourceList(ctx, "*")
if err != nil {
    fmt.Fprintf(os.Stderr, "Failed to list cluster at vc %s, %v", ip, err)
    os.Exit(1)
}
for _, cluster := range clusters {
    clusterUniqName := cluster.Reference().Value
    clusterType := cluster.Reference().Type
    clusterName := cluster.Name()
    clusterPath := cluster.InventoryPath
    fmt.Printf("id => %s\nname => %s\npath => %s\ntype => %s\n", clusterUniqName, clusterName, clusterPath, clusterType)
}

这里只是演示如何获取集群,但是创建虚拟机用不到,需要用的是资源池或者宿主机。它们两种的获取方式和集群一样:

resourcePools, err := finder.ResourcePoolList(ctx, "*")
hosts, err := finder.HostSystemList(ctx, "*")

当然你也可以直接通过集群来获取它自身的资源池:

clusters[0].ResourcePool(ctx)

这种遍历资源的方式其实很 low,这个先不管,先把流程走通再说。

存储

储存也属于数据中心,既可以是 vsan,也可以是宿主机。这个是和上面的选择是一一对应的,如果你将虚拟机建在宿主机上,那么存储你就应该选择宿主机。

datastores, err := finder.DatastoreList(ctx, "*")
if err != nil {
    fmt.Fprintf(os.Stderr, "Failed to list datastore at vc %s, %v", ip, err)
    os.Exit(1)
}
for _, datastore := range datastores {
    datastoreUniqName := datastore.Reference().Value
    datastoreType := datastore.Reference().Type
    datastoreName := datastore.Name()
    datastorePath := datastore.InventoryPath
    fmt.Printf("id => %s\nname => %s\npath => %s\ntype => %s\n", datastoreUniqName, datastoreName, datastorePath, datastoreType)
}

VMware 中还存在 datastoreCluste 这种资源类型,但是我没有研究。

网络

网络属于数据中心,它会复杂一点,因为它有很多种类型:

  • Network
  • OpaqueNetwork
  • DistributedVirtualPortgroup
  • DistributedVirtualSwitch
  • VmwareDistributedVirtualSwitch

具体有啥区别我不是很清楚,大概是如果你使用分布式交换机的话,你只需要选择端口组(DistributedVirtualPortgroup)这种的(交换机就不用选了),否则就选择 Network

networks, err := finder.NetworkList(ctx, "*")
if err != nil {
    fmt.Fprintf(os.Stderr, "Failed to list network at vc %s, %v", ip, err)
    os.Exit(1)
}

for _, network := range networks {
    networkUniqName := network.Reference().Value
    // 这就是它的 type,需要注意区分
    networkType := network.Reference().Type
    // 没有 name,name 可以通过 path 来获取
    networkPath := network.GetInventoryPath()
    fmt.Printf("id => %s\npath => %s\ntype => %s\n", networkUniqName, networkPath, networkType)
}

文件夹

文件夹属于数据中心,获取起来方式一样:

folders, err := finder.FolderList(ctx, "*")
if err != nil {
    fmt.Fprintf(os.Stderr, "Failed to list folder at vc %s, %v", ip, err)
    os.Exit(1)
}

for _, folder := range folders {
    folderUniqName := folder.Reference().Value
    folderType := folder.Reference().Type
    folderName := folder.Name()
    folderPath := folder.InventoryPath
    fmt.Printf("id => %s\nname => %s\npath => %s\ntype => %s\n", folderUniqName, folderName, folderPath, folderType)
}

部署虚拟机

资源都已经具备了,但是在部署之前,我们需要获取 ovf 模板的网络和存储,后面会用到。所以要拿到它们,可能是后面通过它部署虚拟机的时候,要把它们替换掉吧。

获取的方式很简单:

rc := rest.NewClient(client.Client)
if err := rc.Login(ctx, url.UserPassword(user, password)); err != nil {
    fmt.Fprintf(os.Stderr, "rc.Login failed, %v", err)
    os.Exit(1)
}

// 先获取 ovf 模板,这个函数定义在前面
item, err := getLibraryItem(ctx, rc)
if err != nil {
    panic(err)
}

m := vcenter.NewManager(rc)
// 这里需要前面获取到的资源池和文件夹,当然宿主机和文件夹也行,就是要将 ResourcePoolID 换成 HostID
// 至于为什么这么做我也不清楚,只要可以获得我们需要的结果就行
fr := vcenter.FilterRequest{Target: vcenter.Target{
    ResourcePoolID: resources[0].Reference().Value,
    FolderID:       folders[0].Reference().Value,
},
}
r, err := m.FilterLibraryItem(ctx, item.ID, fr)
if err != nil {
    fmt.Fprintf(os.Stderr, "FilterLibraryItem error, %v\n", err)
    os.Exit(1)
}
// 模板中的网卡和磁盘可能有多个,我这里当做一个来处理,建议模板中只有一个网卡的磁盘,因为创建虚拟机的时候可以加
// 这两个 key 后面会用到
networkKey := r.Networks[0]
storageKey := r.StorageGroups[0]
fmt.Println(networkKey, storageKey)

接下里就是部署了:

deploy := vcenter.Deploy{
    DeploymentSpec: vcenter.DeploymentSpec{
        // 虚拟机名称
        Name:               "test",
        DefaultDatastoreID: datastores[0].Reference().Value,
        AcceptAllEULA:      true,
        NetworkMappings: []vcenter.NetworkMapping{{
            Key:   networkKey,
            Value: networks[0].Reference().Value,
        }},
        StorageMappings: []vcenter.StorageMapping{{
            Key: storageKey,
            Value: vcenter.StorageGroupMapping{
                Type:         "DATASTORE",
                DatastoreID:  datastores[0].Reference().Value,
                // 精简置备
                Provisioning: "thin",
            },
        }},
        // 精简置备
        StorageProvisioning: "thin",
    },
    Target: vcenter.Target{
        ResourcePoolID: resources[0].Reference().Value,
        FolderID:       folders[0].Reference().Value,
    },
}

ref, err := vcenter.NewManager(rc).DeployLibraryItem(ctx, item.ID, deploy)
if err != nil {
    fmt.Printf("Deploy vm from library failed, %v", err)
    return
}

f := find.NewFinder(client.Client)
obj, err := f.ObjectReference(ctx, *ref)
if err != nil {
    fmt.Fprintf(os.Stderr, "Find vm failed, %v\n", err)
    os.Exit(1)
}

// 这是虚拟机本尊,后面会用到
vm = obj.(*object.VirtualMachine)

这就开始部署了。

注意我这里所有的资源都是选择的第一个,只是为了演示,你在使用的时候,需要选择正确的才行。当然前面通过遍历的方式获取所有的资源方式显得很 low,而且性能很差。我们的做法是定时去抓取所有 vsphere 的所有资源,将其存到 MySQL 中,包括资源的名称、类型(网络才需要)、路径(这是关键)、资源的 ID(也就是 uniqName)。

这样在创建虚拟机的时候通过选择这些资源,就可以拿到这些资源的路径,而通过这些路径是可以直接获取资源本身的。这里以存储举例:

si := object.NewSearchIndex(client.Client)
inventoryPath, err := si.FindByInventoryPath(ctx, "/beijing/datastore/store1")
if inventoryPath == nil {
    fmt.Fprintf(os.Stderr, "Get datastore object failed, %v", err)
    return
}

// 如果是其他资源就换成其他资源就行
ds := object.NewDatastore(client.Client, inventoryPath.Reference())

完整代码

package main

import ( "context" "fmt" "github.com/vmware/govmomi" "github.com/vmware/govmomi/find" "github.com/vmware/govmomi/vapi/library" "github.com/vmware/govmomi/vapi/rest" "github.com/vmware/govmomi/vapi/vcenter" "net/url" "os" ) func getLibraryItem(ctx context.Context, rc *rest.Client) (*library.Item, error) { const ( libraryName = "Librarysub_from_49.100" libraryItemName = "CentOS 7.5" libraryItemType = "ovf" ) m := library.NewManager(rc) libraries, err := m.FindLibrary(ctx, library.Find{Name: libraryName}) if err != nil { fmt.Printf("Find library by name %s failed, %v", libraryName, err) return nil, err } if len(libraries) == 0 { fmt.Printf("Library %s was not found", libraryName) return nil, fmt.Errorf("library %s was not found", libraryName) } if len(libraries) > 1 { fmt.Printf("There are multiple libraries with the name %s", libraryName) return nil, fmt.Errorf("there are multiple libraries with the name %s", libraryName) } items, err := m.FindLibraryItems(ctx, library.FindItem{Name: libraryItemName, Type: libraryItemType, LibraryID: libraries[0]}) if err != nil { fmt.Printf("Find library item by name %s failed", libraryItemName) return nil, fmt.Errorf("find library item by name %s failed", libraryItemName) } if len(items) == 0 { fmt.Printf("Library item %s was not found", libraryItemName) return nil, fmt.Errorf("library item %s was not found", libraryItemName) } if len(items) > 1 { fmt.Printf("There are multiple library items with the name %s", libraryItemName) return nil, fmt.Errorf("there are multiple library items with the name %s", libraryItemName) } item, err := m.GetLibraryItem(ctx, items[0]) if err != nil { fmt.Printf("Get library item by %s failed, %v", items[0], err) return nil, err } return item, nil } func main() { const ( ip = "" user = "" password = "" ) u := &url.URL{ Scheme: "https", Host: ip, Path: "/sdk", } ctx := context.Background() u.User = url.UserPassword(user, password) client, err := govmomi.NewClient(ctx, u, true) if err != nil { fmt.Fprintf(os.Stderr, "Login to vsphere failed, %v", err) os.Exit(1) } finder := find.NewFinder(client.Client) resourcePools, err := finder.ResourcePoolList(ctx, "*") if err != nil { fmt.Fprintf(os.Stderr, "Failed to list resource pool at vc %s, %v", ip, err) os.Exit(1) } //hosts, err := finder.HostSystemList(ctx, "*") datastores, err := finder.DatastoreList(ctx, "*") if err != nil { fmt.Fprintf(os.Stderr, "Failed to list datastore at vc %s, %v", ip, err) os.Exit(1) } networks, err := finder.NetworkList(ctx, "*") if err != nil { fmt.Fprintf(os.Stderr, "Failed to list network at vc %s, %v", ip, err) os.Exit(1) } folders, err := finder.FolderList(ctx, "*") if err != nil { fmt.Fprintf(os.Stderr, "Failed to list folder at vc %s, %v", ip, err) os.Exit(1) } rc := rest.NewClient(client.Client) if err := rc.Login(ctx, url.UserPassword(user, password)); err != nil { fmt.Fprintf(os.Stderr, "rc.Login failed, %v", err) os.Exit(1) } item, err := getLibraryItem(ctx, rc) if err != nil { return } m := vcenter.NewManager(rc) fr := vcenter.FilterRequest{Target: vcenter.Target{ ResourcePoolID: resourcePools[0].Reference().Value, FolderID: folders[0].Reference().Value, }, } r, err := m.FilterLibraryItem(ctx, item.ID, fr) if err != nil { fmt.Fprintf(os.Stderr, "FilterLibraryItem error, %v\n", err) os.Exit(1) } networkKey := r.Networks[0] storageKey := r.StorageGroups[0] deploy := vcenter.Deploy{ DeploymentSpec: vcenter.DeploymentSpec{ Name: "test", DefaultDatastoreID: datastores[0].Reference().Value, AcceptAllEULA: true, NetworkMappings: []vcenter.NetworkMapping{{ Key: networkKey, Value: networks[0].Reference().Value, }}, StorageMappings: []vcenter.StorageMapping{{ Key: storageKey, Value: vcenter.StorageGroupMapping{ Type: "DATASTORE", DatastoreID: datastores[0].Reference().Value, Provisioning: "thin", }, }}, StorageProvisioning: "thin", }, Target: vcenter.Target{ ResourcePoolID: resourcePools[0].Reference().Value, FolderID: folders[0].Reference().Value, }, } ref, err := vcenter.NewManager(rc).DeployLibraryItem(ctx, item.ID, deploy) if err != nil { fmt.Printf("Deploy vm from library failed, %v", err) return } f := find.NewFinder(client.Client) obj, err := f.ObjectReference(ctx, *ref) if err != nil { fmt.Fprintf(os.Stderr, "Find vm failed, %v\n", err) os.Exit(1) } vm = obj.(*object.VirtualMachine) } 

设置 ip

部署完成后,我们还需要对虚拟机做一些配置,包括配置 ip、更改 cpu 和内存的配置、增加磁盘等,注意这些操作必须在虚拟机关机的情况下才能进行,而刚部署完的虚拟机处于关机状态,我们正好进行操作。

需要注意的是,配置 ip 依赖于 vmtools,因此你得先确保你的模板中已经存在 vmtools。CentOS 6 安装 vm_tools 可能有些麻烦,还得挂盘按照官方的要求一步步进行。但是在 CentOS 7 上你只需要安装 open-vm-tools,然后安装 perl 即可。

type ipAddr struct {
	ip       string
	netmask  string
	gateway  string
	hostname string
}

func (p *ipAddr) setIP(ctx context.Context, vm *object.VirtualMachine) error {
	cam := types.CustomizationAdapterMapping{
		Adapter: types.CustomizationIPSettings{
			Ip:         &types.CustomizationFixedIp{IpAddress: p.ip},
			SubnetMask: p.netmask,
			Gateway:    []string{p.gateway},
		},
	}

	customSpec := types.CustomizationSpec{
		NicSettingMap: []types.CustomizationAdapterMapping{cam},
		Identity:      &types.CustomizationLinuxPrep{HostName: &types.CustomizationFixedName{Name: p.hostname}},
	}

	task, err := vm.Customize(ctx, customSpec)
	if err != nil {
		return err
	}

	return task.Wait(ctx)
}

设置 CPU 和内存

func setCPUAndMem(ctx context.Context, vm *object.VirtualMachine, cpuNum int32, mem int64) error {
	spec := types.VirtualMachineConfigSpec{
		NumCPUs:             cpuNum,
		NumCoresPerSocket:   cpuNum / 2,
		MemoryMB:            1024 * mem,
		CpuHotAddEnabled:    types.NewBool(true),
		MemoryHotAddEnabled: types.NewBool(true),
	}
	task, err := vm.Reconfigure(ctx, spec)
	if err != nil {
		return err
	}

	return task.Wait(ctx)
}

添加磁盘

你需要给它传递一个数据存储对象:

func addDisk(ctx context.Context, vm *object.VirtualMachine, diskCapacityKB int64, ds *types.ManagedObjectReference) error {
	devices, err := vm.Device(ctx)
	if err != nil {
		log.Errorf("Failed to get device list for vm %s, %v", vm.Name(), err)
		return err
	}

    // 这里要看你的磁盘类型,如果你有 nvme,就选择 nvme;否则就选 scsi。当然还有 ide,但是还有人用么
	controller, err := devices.FindDiskController("scsi")
	if err != nil {
		log.Errorf("Failed to find disk controller by name scsi, %v", err)
		return err
	}

	device := types.VirtualDisk{
		CapacityInKB: diskCapacityKB,
		VirtualDevice: types.VirtualDevice{
			Backing: &types.VirtualDiskFlatVer2BackingInfo{
				DiskMode:        string(types.VirtualDiskModePersistent),
				ThinProvisioned: types.NewBool(true),
				VirtualDeviceFileBackingInfo: types.VirtualDeviceFileBackingInfo{
					Datastore: ds,
				},
			},
		},
	}

	devices.AssignController(&device, controller)
	DeviceSpec := &types.VirtualDeviceConfigSpec{
		Operation:     types.VirtualDeviceConfigSpecOperationAdd,
		FileOperation: types.VirtualDeviceConfigSpecFileOperationCreate,
		Device:        &device,
	}

	spec := types.VirtualMachineConfigSpec{}
	spec.DeviceChange = append(spec.DeviceChange, DeviceSpec)
	task, err := vm.Reconfigure(ctx, spec)
	if err != nil {
		log.Errorf("Failed to add disk for vm %s, %v", vm.Name(), err)
		return err
	}

	if err := task.Wait(ctx); err != nil {
		log.Errorf("Failed to add disk for vm %s, %v", vm.Name(), err)
		return err
	}
	return nil
}

开机

在设置了 ip 之后,开机会启动两次,就是第一次启动成功后会重启一次,两次加起来时间还有点长。我也不知道为啥会这样,反正需要等一会儿。

func powerOn(ctx context.Context, vm *object.VirtualMachine) error {
	task, err := vm.PowerOn(ctx)
	if err != nil {
		log.Errorf("Failed to power on %s", vm.Name())
		return err
	}

	return task.Wait(ctx)
}

govmomi 的功能非常多,我这里用到的这是非常少的一部分,如果无法满足你的所有需求,你可能需要看看 govc 源码了😂。

OK,本文到此结束,感谢阅读。

今天的文章使用 govmomi 创建 VMware 虚拟机分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/14124.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注