Initial ESX management libraries

This commit is contained in:
spsobole 2023-06-21 23:16:40 -06:00
parent 41ab945e2a
commit c706dc5126
10 changed files with 821 additions and 0 deletions

74
client/client.go Normal file
View File

@ -0,0 +1,74 @@
package client
import (
"context"
"regexp"
"esxlib/pkg/sshutil"
"github.com/vmware/govmomi/session/cache"
"github.com/vmware/govmomi/vim25"
"github.com/vmware/govmomi/vim25/soap"
)
var diskRegexp = regexp.MustCompile("\\[(.*?)\\] (.*)")
type Option func(c *Client)
type Client struct {
sshAuth *sshutil.Auth
vim *vim25.Client
VirtualMachines VirtualMachines
}
type ClientRef interface {
VIM() *vim25.Client
}
func (c *Client) VIM() *vim25.Client {
return c.vim
}
func WithSSH(host, user, password string) Option {
return func(c *Client) {
c.sshAuth = &sshutil.Auth{
Host: host,
User: user,
Password: password,
}
}
}
func NewClient(ctx context.Context, url string, insecure bool, opts ...Option) (*Client, error) {
// Parse URL from string
u, err := soap.ParseURL(url)
if err != nil {
return nil, err
}
// Share govc's session cache
s := &cache.Session{
URL: u,
Insecure: insecure,
}
c := new(vim25.Client)
err = s.Login(ctx, c, nil)
if err != nil {
return nil, err
}
client := &Client{
vim: c,
}
for _, opt := range opts {
opt(client)
}
client.VirtualMachines.ClientRef = client
client.VirtualMachines.sshAuth = client.sshAuth
return client, nil
}

284
client/inventory_vms.go Normal file
View File

