diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..737d434 --- /dev/null +++ b/client/client.go @@ -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 +} diff --git a/client/inventory_vms.go b/client/inventory_vms.go new file mode 100644 index 0000000..fb056de --- /dev/null +++ b/client/inventory_vms.go @@ -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 +} diff --git a/client/vm.go b/client/vm.go new file mode 100644 index 0000000..f33bfa1 --- /dev/null +++ b/client/vm.go @@ -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) +} diff --git a/cmd/vmctl/main.go b/cmd/vmctl/main.go new file mode 100644 index 0000000..8582ddc --- /dev/null +++ b/cmd/vmctl/main.go @@ -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) + } +} diff --git a/configs/README.md b/configs/README.md new file mode 100644 index 0000000..82e200e --- /dev/null +++ b/configs/README.md @@ -0,0 +1,2 @@ +https://mac-blog.org.ua/esxi-automate-vm-creation/ +https://mac-blog.org.ua/esxi-cloud-init-ubuntu/ \ No newline at end of file diff --git a/configs/ubuntu-lunar.cloudinit.tmpl b/configs/ubuntu-lunar.cloudinit.tmpl new file mode 100644 index 0000000..f9955aa --- /dev/null +++ b/configs/ubuntu-lunar.cloudinit.tmpl @@ -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 }} \ No newline at end of file diff --git a/configs/ubuntu-lunar.esx.tmpl b/configs/ubuntu-lunar.esx.tmpl new file mode 100644 index 0000000..7844354 --- /dev/null +++ b/configs/ubuntu-lunar.esx.tmpl @@ -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 }} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..06f2d2d --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d5b3724 --- /dev/null +++ b/go.sum @@ -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= diff --git a/pkg/sshutil/commands.go b/pkg/sshutil/commands.go new file mode 100644 index 0000000..252815d --- /dev/null +++ b/pkg/sshutil/commands.go @@ -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 +}