@ -0,0 +1,284 @@
package client
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"text/template"
"esxlib/pkg/sshutil"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/view"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/soap"
)
type VirtualMachines struct {
ClientRef
sshAuth *sshutil.Auth
}
func (vms *VirtualMachines) List(ctx context.Context) ([]*VirtualMachine, error) {
m := view.NewManager(vms.VIM())
v, err := m.CreateContainerView(ctx, vms.VIM().ServiceContent.RootFolder, []string{"VirtualMachine"}, true)
if err != nil {
return nil, err
}
defer v.Destroy(ctx)
// Retrieve summary property for all machines
// Reference: http://pubs.vmware.com/vsphere-60/topic/com.vmware.wssdk.apiref.doc/vim.VirtualMachine.html
var moList []mo.VirtualMachine
err = v.Retrieve(ctx, []string{"VirtualMachine"}, nil, &moList)
if err != nil {
return nil, err
}
vmList := make([]*VirtualMachine, 0)
for idx := range moList {
vmList = append(vmList, &VirtualMachine{
c: vms.VIM(),
sshAuth: vms.sshAuth,
ref: moList[idx].Reference(),
mo: &moList[idx],
})
}
return vmList, nil
}
func (vms *VirtualMachines) Get(ctx context.Context, name string) (*VirtualMachine, error) {
vmList, err := vms.List(ctx)
if err != nil {
return nil, err
}
for idx, vm := range vmList {
if vm.Name() == name {
return vmList[idx], nil
}
}
return nil, errors.New("vm does not exist")
}
func (vms *VirtualMachines) Create(ctx context.Context, name string, memSize uint, diskSize uint, initScript []byte) (*VirtualMachine, error) {
// we need some into about this ESX server from someplace
// DestinationPath: /vmfs/volumes/datastore1
// SourcePath
container := "datastore1"
path := fmt.Sprintf("%s/%s.vmx", name, name)
initScriptEncoded := ""
if len(initScript) != 0 {
initScriptEncoded = base64.StdEncoding.EncodeToString(initScript)
}
tmpl, err := template.New("ubuntu-lunar.cloudinit.tmpl").ParseFiles("configs/ubuntu-lunar.cloudinit.tmpl")
if err != nil {
panic(err)
}
config := struct {
Name string
Hostname string
MemSizeMB uint
CloudInitBase64 string
InitScriptBase64 string
}{
Name: name,
Hostname: name,
MemSizeMB: memSize,
InitScriptBase64: initScriptEncoded,
}
var b bytes.Buffer
w := bufio.NewWriter(&b)
err = tmpl.Execute(w, config)
if err != nil {
panic(err)
}
err = w.Flush()
if err != nil {
panic(err)
}
cloudInitEncoded := base64.StdEncoding.EncodeToString(b.Bytes())
config.CloudInitBase64 = cloudInitEncoded
fmt.Printf("%s\n", string(b.Bytes()))
// Now encode the vm config
tmpl, err = template.New("ubuntu-lunar.esx.tmpl").ParseFiles("configs/ubuntu-lunar.esx.tmpl")
if err != nil {
panic(err)
}
var upload bytes.Buffer
w = bufio.NewWriter(&upload)
err = tmpl.Execute(w, config)
if err != nil {
panic(err)
}
err = w.Flush()
if err != nil {
panic(err)
}
p := soap.DefaultUpload
err = vms.Mkdir(ctx, container, name)
if err != nil {
panic(err)
}
finder := find.NewFinder(vms.VIM())
ds, err := finder.Datastore(ctx, container)
if err != nil {
return nil, err
}
err = ds.Upload(ctx, &upload, path, &p)
if err != nil {
return nil, err
}
err = vms.cloneImage(ctx, "/vmfs/volumes/nas011/iso/lunar-server-cloudimg-amd64.vmdk", fmt.Sprintf("/vmfs/volumes/%s/%s/%s.vmdk", container, name, name), diskSize)
if err != nil {
return nil, err
}
err = vms.registerVM(ctx, fmt.Sprintf("/vmfs/volumes/%s/%s/%s.vmx", container, name, name))
if err != nil {
return nil, err
}
vm, err := vms.Get(ctx, name)
return vm, err
}
func (vms *VirtualMachines) Destroy(ctx context.Context, name string) error {
vm, err := vms.Get(ctx, name)
if err != nil {
return err
}
err = vm.PowerOff(ctx)
if err != nil {
return err
}
// delete the content
datastore, dir, err := vm.InternalPath()
if err != nil {
return err
}
err = vms.unregisterVM(ctx, vm.Id())
if err != nil {
return err
}
err = vms.wipe(ctx, datastore, dir)
return err
}
func (vms *VirtualMachines) unregisterVM(ctx context.Context, id string) error {
cmd := fmt.Sprintf("vim-cmd vmsvc/unregister %s", id)
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: vms.sshAuth.User,
Password: vms.sshAuth.Password,
Host: vms.sshAuth.Host,
}, cmd)
if err != nil {
fmt.Printf("cmd: %s\n", cmd)
return err
}
return nil
}
func (vms *VirtualMachines) registerVM(ctx context.Context, vmxPath string) error {
cmd := fmt.Sprintf("vim-cmd solo/registervm %s", vmxPath)
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: vms.sshAuth.User,
Password: vms.sshAuth.Password,
Host: vms.sshAuth.Host,
}, cmd)
if err != nil {
fmt.Printf("cmd: %s\n", cmd)
return err
}
return nil
}
func (vms *VirtualMachines) cloneImage(ctx context.Context, src, dest string, size uint) error {
cmd := fmt.Sprintf("vmkfstools --clonevirtualdisk %s --diskformat thin %s", src, dest)
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: vms.sshAuth.User,
Password: vms.sshAuth.Password,
Host: vms.sshAuth.Host,
}, cmd)
if err != nil {
fmt.Printf("cmd: %s\n", cmd)
return err
}
cmd = fmt.Sprintf("vmkfstools -X %s %s", "20G", dest)
_, err = sshutil.CombinedOutput(sshutil.Auth{
User: vms.sshAuth.User,
Password: vms.sshAuth.Password,
Host: vms.sshAuth.Host,
}, cmd)
if err != nil {
fmt.Printf("cmd: %s\n", cmd)
return err
}
return nil
}
func (vms *VirtualMachines) wipe(ctx context.Context, datastore, dir string) error {
if vms.sshAuth != nil {
cmd := fmt.Sprintf("rm -rf /vmfs/volumes/%s/%s", datastore, dir)
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: vms.sshAuth.User,
Password: vms.sshAuth.Password,
Host: vms.sshAuth.Host,
}, cmd)
fmt.Printf("cmd: %s\n", cmd)
return err
} else {
panic("no ssh config")
}
return nil
}
func (vms *VirtualMachines) Mkdir(ctx context.Context, datastore, dir string) error {
// you need a ESX payed license to create directories ... thats some bullshit */
if vms.sshAuth != nil {
cmd := fmt.Sprintf("mkdir -p /vmfs/volumes/%s/%s", datastore, dir)
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: vms.sshAuth.User,
Password: vms.sshAuth.Password,
Host: vms.sshAuth.Host,
}, cmd)
fmt.Printf("cmd: %s\n", cmd)
return err
} else {
panic("no ssh config")
}
return nil
}

204
client/vm.go Normal file
View File

@ -0,0 +1,204 @@
package client
import (
"context"
"errors"
"fmt"
"path"
"regexp"
"strings"
"esxlib/pkg/sshutil"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/property"
"github.com/vmware/govmomi/vim25"
"github.com/vmware/govmomi/vim25/methods"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/types"
)
type PowerState string
const (
PowerStateOff = PowerState("off")
PowerStateOn = PowerState("on")
PowerStateSuspended = PowerState("suspended")
PowerStateUndefined = PowerState("")
)
type VirtualMachine struct {
c *vim25.Client
sshAuth *sshutil.Auth
mo *mo.VirtualMachine
ref types.ManagedObjectReference
}
func (v *VirtualMachine) refresh() error {
pc := property.DefaultCollector(v.c)
refs := []types.ManagedObjectReference{v.ref}
var vms []mo.VirtualMachine
err := pc.Retrieve(context.Background(), refs, nil, &vms)
if err != nil {
return err
}
if len(vms) != 1 || vms[0].Name != v.mo.Name {
return errors.New("internal error (vms don't) match")
}
v.mo = &vms[0]
return nil
}
func (v *VirtualMachine) Update() error {
return v.refresh()
}
func (v *VirtualMachine) Name() string {
return v.mo.Name
}
func (v *VirtualMachine) UUID() string {
return v.mo.Config.Uuid
}
func (v *VirtualMachine) Id() string {
return v.mo.Summary.Vm.Value
}
func (v *VirtualMachine) InternalPath() (string, string, error) {
layout, err := v.GetFiles()
if err != nil {
panic(err)
}
for _, l := range layout.File {
if strings.HasSuffix(l.Name, ".vmx") {
re := regexp.MustCompile(`\[(.*?)\] (.*)`)
matches := re.FindStringSubmatch(l.Name)
if len(matches) == 3 {
return matches[1], path.Dir(matches[2]), nil
}
}
}
return "", "", fmt.Errorf("not found")
}
func (v *VirtualMachine) GetNetworkAddressV4() []string {
addresses := make([]string, 0)
for _, x := range v.mo.Config.ExtraConfig {
kv := x.GetOptionValue()
switch kv.Key {
case "guestinfo.local-ipv4":
addresses = append(addresses, kv.Value.(string))
}
}
return addresses
}
func (v *VirtualMachine) GetFiles() (*types.VirtualMachineFileLayoutEx, error) {
if err := v.refresh(); err != nil {
return nil, err
}
return v.mo.LayoutEx, nil
}
func (v *VirtualMachine) State() PowerState {
switch v.mo.Runtime.PowerState {
case types.VirtualMachinePowerStatePoweredOff:
return PowerStateOff
case types.VirtualMachinePowerStatePoweredOn:
return PowerStateOn
case types.VirtualMachinePowerStateSuspended:
return PowerStateSuspended
default:
return PowerStateUndefined
}
}
func (v *VirtualMachine) PowerOn(ctx context.Context) error {
if v.sshAuth != nil {
cmd := fmt.Sprintf("vim-cmd vmsvc/power.on %s", v.Id())
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: v.sshAuth.User,
Password: v.sshAuth.Password,
Host: v.sshAuth.Host,
}, cmd)
return err
}
return fmt.Errorf("unsupported (requires esx ssh privs)")
}
func (v *VirtualMachine) PowerOff(ctx context.Context) error {
if v.sshAuth != nil {
cmd := fmt.Sprintf("vim-cmd vmsvc/power.off %s", v.Id())
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: v.sshAuth.User,
Password: v.sshAuth.Password,
Host: v.sshAuth.Host,
}, cmd)
return err
}
return fmt.Errorf("unsupported (requires esx ssh privs)")
}
func (v *VirtualMachine) Suspend(ctx context.Context) error {
// Workaround: if you are running the FREE license of ESX, the power management API is not available to you
// IE: ServerFaultCode: Current license or ESXi version prohibits execution of the requested operation
// so as an alternative you can provide the SSH auth to the server and we do it the gross way
if v.sshAuth != nil {
cmd := fmt.Sprintf("vim-cmd vmsvc/power.suspend %s", v.Id())
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: v.sshAuth.User,
Password: v.sshAuth.Password,
Host: v.sshAuth.Host,
}, cmd)
return err
}
// standard API method
req := types.SuspendVM_Task{
This: v.mo.Reference(),
}
res, err := methods.SuspendVM_Task(ctx, v.c, &req)
if err != nil {
return err
}
task := object.NewTask(v.c, res.Returnval)
return task.Wait(ctx)
}
func (v *VirtualMachine) Resume(ctx context.Context) error {
// Workaround: if you are running the FREE license of ESX, the power management API is not available to you
// IE: ServerFaultCode: Current license or ESXi version prohibits execution of the requested operation
// so as an alternative you can provide the SSH auth to the server and we do it the gross way
if v.sshAuth != nil {
cmd := fmt.Sprintf("vim-cmd vmsvc/power.on %s", v.Id())
_, err := sshutil.CombinedOutput(sshutil.Auth{
User: v.sshAuth.User,
Password: v.sshAuth.Password,
Host: v.sshAuth.Host,
}, cmd)
return err
}
// standard API method
req := types.PowerOnVM_Task{
This: v.mo.Reference(),
}
res, err := methods.PowerOnVM_Task(ctx, v.c, &req)
if err != nil {
return err
}
task := object.NewTask(v.c, res.Returnval)
return task.Wait(ctx)
}

91
cmd/vmctl/main.go Normal file
View File

@ -0,0 +1,91 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"esxlib/client"
)
type Config struct {
Host string
User string
Password string
}
func UnmarshalFromFile(path string, v interface{}) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
func main() {
config := &Config{}
err := UnmarshalFromFile("config.json", config)
if err != nil {
panic(err)
}
url := fmt.Sprintf("https://%s:%s@%s", config.User, config.Password, config.Host)
c, err := client.NewClient(
context.TODO(),
url,
true,
client.WithSSH(fmt.Sprintf("%s:22", config.Host), config.User, config.Password))
if err != nil {
panic(err)
}
ls, err := c.VirtualMachines.List(context.TODO())
if err != nil {
panic(err)
}
for _, vm := range ls {
fmt.Printf("%s\n", vm.Name())
}
vm, err := c.VirtualMachines.Create(context.TODO(), "test2", 1024, 20000, []byte("#!/bin/bash"))
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", vm)
err = vm.PowerOn(context.TODO())
if err != nil {
panic(err)
}
for {
err = vm.Update()
if err != nil {
panic(err)
}
addrs := vm.GetNetworkAddressV4()
if len(addrs) == 0 {
time.Sleep(time.Second * 5)
continue
}
for _, a := range addrs {
fmt.Printf("%s\n", a)
}
break
}
fmt.Printf("Destroy the vm now\n")
err = c.VirtualMachines.Destroy(context.TODO(), "test2")
if err != nil {
panic(err)
}
}

2
configs/README.md Normal file
View File

@ -0,0 +1,2 @@
https://mac-blog.org.ua/esxi-automate-vm-creation/
https://mac-blog.org.ua/esxi-cloud-init-ubuntu/

View File

@ -0,0 +1,22 @@
#cloud-config
users:
- name: test
plain_text_passwd: test
groups: sudo
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
{{ if ne .InitScriptBase64 "" -}}
write_files:
- path: /root/.cloud-init-user.sh
encoding: b64
content: {{ .InitScriptBase64 }}
owner: root:root
permissions: '0755'
{{ end }}
{{ if ne .InitScriptBase64 "" -}}
runcmd:
- /root/.cloud-init-user.sh
{{ end }}

View File

@ -0,0 +1,72 @@
.encoding = "UTF-8"
config.version = "8"
virtualHW.version = "19"
svga.present = "TRUE"
pciBridge0.present = "TRUE"
pciBridge4.present = "TRUE"
pciBridge4.virtualDev = "pcieRootPort"
pciBridge4.functions = "8"
pciBridge5.present = "TRUE"
pciBridge5.virtualDev = "pcieRootPort"
pciBridge5.functions = "8"
pciBridge6.present = "TRUE"
pciBridge6.virtualDev = "pcieRootPort"
pciBridge6.functions = "8"
pciBridge7.present = "TRUE"
pciBridge7.virtualDev = "pcieRootPort"
pciBridge7.functions = "8"
vmci0.present = "TRUE"
hpet0.present = "TRUE"
floppy0.present = "FALSE"
RemoteDisplay.maxConnections = "-1"
numvcpus = "4"
memSize = "{{ .MemSizeMB }}"
bios.bootRetry.delay = "10"
powerType.powerOff = "default"
powerType.suspend = "soft"
powerType.reset = "default"
tools.upgrade.policy = "manual"
sched.cpu.units = "mhz"
sched.cpu.affinity = "all"
sched.cpu.latencySensitivity = "normal"
scsi0.virtualDev = "lsilogic"
scsi0.present = "TRUE"
sata0.present = "TRUE"
ehci.present = "TRUE"
svga.autodetect = "TRUE"
ethernet0.virtualDev = "vmxnet3"
ethernet0.networkName = "LAN"
ethernet0.addressType = "generated"
ethernet0.wakeOnPcktRcv = "FALSE"
ethernet0.uptCompatibility = "TRUE"
ethernet0.present = "TRUE"
sata0:0.startConnected = "FALSE"
sata0:0.autodetect = "TRUE"
sata0:0.deviceType = "atapi-cdrom"
sata0:0.fileName = "auto detect"
sata0:0.present = "TRUE"
displayName = "{{ .Name }}"
guestOS = "ubuntu-64"
toolScripts.afterPowerOn = "TRUE"
toolScripts.afterResume = "TRUE"
toolScripts.beforeSuspend = "TRUE"
toolScripts.beforePowerOff = "TRUE"
tools.syncTime = "FALSE"
sched.cpu.min = "0"
sched.cpu.shares = "normal"
sched.mem.min = "0"
sched.mem.minSize = "0"
sched.mem.shares = "normal"
sched.scsi0:0.shares = "normal"
sched.scsi0:0.throughputCap = "off"
scsi0:0.deviceType = "scsi-hardDisk"
scsi0:0.fileName = "{{ .Name }}.vmdk"
scsi0:0.present = "TRUE"
guestinfo.userdata.encoding = "base64"
guestinfo.userdata = {{ .CloudInitBase64 }}

10
go.mod Normal file
View File

@ -0,0 +1,10 @@
module esxlib
go 1.20
require (
github.com/vmware/govmomi v0.30.4
golang.org/x/crypto v0.10.0
)
require golang.org/x/sys v0.9.0 // indirect

8
go.sum Normal file
View File

@ -0,0 +1,8 @@
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/vmware/govmomi v0.30.4 h1:BCKLoTmiBYRuplv3GxKEMBLtBaJm8PA56vo9bddIpYQ=
github.com/vmware/govmomi v0.30.4/go.mod h1:F7adsVewLNHsW/IIm7ziFURaXDaHEwcc+ym4r3INMdY=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=

54
pkg/sshutil/commands.go Normal file
View File

@ -0,0 +1,54 @@
package sshutil
import (
"golang.org/x/crypto/ssh"
)
type Auth struct {
Host string
User string
Password string
}
func SshInteractive(user, instruction string, questions []string, echos []bool) (answers []string, err error) {
answers = make([]string, len(questions))
// The second parameter is unused
for n, _ := range questions {
answers[n] = "your_password"
}
return answers, nil
}
func CombinedOutput(auth Auth, cmd string) ([]byte, error) {
sshConfig := &ssh.ClientConfig{
User: auth.User,
Auth: []ssh.AuthMethod{
ssh.KeyboardInteractive(
func(user, instruction string, questions []string, echos []bool) (answers []string, err error) {
answers = make([]string, len(questions))
// The second parameter is unused
for n, _ := range questions {
answers[n] = auth.Password
}
return answers, nil
}),
},
}
sshConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey()
client, err := ssh.Dial("tcp", auth.Host, sshConfig)
if err != nil {
return nil, err
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return nil, err
}
defer session.Close()
out, err := session.CombinedOutput(cmd)
return out, err
}