From d2f036ec44c00a2b50078787e6f3a5690f83c492 Mon Sep 17 00:00:00 2001 From: Adrien Delorme Date: Thu, 11 Apr 2019 18:52:21 +0200 Subject: [PATCH] Revert "Revert "Merge pull request #7391 from carlpett/proxmox-builder"" This reverts commit 032527ecfe2c5a9fcbe32c63cdf7755f2777df88. --- builder/proxmox/artifact.go | 44 ++ builder/proxmox/bootcommand_driver.go | 117 +++ builder/proxmox/builder.go | 122 +++ builder/proxmox/config.go | 196 +++++ builder/proxmox/config_test.go | 115 +++ builder/proxmox/step_convert_to_template.go | 53 ++ .../proxmox/step_convert_to_template_test.go | 103 +++ .../proxmox/step_finalize_template_config.go | 72 ++ .../step_finalize_template_config_test.go | 151 ++++ builder/proxmox/step_start_vm.go | 150 ++++ builder/proxmox/step_start_vm_test.go | 108 +++ builder/proxmox/step_success.go | 22 + builder/proxmox/step_type_boot_command.go | 119 +++ .../proxmox/step_type_boot_command_test.go | 128 ++++ command/plugin.go | 2 + go.sum | 2 + .../github.com/Telmate/proxmox-api-go/LICENSE | 21 + .../Telmate/proxmox-api-go/proxmox/client.go | 597 +++++++++++++++ .../proxmox-api-go/proxmox/config_qemu.go | 706 ++++++++++++++++++ .../Telmate/proxmox-api-go/proxmox/session.go | 319 ++++++++ .../Telmate/proxmox-api-go/proxmox/util.go | 62 ++ vendor/modules.txt | 2 + website/source/docs/builders/proxmox.html.md | 201 +++++ 23 files changed, 3412 insertions(+) create mode 100644 builder/proxmox/artifact.go create mode 100644 builder/proxmox/bootcommand_driver.go create mode 100644 builder/proxmox/builder.go create mode 100644 builder/proxmox/config.go create mode 100644 builder/proxmox/config_test.go create mode 100644 builder/proxmox/step_convert_to_template.go create mode 100644 builder/proxmox/step_convert_to_template_test.go create mode 100644 builder/proxmox/step_finalize_template_config.go create mode 100644 builder/proxmox/step_finalize_template_config_test.go create mode 100644 builder/proxmox/step_start_vm.go create mode 100644 builder/proxmox/step_start_vm_test.go create mode 100644 builder/proxmox/step_success.go create mode 100644 builder/proxmox/step_type_boot_command.go create mode 100644 builder/proxmox/step_type_boot_command_test.go create mode 100644 vendor/github.com/Telmate/proxmox-api-go/LICENSE create mode 100644 vendor/github.com/Telmate/proxmox-api-go/proxmox/client.go create mode 100644 vendor/github.com/Telmate/proxmox-api-go/proxmox/config_qemu.go create mode 100644 vendor/github.com/Telmate/proxmox-api-go/proxmox/session.go create mode 100644 vendor/github.com/Telmate/proxmox-api-go/proxmox/util.go create mode 100644 website/source/docs/builders/proxmox.html.md diff --git a/builder/proxmox/artifact.go b/builder/proxmox/artifact.go new file mode 100644 index 000000000..99d8e1a20 --- /dev/null +++ b/builder/proxmox/artifact.go @@ -0,0 +1,44 @@ +package proxmox + +import ( + "fmt" + "log" + "strconv" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/packer" +) + +type Artifact struct { + templateID int + proxmoxClient *proxmox.Client +} + +// Artifact implements packer.Artifact +var _ packer.Artifact = &Artifact{} + +func (*Artifact) BuilderId() string { + return BuilderId +} + +func (*Artifact) Files() []string { + return nil +} + +func (a *Artifact) Id() string { + return strconv.Itoa(a.templateID) +} + +func (a *Artifact) String() string { + return fmt.Sprintf("A template was created: %d", a.templateID) +} + +func (a *Artifact) State(name string) interface{} { + return nil +} + +func (a *Artifact) Destroy() error { + log.Printf("Destroying template: %d", a.templateID) + _, err := a.proxmoxClient.DeleteVm(proxmox.NewVmRef(a.templateID)) + return err +} diff --git a/builder/proxmox/bootcommand_driver.go b/builder/proxmox/bootcommand_driver.go new file mode 100644 index 000000000..1570845fe --- /dev/null +++ b/builder/proxmox/bootcommand_driver.go @@ -0,0 +1,117 @@ +package proxmox + +import ( + "fmt" + "time" + "unicode" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/common/bootcommand" +) + +type proxmoxDriver struct { + client commandTyper + vmRef *proxmox.VmRef + specialMap map[string]string + runeMap map[rune]string + interval time.Duration +} + +func NewProxmoxDriver(c commandTyper, vmRef *proxmox.VmRef, interval time.Duration) *proxmoxDriver { + // Mappings for packer shorthand to qemu qkeycodes + sMap := map[string]string{ + "spacebar": "spc", + "bs": "backspace", + "del": "delete", + "return": "ret", + "enter": "ret", + "pageUp": "pgup", + "pageDown": "pgdn", + } + // Mappings for runes that need to be translated to special qkeycodes + // Taken from https://github.com/qemu/qemu/blob/master/pc-bios/keymaps/en-us + rMap := map[rune]string{ + // Clean mappings + ' ': "spc", + '.': "dot", + ',': "comma", + ';': "semicolon", + '*': "asterisk", + '-': "minus", + '[': "bracket_left", + ']': "bracket_right", + '=': "equal", + '\'': "apostrophe", + '`': "grave_accent", + '/': "slash", + '\\': "backslash", + + '!': "shift-1", // "exclam" + '@': "shift-2", // "at" + '#': "shift-3", // "numbersign" + '$': "shift-4", // "dollar" + '%': "shift-5", // "percent" + '^': "shift-6", // "asciicircum" + '&': "shift-7", // "ampersand" + '(': "shift-9", // "parenleft" + ')': "shift-0", // "parenright" + '{': "shift-bracket_left", // "braceleft" + '}': "shift-bracket_right", // "braceright" + '"': "shift-apostrophe", // "quotedbl" + '+': "shift-equal", // "plus" + '_': "shift-minus", // "underscore" + ':': "shift-semicolon", // "colon" + '<': "shift-comma", // "less" is recognized, but seem to map to '/'? + '>': "shift-dot", // "greater" + '~': "shift-grave_accent", // "asciitilde" + '?': "shift-slash", // "question" + '|': "shift-backslash", // "bar" + } + + return &proxmoxDriver{ + client: c, + vmRef: vmRef, + specialMap: sMap, + runeMap: rMap, + interval: interval, + } +} + +func (p *proxmoxDriver) SendKey(key rune, action bootcommand.KeyAction) error { + if special, ok := p.runeMap[key]; ok { + return p.send(special) + } + + var keys string + if unicode.IsUpper(key) { + keys = fmt.Sprintf("shift-%c", unicode.ToLower(key)) + } else { + keys = fmt.Sprintf("%c", key) + } + + return p.send(keys) +} + +func (p *proxmoxDriver) SendSpecial(special string, action bootcommand.KeyAction) error { + keys := special + if replacement, ok := p.specialMap[special]; ok { + keys = replacement + } + + return p.send(keys) +} + +func (p *proxmoxDriver) send(keys string) error { + res, err := p.client.MonitorCmd(p.vmRef, "sendkey "+keys) + if err != nil { + return err + } + if data, ok := res["data"].(string); ok && len(data) > 0 { + return fmt.Errorf("failed to send keys: %s", data) + } + + time.Sleep(p.interval) + return nil +} + +func (p *proxmoxDriver) Flush() error { return nil } diff --git a/builder/proxmox/builder.go b/builder/proxmox/builder.go new file mode 100644 index 000000000..59e271380 --- /dev/null +++ b/builder/proxmox/builder.go @@ -0,0 +1,122 @@ +package proxmox + +import ( + "context" + "crypto/tls" + "fmt" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/helper/communicator" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +// The unique id for the builder +const BuilderId = "proxmox.builder" + +type Builder struct { + config Config + runner multistep.Runner + proxmoxClient *proxmox.Client +} + +// Builder implements packer.Builder +var _ packer.Builder = &Builder{} + +var pluginVersion = "1.0.0" + +func (b *Builder) Prepare(raws ...interface{}) ([]string, error) { + config, warnings, errs := NewConfig(raws...) + if errs != nil { + return warnings, errs + } + b.config = *config + return nil, nil +} + +func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (packer.Artifact, error) { + var err error + tlsConfig := &tls.Config{ + InsecureSkipVerify: b.config.SkipCertValidation, + } + b.proxmoxClient, err = proxmox.NewClient(b.config.ProxmoxURL.String(), nil, tlsConfig) + if err != nil { + return nil, err + } + + err = b.proxmoxClient.Login(b.config.Username, b.config.Password) + if err != nil { + return nil, err + } + + // Set up the state + state := new(multistep.BasicStateBag) + state.Put("config", &b.config) + state.Put("proxmoxClient", b.proxmoxClient) + state.Put("hook", hook) + state.Put("ui", ui) + + // Build the steps + steps := []multistep.Step{ + &stepStartVM{}, + &common.StepHTTPServer{ + HTTPDir: b.config.HTTPDir, + HTTPPortMin: b.config.HTTPPortMin, + HTTPPortMax: b.config.HTTPPortMax, + }, + &stepTypeBootCommand{ + BootConfig: b.config.BootConfig, + Ctx: b.config.ctx, + }, + &communicator.StepConnect{ + Config: &b.config.Comm, + Host: getVMIP, + SSHConfig: b.config.Comm.SSHConfigFunc(), + }, + &common.StepProvision{}, + &common.StepCleanupTempKeys{ + Comm: &b.config.Comm, + }, + &stepConvertToTemplate{}, + &stepFinalizeTemplateConfig{}, + &stepSuccess{}, + } + // Run the steps + b.runner = common.NewRunner(steps, b.config.PackerConfig, ui) + b.runner.Run(ctx, state) + // If there was an error, return that + if rawErr, ok := state.GetOk("error"); ok { + return nil, rawErr.(error) + } + + artifact := &Artifact{ + templateID: state.Get("template_id").(int), + proxmoxClient: b.proxmoxClient, + } + + return artifact, nil +} + +func getVMIP(state multistep.StateBag) (string, error) { + c := state.Get("proxmoxClient").(*proxmox.Client) + vmRef := state.Get("vmRef").(*proxmox.VmRef) + + ifs, err := c.GetVmAgentNetworkInterfaces(vmRef) + if err != nil { + return "", err + } + + // TODO: Do something smarter here? Allow specifying interface? Or address family? + // For now, just go for first non-loopback + for _, iface := range ifs { + for _, addr := range iface.IPAddresses { + if addr.IsLoopback() { + continue + } + return addr.String(), nil + } + } + + return "", fmt.Errorf("Found no IP addresses on VM") +} diff --git a/builder/proxmox/config.go b/builder/proxmox/config.go new file mode 100644 index 000000000..8b60c509f --- /dev/null +++ b/builder/proxmox/config.go @@ -0,0 +1,196 @@ +package proxmox + +import ( + "errors" + "fmt" + "log" + "net/url" + "os" + "time" + + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/common/bootcommand" + "github.com/hashicorp/packer/common/uuid" + "github.com/hashicorp/packer/helper/communicator" + "github.com/hashicorp/packer/helper/config" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template/interpolate" + "github.com/mitchellh/mapstructure" +) + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + common.HTTPConfig `mapstructure:",squash"` + bootcommand.BootConfig `mapstructure:",squash"` + RawBootKeyInterval string `mapstructure:"boot_key_interval"` + BootKeyInterval time.Duration `` + Comm communicator.Config `mapstructure:",squash"` + + ProxmoxURLRaw string `mapstructure:"proxmox_url"` + ProxmoxURL *url.URL + SkipCertValidation bool `mapstructure:"insecure_skip_tls_verify"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + Node string `mapstructure:"node"` + + VMName string `mapstructure:"vm_name"` + VMID int `mapstructure:"vm_id"` + + Memory int `mapstructure:"memory"` + Cores int `mapstructure:"cores"` + Sockets int `mapstructure:"sockets"` + OS string `mapstructure:"os"` + NICs []nicConfig `mapstructure:"network_adapters"` + Disks []diskConfig `mapstructure:"disks"` + ISOFile string `mapstructure:"iso_file"` + + TemplateName string `mapstructure:"template_name"` + TemplateDescription string `mapstructure:"template_description"` + UnmountISO bool `mapstructure:"unmount_iso"` + + ctx interpolate.Context +} + +type nicConfig struct { + Model string `mapstructure:"model"` + MACAddress string `mapstructure:"mac_address"` + Bridge string `mapstructure:"bridge"` + VLANTag string `mapstructure:"vlan_tag"` +} +type diskConfig struct { + Type string `mapstructure:"type"` + StoragePool string `mapstructure:"storage_pool"` + StoragePoolType string `mapstructure:"storage_pool_type"` + Size string `mapstructure:"disk_size"` + CacheMode string `mapstructure:"cache_mode"` + DiskFormat string `mapstructure:"format"` +} + +func NewConfig(raws ...interface{}) (*Config, []string, error) { + c := new(Config) + + var md mapstructure.Metadata + err := config.Decode(c, &config.DecodeOpts{ + Metadata: &md, + Interpolate: true, + InterpolateContext: &c.ctx, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{ + "boot_command", + }, + }, + }, raws...) + if err != nil { + return nil, nil, err + } + + var errs *packer.MultiError + + // Defaults + if c.ProxmoxURLRaw == "" { + c.ProxmoxURLRaw = os.Getenv("PROXMOX_URL") + } + if c.Username == "" { + c.Username = os.Getenv("PROXMOX_USERNAME") + } + if c.Password == "" { + c.Password = os.Getenv("PROXMOX_PASSWORD") + } + if c.RawBootKeyInterval == "" { + c.RawBootKeyInterval = os.Getenv(common.PackerKeyEnv) + } + if c.RawBootKeyInterval == "" { + c.BootKeyInterval = common.PackerKeyDefault + } else { + if interval, err := time.ParseDuration(c.RawBootKeyInterval); err == nil { + c.BootKeyInterval = interval + } else { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Could not parse boot_key_interval: %v", err)) + } + } + + if c.VMName == "" { + // Default to packer-[time-ordered-uuid] + c.VMName = fmt.Sprintf("packer-%s", uuid.TimeOrderedUUID()) + } + if c.Memory < 16 { + log.Printf("Memory %d is too small, using default: 512", c.Memory) + c.Memory = 512 + } + if c.Cores < 1 { + log.Printf("Number of cores %d is too small, using default: 1", c.Cores) + c.Cores = 1 + } + if c.Sockets < 1 { + log.Printf("Number of sockets %d is too small, using default: 1", c.Sockets) + c.Sockets = 1 + } + if c.OS == "" { + log.Printf("OS not set, using default 'other'") + c.OS = "other" + } + for idx := range c.NICs { + if c.NICs[idx].Model == "" { + log.Printf("NIC %d model not set, using default 'e1000'", idx) + c.NICs[idx].Model = "e1000" + } + } + for idx := range c.Disks { + if c.Disks[idx].Type == "" { + log.Printf("Disk %d type not set, using default 'scsi'", idx) + c.Disks[idx].Type = "scsi" + } + if c.Disks[idx].Size == "" { + log.Printf("Disk %d size not set, using default '20G'", idx) + c.Disks[idx].Size = "20G" + } + if c.Disks[idx].CacheMode == "" { + log.Printf("Disk %d cache mode not set, using default 'none'", idx) + c.Disks[idx].CacheMode = "none" + } + } + + errs = packer.MultiErrorAppend(errs, c.Comm.Prepare(&c.ctx)...) + errs = packer.MultiErrorAppend(errs, c.BootConfig.Prepare(&c.ctx)...) + errs = packer.MultiErrorAppend(errs, c.HTTPConfig.Prepare(&c.ctx)...) + + // Required configurations that will display errors if not set + if c.Username == "" { + errs = packer.MultiErrorAppend(errs, errors.New("username must be specified")) + } + if c.Password == "" { + errs = packer.MultiErrorAppend(errs, errors.New("password must be specified")) + } + if c.ProxmoxURLRaw == "" { + errs = packer.MultiErrorAppend(errs, errors.New("proxmox_url must be specified")) + } + if c.ProxmoxURL, err = url.Parse(c.ProxmoxURLRaw); err != nil { + errs = packer.MultiErrorAppend(errs, errors.New(fmt.Sprintf("Could not parse proxmox_url: %s", err))) + } + if c.ISOFile == "" { + errs = packer.MultiErrorAppend(errs, errors.New("iso_file must be specified")) + } + if c.Node == "" { + errs = packer.MultiErrorAppend(errs, errors.New("node must be specified")) + } + for idx := range c.NICs { + if c.NICs[idx].Bridge == "" { + errs = packer.MultiErrorAppend(errs, errors.New(fmt.Sprintf("network_adapters[%d].bridge must be specified", idx))) + } + } + for idx := range c.Disks { + if c.Disks[idx].StoragePool == "" { + errs = packer.MultiErrorAppend(errs, errors.New(fmt.Sprintf("disks[%d].storage_pool must be specified", idx))) + } + if c.Disks[idx].StoragePoolType == "" { + errs = packer.MultiErrorAppend(errs, errors.New(fmt.Sprintf("disks[%d].storage_pool_type must be specified", idx))) + } + } + + if errs != nil && len(errs.Errors) > 0 { + return nil, nil, errs + } + + packer.LogSecretFilter.Set(c.Password) + return c, nil, nil +} diff --git a/builder/proxmox/config_test.go b/builder/proxmox/config_test.go new file mode 100644 index 000000000..e04f4a0d5 --- /dev/null +++ b/builder/proxmox/config_test.go @@ -0,0 +1,115 @@ +package proxmox + +import ( + "strings" + "testing" + + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template" +) + +func TestRequiredParameters(t *testing.T) { + _, _, err := NewConfig(make(map[string]interface{})) + if err == nil { + t.Fatal("Expected empty configuration to fail") + } + errs, ok := err.(*packer.MultiError) + if !ok { + t.Fatal("Expected errors to be packer.MultiError") + } + + required := []string{"username", "password", "proxmox_url", "iso_file", "node", "ssh_username"} + for _, param := range required { + found := false + for _, err := range errs.Errors { + if strings.Contains(err.Error(), param) { + found = true + break + } + } + if !found { + t.Errorf("Expected error about missing parameter %q", required) + } + } +} + +func TestBasicExampleFromDocsIsValid(t *testing.T) { + const config = `{ + "builders": [ + { + "type": "proxmox", + "proxmox_url": "https://my-proxmox.my-domain:8006/api2/json", + "insecure_skip_tls_verify": true, + "username": "apiuser@pve", + "password": "supersecret", + + "node": "my-proxmox", + "network_adapters": [ + { + "bridge": "vmbr0" + } + ], + "disks": [ + { + "type": "scsi", + "disk_size": "5G", + "storage_pool": "local-lvm", + "storage_pool_type": "lvm" + } + ], + + "iso_file": "local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso", + "http_directory":"config", + "boot_wait": "10s", + "boot_command": [ + " ip=dhcp inst.cmdline inst.ks=http://{{.HTTPIP}}:{{.HTTPPort}}/ks.cfg" + ], + + "ssh_username": "root", + "ssh_timeout": "15m", + "ssh_password": "packer", + + "unmount_iso": true, + "template_name": "fedora-29", + "template_description": "Fedora 29-1.2, generated on {{ isotime \"2006-01-02T15:04:05Z\" }}" + } + ] +}` + tpl, err := template.Parse(strings.NewReader(config)) + if err != nil { + t.Fatal(err) + } + + b := &Builder{} + warn, err := b.Prepare(tpl.Builders["proxmox"].Config) + if err != nil { + t.Fatal(err, warn) + } + + // The example config does not set a number of optional fields. Validate that: + // Memory 0 is too small, using default: 512 + // Number of cores 0 is too small, using default: 1 + // Number of sockets 0 is too small, using default: 1 + // OS not set, using default 'other' + // NIC 0 model not set, using default 'e1000' + // Disk 0 cache mode not set, using default 'none' + + if b.config.Memory != 512 { + t.Errorf("Expected Memory to be 512, got %d", b.config.Memory) + } + if b.config.Cores != 1 { + t.Errorf("Expected Cores to be 1, got %d", b.config.Cores) + } + if b.config.Sockets != 1 { + t.Errorf("Expected Sockets to be 1, got %d", b.config.Sockets) + } + if b.config.OS != "other" { + t.Errorf("Expected OS to be 'other', got %s", b.config.OS) + } + if b.config.NICs[0].Model != "e1000" { + t.Errorf("Expected NIC model to be 'e1000', got %s", b.config.NICs[0].Model) + } + if b.config.Disks[0].CacheMode != "none" { + t.Errorf("Expected disk cache mode to be 'none', got %s", b.config.Disks[0].CacheMode) + } +} diff --git a/builder/proxmox/step_convert_to_template.go b/builder/proxmox/step_convert_to_template.go new file mode 100644 index 000000000..712badd81 --- /dev/null +++ b/builder/proxmox/step_convert_to_template.go @@ -0,0 +1,53 @@ +package proxmox + +import ( + "context" + "fmt" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +// stepConvertToTemplate takes the running VM configured in earlier steps, stops it, and +// converts it into a Proxmox template. +// +// It sets the template_id state which is used for Artifact lookup. +type stepConvertToTemplate struct{} + +type templateConverter interface { + ShutdownVm(*proxmox.VmRef) (string, error) + CreateTemplate(*proxmox.VmRef) error +} + +var _ templateConverter = &proxmox.Client{} + +func (s *stepConvertToTemplate) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + client := state.Get("proxmoxClient").(templateConverter) + vmRef := state.Get("vmRef").(*proxmox.VmRef) + + ui.Say("Stopping VM") + _, err := client.ShutdownVm(vmRef) + if err != nil { + err := fmt.Errorf("Error converting VM to template, could not stop: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + ui.Say("Converting VM to template") + err = client.CreateTemplate(vmRef) + if err != nil { + err := fmt.Errorf("Error converting VM to template: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + state.Put("template_id", vmRef.VmId()) + + return multistep.ActionContinue +} + +func (s *stepConvertToTemplate) Cleanup(state multistep.StateBag) {} diff --git a/builder/proxmox/step_convert_to_template_test.go b/builder/proxmox/step_convert_to_template_test.go new file mode 100644 index 000000000..a7f472e3a --- /dev/null +++ b/builder/proxmox/step_convert_to_template_test.go @@ -0,0 +1,103 @@ +package proxmox + +import ( + "context" + "fmt" + "testing" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +type converterMock struct { + shutdownVm func(*proxmox.VmRef) (string, error) + createTemplate func(*proxmox.VmRef) error +} + +func (m converterMock) ShutdownVm(r *proxmox.VmRef) (string, error) { + return m.shutdownVm(r) +} +func (m converterMock) CreateTemplate(r *proxmox.VmRef) error { + return m.createTemplate(r) +} + +var _ templateConverter = converterMock{} + +func TestConvertToTemplate(t *testing.T) { + cs := []struct { + name string + shutdownErr error + expectCallCreateTemplate bool + createTemplateErr error + expectedAction multistep.StepAction + expectTemplateIdSet bool + }{ + { + name: "no errors returns continue and sets template id", + expectCallCreateTemplate: true, + expectedAction: multistep.ActionContinue, + expectTemplateIdSet: true, + }, + { + name: "when shutdown fails, don't try to create template and halt", + shutdownErr: fmt.Errorf("failed to stop vm"), + expectCallCreateTemplate: false, + expectedAction: multistep.ActionHalt, + expectTemplateIdSet: false, + }, + { + name: "when create template fails, halt", + expectCallCreateTemplate: true, + createTemplateErr: fmt.Errorf("failed to stop vm"), + expectedAction: multistep.ActionHalt, + expectTemplateIdSet: false, + }, + } + + const vmid = 123 + + for _, c := range cs { + t.Run(c.name, func(t *testing.T) { + converter := converterMock{ + shutdownVm: func(r *proxmox.VmRef) (string, error) { + if r.VmId() != vmid { + t.Errorf("ShutdownVm called with unexpected id, expected %d, got %d", vmid, r.VmId()) + } + return "", c.shutdownErr + }, + createTemplate: func(r *proxmox.VmRef) error { + if r.VmId() != vmid { + t.Errorf("CreateTemplate called with unexpected id, expected %d, got %d", vmid, r.VmId()) + } + if !c.expectCallCreateTemplate { + t.Error("Did not expect CreateTemplate to be called") + } + + return c.createTemplateErr + }, + } + + state := new(multistep.BasicStateBag) + state.Put("ui", packer.TestUi(t)) + state.Put("vmRef", proxmox.NewVmRef(vmid)) + state.Put("proxmoxClient", converter) + + step := stepConvertToTemplate{} + action := step.Run(context.TODO(), state) + if action != c.expectedAction { + t.Errorf("Expected action to be %v, got %v", c.expectedAction, action) + } + + id, wasSet := state.GetOk("template_id") + + if c.expectTemplateIdSet != wasSet { + t.Errorf("Expected template_id state present=%v was present=%v", c.expectTemplateIdSet, wasSet) + } + + if c.expectTemplateIdSet && id != vmid { + t.Errorf("Expected template_id state to be set to %d, got %v", vmid, id) + } + }) + } +} diff --git a/builder/proxmox/step_finalize_template_config.go b/builder/proxmox/step_finalize_template_config.go new file mode 100644 index 000000000..df90fc9f8 --- /dev/null +++ b/builder/proxmox/step_finalize_template_config.go @@ -0,0 +1,72 @@ +package proxmox + +import ( + "context" + "fmt" + "strings" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +// stepFinalizeTemplateConfig does any required modifications to the configuration _after_ +// the VM has been converted into a template, such as updating name and description, or +// unmounting the installation ISO. +type stepFinalizeTemplateConfig struct{} + +type templateFinalizer interface { + GetVmConfig(*proxmox.VmRef) (map[string]interface{}, error) + SetVmConfig(*proxmox.VmRef, map[string]interface{}) (interface{}, error) +} + +var _ templateFinalizer = &proxmox.Client{} + +func (s *stepFinalizeTemplateConfig) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + client := state.Get("proxmoxClient").(templateFinalizer) + c := state.Get("config").(*Config) + vmRef := state.Get("vmRef").(*proxmox.VmRef) + + changes := make(map[string]interface{}) + + if c.TemplateName != "" { + changes["name"] = c.TemplateName + } + + // During build, the description is "Packer ephemeral build VM", so if no description is + // set, we need to clear it + changes["description"] = c.TemplateDescription + + if c.UnmountISO { + vmParams, err := client.GetVmConfig(vmRef) + if err != nil { + err := fmt.Errorf("Error fetching template config: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + if vmParams["ide2"] == nil || !strings.HasSuffix(vmParams["ide2"].(string), "media=cdrom") { + err := fmt.Errorf("Cannot eject ISO from cdrom drive, ide2 is not present, or not a cdrom media") + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + changes["ide2"] = "none,media=cdrom" + } + + if len(changes) > 0 { + _, err := client.SetVmConfig(vmRef, changes) + if err != nil { + err := fmt.Errorf("Error updating template: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + } + + return multistep.ActionContinue +} + +func (s *stepFinalizeTemplateConfig) Cleanup(state multistep.StateBag) {} diff --git a/builder/proxmox/step_finalize_template_config_test.go b/builder/proxmox/step_finalize_template_config_test.go new file mode 100644 index 000000000..501dddef2 --- /dev/null +++ b/builder/proxmox/step_finalize_template_config_test.go @@ -0,0 +1,151 @@ +package proxmox + +import ( + "context" + "fmt" + "testing" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +type finalizerMock struct { + getConfig func() (map[string]interface{}, error) + setConfig func(map[string]interface{}) (string, error) +} + +func (m finalizerMock) GetVmConfig(*proxmox.VmRef) (map[string]interface{}, error) { + return m.getConfig() +} +func (m finalizerMock) SetVmConfig(vmref *proxmox.VmRef, c map[string]interface{}) (interface{}, error) { + return m.setConfig(c) +} + +var _ templateFinalizer = finalizerMock{} + +func TestTemplateFinalize(t *testing.T) { + cs := []struct { + name string + builderConfig *Config + initialVMConfig map[string]interface{} + getConfigErr error + expectCallSetConfig bool + expectedVMConfig map[string]interface{} + setConfigErr error + expectedAction multistep.StepAction + }{ + { + name: "empty config changes only description", + builderConfig: &Config{}, + initialVMConfig: map[string]interface{}{ + "name": "dummy", + "description": "Packer ephemeral build VM", + "ide2": "local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso,media=cdrom", + }, + expectCallSetConfig: true, + expectedVMConfig: map[string]interface{}{ + "name": nil, + "description": "", + "ide2": nil, + }, + expectedAction: multistep.ActionContinue, + }, + { + name: "all options", + builderConfig: &Config{ + TemplateName: "my-template", + TemplateDescription: "some-description", + UnmountISO: true, + }, + initialVMConfig: map[string]interface{}{ + "name": "dummy", + "description": "Packer ephemeral build VM", + "ide2": "local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso,media=cdrom", + }, + expectCallSetConfig: true, + expectedVMConfig: map[string]interface{}{ + "name": "my-template", + "description": "some-description", + "ide2": "none,media=cdrom", + }, + expectedAction: multistep.ActionContinue, + }, + { + name: "no cd-drive with unmount=true should returns halt", + builderConfig: &Config{ + TemplateName: "my-template", + TemplateDescription: "some-description", + UnmountISO: true, + }, + initialVMConfig: map[string]interface{}{ + "name": "dummy", + "description": "Packer ephemeral build VM", + "ide1": "local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso,media=cdrom", + }, + expectCallSetConfig: false, + expectedAction: multistep.ActionHalt, + }, + { + name: "GetVmConfig error should return halt", + builderConfig: &Config{ + TemplateName: "my-template", + TemplateDescription: "some-description", + UnmountISO: true, + }, + getConfigErr: fmt.Errorf("some error"), + expectCallSetConfig: false, + expectedAction: multistep.ActionHalt, + }, + { + name: "SetVmConfig error should return halt", + builderConfig: &Config{ + TemplateName: "my-template", + TemplateDescription: "some-description", + UnmountISO: true, + }, + initialVMConfig: map[string]interface{}{ + "name": "dummy", + "description": "Packer ephemeral build VM", + "ide2": "local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso,media=cdrom", + }, + expectCallSetConfig: true, + setConfigErr: fmt.Errorf("some error"), + expectedAction: multistep.ActionHalt, + }, + } + + for _, c := range cs { + t.Run(c.name, func(t *testing.T) { + finalizer := finalizerMock{ + getConfig: func() (map[string]interface{}, error) { + return c.initialVMConfig, c.getConfigErr + }, + setConfig: func(cfg map[string]interface{}) (string, error) { + if !c.expectCallSetConfig { + t.Error("Did not expect SetVmConfig to be called") + } + for key, val := range c.expectedVMConfig { + if cfg[key] != val { + t.Errorf("Expected %q to be %q, got %q", key, val, cfg[key]) + } + } + + return "", c.setConfigErr + }, + } + + state := new(multistep.BasicStateBag) + state.Put("ui", packer.TestUi(t)) + state.Put("config", c.builderConfig) + state.Put("vmRef", proxmox.NewVmRef(1)) + state.Put("proxmoxClient", finalizer) + + step := stepFinalizeTemplateConfig{} + action := step.Run(context.TODO(), state) + if action != c.expectedAction { + t.Errorf("Expected action to be %v, got %v", c.expectedAction, action) + } + }) + } +} diff --git a/builder/proxmox/step_start_vm.go b/builder/proxmox/step_start_vm.go new file mode 100644 index 000000000..779394023 --- /dev/null +++ b/builder/proxmox/step_start_vm.go @@ -0,0 +1,150 @@ +package proxmox + +import ( + "context" + "fmt" + "log" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +// stepStartVM takes the given configuration and starts a VM on the given Proxmox node. +// +// It sets the vmRef state which is used throughout the later steps to reference the VM +// in API calls. +type stepStartVM struct{} + +func (s *stepStartVM) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + client := state.Get("proxmoxClient").(*proxmox.Client) + c := state.Get("config").(*Config) + + ui.Say("Creating VM") + config := proxmox.ConfigQemu{ + Name: c.VMName, + Agent: "1", + Description: "Packer ephemeral build VM", + Memory: c.Memory, + QemuCores: c.Cores, + QemuSockets: c.Sockets, + QemuOs: c.OS, + QemuIso: c.ISOFile, + QemuNetworks: generateProxmoxNetworkAdapters(c.NICs), + QemuDisks: generateProxmoxDisks(c.Disks), + } + + if c.VMID == 0 { + ui.Say("No VM ID given, getting next free from Proxmox") + for n := 0; n < 5; n++ { + id, err := proxmox.MaxVmId(client) + if err != nil { + log.Printf("Error getting max used VM ID: %v (attempt %d/5)", err, n+1) + continue + } + c.VMID = id + 1 + break + } + if c.VMID == 0 { + err := fmt.Errorf("Failed to get free VM ID") + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + } + vmRef := proxmox.NewVmRef(c.VMID) + vmRef.SetNode(c.Node) + + err := config.CreateVm(vmRef, client) + if err != nil { + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + // Store the vm id for later + state.Put("vmRef", vmRef) + + ui.Say("Starting VM") + _, err = client.StartVm(vmRef) + if err != nil { + err := fmt.Errorf("Error starting VM: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + return multistep.ActionContinue +} + +func generateProxmoxNetworkAdapters(nics []nicConfig) proxmox.QemuDevices { + devs := make(proxmox.QemuDevices) + for idx := range nics { + devs[idx] = make(proxmox.QemuDevice) + setDeviceParamIfDefined(devs[idx], "model", nics[idx].Model) + setDeviceParamIfDefined(devs[idx], "macaddr", nics[idx].MACAddress) + setDeviceParamIfDefined(devs[idx], "bridge", nics[idx].Bridge) + setDeviceParamIfDefined(devs[idx], "tag", nics[idx].VLANTag) + } + return devs +} +func generateProxmoxDisks(disks []diskConfig) proxmox.QemuDevices { + devs := make(proxmox.QemuDevices) + for idx := range disks { + devs[idx] = make(proxmox.QemuDevice) + setDeviceParamIfDefined(devs[idx], "type", disks[idx].Type) + setDeviceParamIfDefined(devs[idx], "size", disks[idx].Size) + setDeviceParamIfDefined(devs[idx], "storage", disks[idx].StoragePool) + setDeviceParamIfDefined(devs[idx], "storage_type", disks[idx].StoragePoolType) + setDeviceParamIfDefined(devs[idx], "cache", disks[idx].CacheMode) + setDeviceParamIfDefined(devs[idx], "format", disks[idx].DiskFormat) + } + return devs +} + +func setDeviceParamIfDefined(dev proxmox.QemuDevice, key, value string) { + if value != "" { + dev[key] = value + } +} + +type startedVMCleaner interface { + StopVm(*proxmox.VmRef) (string, error) + DeleteVm(*proxmox.VmRef) (string, error) +} + +var _ startedVMCleaner = &proxmox.Client{} + +func (s *stepStartVM) Cleanup(state multistep.StateBag) { + vmRefUntyped, ok := state.GetOk("vmRef") + // If not ok, we probably errored out before creating the VM + if !ok { + return + } + vmRef := vmRefUntyped.(*proxmox.VmRef) + + // The vmRef will actually refer to the created template if everything + // finished successfully, so in that case we shouldn't cleanup + if _, ok := state.GetOk("success"); ok { + return + } + + client := state.Get("proxmoxClient").(startedVMCleaner) + ui := state.Get("ui").(packer.Ui) + + // Destroy the server we just created + ui.Say("Stopping VM") + _, err := client.StopVm(vmRef) + if err != nil { + ui.Error(fmt.Sprintf("Error stop VM. Please stop and delete it manually: %s", err)) + return + } + + ui.Say("Deleting VM") + _, err = client.DeleteVm(vmRef) + if err != nil { + ui.Error(fmt.Sprintf("Error deleting VM. Please delete it manually: %s", err)) + return + } +} diff --git a/builder/proxmox/step_start_vm_test.go b/builder/proxmox/step_start_vm_test.go new file mode 100644 index 000000000..cb19670f5 --- /dev/null +++ b/builder/proxmox/step_start_vm_test.go @@ -0,0 +1,108 @@ +package proxmox + +import ( + "fmt" + "testing" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +type startedVMCleanerMock struct { + stopVm func() (string, error) + deleteVm func() (string, error) +} + +func (m startedVMCleanerMock) StopVm(*proxmox.VmRef) (string, error) { + return m.stopVm() +} +func (m startedVMCleanerMock) DeleteVm(*proxmox.VmRef) (string, error) { + return m.deleteVm() +} + +var _ startedVMCleaner = &startedVMCleanerMock{} + +func TestCleanupStartVM(t *testing.T) { + cs := []struct { + name string + setVmRef bool + setSuccess bool + stopVMErr error + expectCallStopVM bool + deleteVMErr error + expectCallDeleteVM bool + }{ + { + name: "when vmRef state is not set, nothing should happen", + setVmRef: false, + expectCallStopVM: false, + }, + { + name: "when success state is set, nothing should happen", + setVmRef: true, + setSuccess: true, + expectCallStopVM: false, + }, + { + name: "when not successful, vm should be stopped and deleted", + setVmRef: true, + setSuccess: false, + expectCallStopVM: true, + expectCallDeleteVM: true, + }, + { + name: "if stopping fails, DeleteVm should not be called", + setVmRef: true, + setSuccess: false, + expectCallStopVM: true, + stopVMErr: fmt.Errorf("some error"), + expectCallDeleteVM: false, + }, + } + + for _, c := range cs { + t.Run(c.name, func(t *testing.T) { + var stopWasCalled, deleteWasCalled bool + + cleaner := startedVMCleanerMock{ + stopVm: func() (string, error) { + if !c.expectCallStopVM { + t.Error("Did not expect StopVm to be called") + } + + stopWasCalled = true + return "", c.stopVMErr + }, + deleteVm: func() (string, error) { + if !c.expectCallDeleteVM { + t.Error("Did not expect DeleteVm to be called") + } + + deleteWasCalled = true + return "", c.deleteVMErr + }, + } + + state := new(multistep.BasicStateBag) + state.Put("ui", packer.TestUi(t)) + state.Put("proxmoxClient", cleaner) + if c.setVmRef { + state.Put("vmRef", proxmox.NewVmRef(1)) + } + if c.setSuccess { + state.Put("success", "true") + } + + step := stepStartVM{} + step.Cleanup(state) + + if c.expectCallStopVM && !stopWasCalled { + t.Error("Expected StopVm to be called, but it wasn't") + } + if c.expectCallDeleteVM && !deleteWasCalled { + t.Error("Expected DeleteVm to be called, but it wasn't") + } + }) + } +} diff --git a/builder/proxmox/step_success.go b/builder/proxmox/step_success.go new file mode 100644 index 000000000..06de9f21d --- /dev/null +++ b/builder/proxmox/step_success.go @@ -0,0 +1,22 @@ +package proxmox + +import ( + "context" + + "github.com/hashicorp/packer/helper/multistep" +) + +// stepSuccess runs after the full build has succeeded. +// +// It sets the success state, which ensures cleanup does not remove the finished template +type stepSuccess struct{} + +func (s *stepSuccess) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + // We need to ensure stepStartVM.Cleanup doesn't delete the template (no + // difference between VMs and templates when deleting) + state.Put("success", true) + + return multistep.ActionContinue +} + +func (s *stepSuccess) Cleanup(state multistep.StateBag) {} diff --git a/builder/proxmox/step_type_boot_command.go b/builder/proxmox/step_type_boot_command.go new file mode 100644 index 000000000..89d50fb0d --- /dev/null +++ b/builder/proxmox/step_type_boot_command.go @@ -0,0 +1,119 @@ +package proxmox + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "time" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/common/bootcommand" + commonhelper "github.com/hashicorp/packer/helper/common" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template/interpolate" +) + +// stepTypeBootCommand takes the started VM, and sends the keystrokes required to start +// the installation process such that Packer can later reach the VM over SSH/WinRM +type stepTypeBootCommand struct { + bootcommand.BootConfig + Ctx interpolate.Context +} + +type bootCommandTemplateData struct { + HTTPIP string + HTTPPort uint +} + +type commandTyper interface { + MonitorCmd(*proxmox.VmRef, string) (map[string]interface{}, error) +} + +var _ commandTyper = &proxmox.Client{} + +func (s *stepTypeBootCommand) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + c := state.Get("config").(*Config) + client := state.Get("proxmoxClient").(commandTyper) + vmRef := state.Get("vmRef").(*proxmox.VmRef) + + if len(s.BootCommand) == 0 { + log.Println("No boot command given, skipping") + return multistep.ActionContinue + } + + if int64(s.BootWait) > 0 { + ui.Say(fmt.Sprintf("Waiting %s for boot", s.BootWait.String())) + select { + case <-time.After(s.BootWait): + break + case <-ctx.Done(): + return multistep.ActionHalt + } + } + + httpIP, err := hostIP() + if err != nil { + err := fmt.Errorf("Failed to determine host IP: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + common.SetHTTPIP(httpIP) + s.Ctx.Data = &bootCommandTemplateData{ + HTTPIP: httpIP, + HTTPPort: state.Get("http_port").(uint), + } + + ui.Say("Typing the boot command") + d := NewProxmoxDriver(client, vmRef, c.BootKeyInterval) + command, err := interpolate.Render(s.FlatBootCommand(), &s.Ctx) + if err != nil { + err := fmt.Errorf("Error preparing boot command: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + seq, err := bootcommand.GenerateExpressionSequence(command) + if err != nil { + err := fmt.Errorf("Error generating boot command: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + if err := seq.Do(ctx, d); err != nil { + err := fmt.Errorf("Error running boot command: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + return multistep.ActionContinue +} + +func (*stepTypeBootCommand) Cleanup(multistep.StateBag) { + commonhelper.RemoveSharedStateFile("ip", "") +} + +func hostIP() (string, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String(), nil + } + } + } + + return "", errors.New("No host IP found") +} diff --git a/builder/proxmox/step_type_boot_command_test.go b/builder/proxmox/step_type_boot_command_test.go new file mode 100644 index 000000000..c51b57d73 --- /dev/null +++ b/builder/proxmox/step_type_boot_command_test.go @@ -0,0 +1,128 @@ +package proxmox + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/common/bootcommand" + "github.com/hashicorp/packer/helper/multistep" + "github.com/hashicorp/packer/packer" +) + +type commandTyperMock struct { + monitorCmd func(*proxmox.VmRef, string) (map[string]interface{}, error) +} + +func (m commandTyperMock) MonitorCmd(ref *proxmox.VmRef, cmd string) (map[string]interface{}, error) { + return m.monitorCmd(ref, cmd) +} + +var _ commandTyper = commandTyperMock{} + +func TestTypeBootCommand(t *testing.T) { + cs := []struct { + name string + builderConfig *Config + expectCallMonitorCmd bool + monitorCmdErr error + monitorCmdRet map[string]interface{} + expectedKeysSent string + expectedAction multistep.StepAction + }{ + { + name: "simple boot command is typed", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{"hello"}}}, + expectCallMonitorCmd: true, + expectedKeysSent: "hello", + expectedAction: multistep.ActionContinue, + }, + { + name: "interpolated boot command", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{"helloworld"}}}, + expectCallMonitorCmd: true, + expectedKeysSent: "helloretworld", + expectedAction: multistep.ActionContinue, + }, + { + name: "merge multiple interpolated boot command", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{"Hello World 2.0", "foo!bar@baz"}}}, + expectCallMonitorCmd: true, + expectedKeysSent: "shift-hellospcshift-worldspc2dot0fooshift-1barshift-2baz", + expectedAction: multistep.ActionContinue, + }, + { + name: "without boot command monitorcmd should not be called", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{}}}, + expectCallMonitorCmd: false, + expectedAction: multistep.ActionContinue, + }, + { + name: "invalid boot command template function", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{"{{ foo }}"}}}, + expectCallMonitorCmd: false, + expectedAction: multistep.ActionHalt, + }, + { + // When proxmox (or Qemu, really) doesn't recognize the keycode we send, we get no error back, but + // a map {"data": "invalid parameter: X"}, where X is the keycode. + name: "invalid keys sent to proxmox", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{"x"}}}, + expectCallMonitorCmd: true, + monitorCmdRet: map[string]interface{}{"data": "invalid parameter: x"}, + expectedKeysSent: "x", + expectedAction: multistep.ActionHalt, + }, + { + name: "error in typing should return halt", + builderConfig: &Config{BootConfig: bootcommand.BootConfig{BootCommand: []string{"hello"}}}, + expectCallMonitorCmd: true, + monitorCmdErr: fmt.Errorf("some error"), + expectedKeysSent: "h", + expectedAction: multistep.ActionHalt, + }, + } + + for _, c := range cs { + t.Run(c.name, func(t *testing.T) { + accumulator := strings.Builder{} + typer := commandTyperMock{ + monitorCmd: func(ref *proxmox.VmRef, cmd string) (map[string]interface{}, error) { + if !c.expectCallMonitorCmd { + t.Error("Did not expect MonitorCmd to be called") + } + if !strings.HasPrefix(cmd, "sendkey ") { + t.Errorf("Expected all commands to be sendkey, got %s", cmd) + } + + accumulator.WriteString(strings.TrimPrefix(cmd, "sendkey ")) + + return c.monitorCmdRet, c.monitorCmdErr + }, + } + + state := new(multistep.BasicStateBag) + state.Put("ui", packer.TestUi(t)) + state.Put("config", c.builderConfig) + state.Put("http_port", uint(0)) + state.Put("vmRef", proxmox.NewVmRef(1)) + state.Put("proxmoxClient", typer) + + step := stepTypeBootCommand{ + c.builderConfig.BootConfig, + c.builderConfig.ctx, + } + action := step.Run(context.TODO(), state) + step.Cleanup(state) + + if action != c.expectedAction { + t.Errorf("Expected action to be %v, got %v", c.expectedAction, action) + } + if c.expectedKeysSent != accumulator.String() { + t.Errorf("Expected keystrokes to be %q, got %q", c.expectedKeysSent, accumulator.String()) + } + }) + } +} diff --git a/command/plugin.go b/command/plugin.go index 416605a6b..25b9fd27a 100644 --- a/command/plugin.go +++ b/command/plugin.go @@ -40,6 +40,7 @@ import ( parallelsisobuilder "github.com/hashicorp/packer/builder/parallels/iso" parallelspvmbuilder "github.com/hashicorp/packer/builder/parallels/pvm" profitbricksbuilder "github.com/hashicorp/packer/builder/profitbricks" + proxmoxbuilder "github.com/hashicorp/packer/builder/proxmox" qemubuilder "github.com/hashicorp/packer/builder/qemu" scalewaybuilder "github.com/hashicorp/packer/builder/scaleway" tencentcloudcvmbuilder "github.com/hashicorp/packer/builder/tencentcloud/cvm" @@ -118,6 +119,7 @@ var Builders = map[string]packer.Builder{ "parallels-iso": new(parallelsisobuilder.Builder), "parallels-pvm": new(parallelspvmbuilder.Builder), "profitbricks": new(profitbricksbuilder.Builder), + "proxmox": new(proxmoxbuilder.Builder), "qemu": new(qemubuilder.Builder), "scaleway": new(scalewaybuilder.Builder), "tencentcloud-cvm": new(tencentcloudcvmbuilder.Builder), diff --git a/go.sum b/go.sum index ab1c42279..d574a6bde 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/ChrisTrenkamp/goxpath v0.0.0-20170625215350-4fe035839290 h1:K9I21XUHN github.com/ChrisTrenkamp/goxpath v0.0.0-20170625215350-4fe035839290/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= github.com/NaverCloudPlatform/ncloud-sdk-go v0.0.0-20180110055012-c2e73f942591 h1:/P9HCl71+Eh6vDbKNyRu+rpIIR70UCZWNOGexVV3e6k= github.com/NaverCloudPlatform/ncloud-sdk-go v0.0.0-20180110055012-c2e73f942591/go.mod h1:EHGzQGbwozJBj/4qj3WGrTJ0FqjgOTOxLQ0VNWvPn08= +github.com/Telmate/proxmox-api-go v0.0.0-20190410200643-f08824d5082d h1:igrCnHheXb+lZ1bW9Ths8JZZIjh9D4Vi/49JqiHE+cI= +github.com/Telmate/proxmox-api-go v0.0.0-20190410200643-f08824d5082d/go.mod h1:OGWyIMJ87/k/GCz8CGiWB2HOXsOVDM6Lpe/nFPkC4IQ= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af h1:DBNMBMuMiWYu0b+8KMJuWmfCkcxl09JwdlqwDZZ6U14= github.com/abdullin/seq v0.0.0-20160510034733-d5467c17e7af/go.mod h1:5Jv4cbFiHJMsVxt52+i0Ha45fjshj6wxYr1r19tB9bw= github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20170113022742-e6dbea820a9f h1:jI4DIE5Vf4oRaHfthB0oRhU+yuYuoOTurDzwAlskP00= diff --git a/vendor/github.com/Telmate/proxmox-api-go/LICENSE b/vendor/github.com/Telmate/proxmox-api-go/LICENSE new file mode 100644 index 000000000..57395f1b0 --- /dev/null +++ b/vendor/github.com/Telmate/proxmox-api-go/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/vendor/github.com/Telmate/proxmox-api-go/proxmox/client.go b/vendor/github.com/Telmate/proxmox-api-go/proxmox/client.go new file mode 100644 index 000000000..7b97b9505 --- /dev/null +++ b/vendor/github.com/Telmate/proxmox-api-go/proxmox/client.go @@ -0,0 +1,597 @@ +package proxmox + +// inspired by https://github.com/Telmate/vagrant-proxmox/blob/master/lib/vagrant-proxmox/proxmox/connection.rb + +import ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// TaskTimeout - default async task call timeout in seconds +const TaskTimeout = 300 + +// TaskStatusCheckInterval - time between async checks in seconds +const TaskStatusCheckInterval = 2 + +const exitStatusSuccess = "OK" + +// Client - URL, user and password to specifc Proxmox node +type Client struct { + session *Session + ApiUrl string + Username string + Password string +} + +// VmRef - virtual machine ref parts +// map[type:qemu node:proxmox1-xx id:qemu/132 diskread:5.57424738e+08 disk:0 netin:5.9297450593e+10 mem:3.3235968e+09 uptime:1.4567097e+07 vmid:132 template:0 maxcpu:2 netout:6.053310416e+09 maxdisk:3.4359738368e+10 maxmem:8.592031744e+09 diskwrite:1.49663619584e+12 status:running cpu:0.00386980694947209 name:appt-app1-dev.xxx.xx] +type VmRef struct { + vmId int + node string + vmType string +} + +func (vmr *VmRef) SetNode(node string) { + vmr.node = node + return +} + +func (vmr *VmRef) SetVmType(vmType string) { + vmr.vmType = vmType + return +} + +func (vmr *VmRef) VmId() int { + return vmr.vmId +} + +func (vmr *VmRef) Node() string { + return vmr.node +} + +func NewVmRef(vmId int) (vmr *VmRef) { + vmr = &VmRef{vmId: vmId, node: "", vmType: ""} + return +} + +func NewClient(apiUrl string, hclient *http.Client, tls *tls.Config) (client *Client, err error) { + var sess *Session + sess, err = NewSession(apiUrl, hclient, tls) + if err == nil { + client = &Client{session: sess, ApiUrl: apiUrl} + } + return client, err +} + +func (c *Client) Login(username string, password string) (err error) { + c.Username = username + c.Password = password + return c.session.Login(username, password) +} + +func (c *Client) GetJsonRetryable(url string, data *map[string]interface{}, tries int) error { + var statErr error + for ii := 0; ii < tries; ii++ { + _, statErr = c.session.GetJSON(url, nil, nil, data) + if statErr == nil { + return nil + } + // if statErr != io.ErrUnexpectedEOF { // don't give up on ErrUnexpectedEOF + // return statErr + // } + time.Sleep(5 * time.Second) + } + return statErr +} + +func (c *Client) GetNodeList() (list map[string]interface{}, err error) { + err = c.GetJsonRetryable("/nodes", &list, 3) + return +} + +func (c *Client) GetVmList() (list map[string]interface{}, err error) { + err = c.GetJsonRetryable("/cluster/resources?type=vm", &list, 3) + return +} + +func (c *Client) CheckVmRef(vmr *VmRef) (err error) { + if vmr.node == "" || vmr.vmType == "" { + _, err = c.GetVmInfo(vmr) + } + return +} + +func (c *Client) GetVmInfo(vmr *VmRef) (vmInfo map[string]interface{}, err error) { + resp, err := c.GetVmList() + vms := resp["data"].([]interface{}) + for vmii := range vms { + vm := vms[vmii].(map[string]interface{}) + if int(vm["vmid"].(float64)) == vmr.vmId { + vmInfo = vm + vmr.node = vmInfo["node"].(string) + vmr.vmType = vmInfo["type"].(string) + return + } + } + return nil, errors.New(fmt.Sprintf("Vm '%d' not found", vmr.vmId)) +} + +func (c *Client) GetVmRefByName(vmName string) (vmr *VmRef, err error) { + resp, err := c.GetVmList() + vms := resp["data"].([]interface{}) + for vmii := range vms { + vm := vms[vmii].(map[string]interface{}) + if vm["name"] != nil && vm["name"].(string) == vmName { + vmr = NewVmRef(int(vm["vmid"].(float64))) + vmr.node = vm["node"].(string) + vmr.vmType = vm["type"].(string) + return + } + } + return nil, errors.New(fmt.Sprintf("Vm '%s' not found", vmName)) +} + +func (c *Client) GetVmState(vmr *VmRef) (vmState map[string]interface{}, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return nil, err + } + var data map[string]interface{} + url := fmt.Sprintf("/nodes/%s/%s/%d/status/current", vmr.node, vmr.vmType, vmr.vmId) + err = c.GetJsonRetryable(url, &data, 3) + if err != nil { + return nil, err + } + if data["data"] == nil { + return nil, errors.New("Vm STATE not readable") + } + vmState = data["data"].(map[string]interface{}) + return +} + +func (c *Client) GetVmConfig(vmr *VmRef) (vmConfig map[string]interface{}, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return nil, err + } + var data map[string]interface{} + url := fmt.Sprintf("/nodes/%s/%s/%d/config", vmr.node, vmr.vmType, vmr.vmId) + err = c.GetJsonRetryable(url, &data, 3) + if err != nil { + return nil, err + } + if data["data"] == nil { + return nil, errors.New("Vm CONFIG not readable") + } + vmConfig = data["data"].(map[string]interface{}) + return +} + +func (c *Client) GetVmSpiceProxy(vmr *VmRef) (vmSpiceProxy map[string]interface{}, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return nil, err + } + var data map[string]interface{} + url := fmt.Sprintf("/nodes/%s/%s/%d/spiceproxy", vmr.node, vmr.vmType, vmr.vmId) + _, err = c.session.PostJSON(url, nil, nil, nil, &data) + if err != nil { + return nil, err + } + if data["data"] == nil { + return nil, errors.New("Vm SpiceProxy not readable") + } + vmSpiceProxy = data["data"].(map[string]interface{}) + return +} + +type AgentNetworkInterface struct { + MACAddress string + IPAddresses []net.IP + Name string + Statistics map[string]int64 +} + +func (a *AgentNetworkInterface) UnmarshalJSON(b []byte) error { + var intermediate struct { + HardwareAddress string `json:"hardware-address"` + IPAddresses []struct { + IPAddress string `json:"ip-address"` + IPAddressType string `json:"ip-address-type"` + Prefix int `json:"prefix"` + } `json:"ip-addresses"` + Name string `json:"name"` + Statistics map[string]int64 `json:"statistics"` + } + err := json.Unmarshal(b, &intermediate) + if err != nil { + return err + } + + a.IPAddresses = make([]net.IP, len(intermediate.IPAddresses)) + for idx, ip := range intermediate.IPAddresses { + a.IPAddresses[idx] = net.ParseIP(ip.IPAddress) + if a.IPAddresses[idx] == nil { + return fmt.Errorf("Could not parse %s as IP", ip.IPAddress) + } + } + a.MACAddress = intermediate.HardwareAddress + a.Name = intermediate.Name + a.Statistics = intermediate.Statistics + return nil +} + +func (c *Client) GetVmAgentNetworkInterfaces(vmr *VmRef) ([]AgentNetworkInterface, error) { + var ifs []AgentNetworkInterface + err := c.doAgentGet(vmr, "network-get-interfaces", &ifs) + return ifs, err +} + +func (c *Client) doAgentGet(vmr *VmRef, command string, output interface{}) error { + err := c.CheckVmRef(vmr) + if err != nil { + return err + } + + url := fmt.Sprintf("/nodes/%s/%s/%d/agent/%s", vmr.node, vmr.vmType, vmr.vmId, command) + resp, err := c.session.Get(url, nil, nil) + if err != nil { + return err + } + + return TypedResponse(resp, output) +} + +func (c *Client) CreateTemplate(vmr *VmRef) error { + err := c.CheckVmRef(vmr) + if err != nil { + return err + } + + url := fmt.Sprintf("/nodes/%s/%s/%d/template", vmr.node, vmr.vmType, vmr.vmId) + _, err = c.session.Post(url, nil, nil, nil) + if err != nil { + return err + } + + return nil +} + +func (c *Client) MonitorCmd(vmr *VmRef, command string) (monitorRes map[string]interface{}, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return nil, err + } + reqbody := ParamsToBody(map[string]interface{}{"command": command}) + url := fmt.Sprintf("/nodes/%s/%s/%d/monitor", vmr.node, vmr.vmType, vmr.vmId) + resp, err := c.session.Post(url, nil, nil, &reqbody) + monitorRes, err = ResponseJSON(resp) + return +} + +// WaitForCompletion - poll the API for task completion +func (c *Client) WaitForCompletion(taskResponse map[string]interface{}) (waitExitStatus string, err error) { + if taskResponse["errors"] != nil { + errJSON, _ := json.MarshalIndent(taskResponse["errors"], "", " ") + return string(errJSON), errors.New("Error reponse") + } + if taskResponse["data"] == nil { + return "", nil + } + waited := 0 + taskUpid := taskResponse["data"].(string) + for waited < TaskTimeout { + exitStatus, statErr := c.GetTaskExitstatus(taskUpid) + if statErr != nil { + if statErr != io.ErrUnexpectedEOF { // don't give up on ErrUnexpectedEOF + return "", statErr + } + } + if exitStatus != nil { + waitExitStatus = exitStatus.(string) + return + } + time.Sleep(TaskStatusCheckInterval * time.Second) + waited = waited + TaskStatusCheckInterval + } + return "", errors.New("Wait timeout for:" + taskUpid) +} + +var rxTaskNode = regexp.MustCompile("UPID:(.*?):") + +func (c *Client) GetTaskExitstatus(taskUpid string) (exitStatus interface{}, err error) { + node := rxTaskNode.FindStringSubmatch(taskUpid)[1] + url := fmt.Sprintf("/nodes/%s/tasks/%s/status", node, taskUpid) + var data map[string]interface{} + _, err = c.session.GetJSON(url, nil, nil, &data) + if err == nil { + exitStatus = data["data"].(map[string]interface{})["exitstatus"] + } + if exitStatus != nil && exitStatus != exitStatusSuccess { + err = errors.New(exitStatus.(string)) + } + return +} + +func (c *Client) StatusChangeVm(vmr *VmRef, setStatus string) (exitStatus string, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return "", err + } + + url := fmt.Sprintf("/nodes/%s/%s/%d/status/%s", vmr.node, vmr.vmType, vmr.vmId, setStatus) + var taskResponse map[string]interface{} + for i := 0; i < 3; i++ { + _, err = c.session.PostJSON(url, nil, nil, nil, &taskResponse) + exitStatus, err = c.WaitForCompletion(taskResponse) + if exitStatus == "" { + time.Sleep(TaskStatusCheckInterval * time.Second) + } else { + return + } + } + return +} + +func (c *Client) StartVm(vmr *VmRef) (exitStatus string, err error) { + return c.StatusChangeVm(vmr, "start") +} + +func (c *Client) StopVm(vmr *VmRef) (exitStatus string, err error) { + return c.StatusChangeVm(vmr, "stop") +} + +func (c *Client) ShutdownVm(vmr *VmRef) (exitStatus string, err error) { + return c.StatusChangeVm(vmr, "shutdown") +} + +func (c *Client) ResetVm(vmr *VmRef) (exitStatus string, err error) { + return c.StatusChangeVm(vmr, "reset") +} + +func (c *Client) SuspendVm(vmr *VmRef) (exitStatus string, err error) { + return c.StatusChangeVm(vmr, "suspend") +} + +func (c *Client) ResumeVm(vmr *VmRef) (exitStatus string, err error) { + return c.StatusChangeVm(vmr, "resume") +} + +func (c *Client) DeleteVm(vmr *VmRef) (exitStatus string, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return "", err + } + url := fmt.Sprintf("/nodes/%s/%s/%d", vmr.node, vmr.vmType, vmr.vmId) + var taskResponse map[string]interface{} + _, err = c.session.RequestJSON("DELETE", url, nil, nil, nil, &taskResponse) + exitStatus, err = c.WaitForCompletion(taskResponse) + return +} + +func (c *Client) CreateQemuVm(node string, vmParams map[string]interface{}) (exitStatus string, err error) { + // Create VM disks first to ensure disks names. + createdDisks, createdDisksErr := c.createVMDisks(node, vmParams) + if createdDisksErr != nil { + return "", createdDisksErr + } + + // Then create the VM itself. + reqbody := ParamsToBody(vmParams) + url := fmt.Sprintf("/nodes/%s/qemu", node) + var resp *http.Response + resp, err = c.session.Post(url, nil, nil, &reqbody) + defer resp.Body.Close() + if err != nil { + b, _ := ioutil.ReadAll(resp.Body) + exitStatus = string(b) + return + } + + taskResponse, err := ResponseJSON(resp) + if err != nil { + return + } + exitStatus, err = c.WaitForCompletion(taskResponse) + // Delete VM disks if the VM didn't create. + if exitStatus != "OK" { + deleteDisksErr := c.DeleteVMDisks(node, createdDisks) + if deleteDisksErr != nil { + return "", deleteDisksErr + } + } + + return +} + +func (c *Client) CloneQemuVm(vmr *VmRef, vmParams map[string]interface{}) (exitStatus string, err error) { + reqbody := ParamsToBody(vmParams) + url := fmt.Sprintf("/nodes/%s/qemu/%d/clone", vmr.node, vmr.vmId) + resp, err := c.session.Post(url, nil, nil, &reqbody) + if err == nil { + taskResponse, err := ResponseJSON(resp) + if err != nil { + return "", err + } + exitStatus, err = c.WaitForCompletion(taskResponse) + } + return +} + +func (c *Client) RollbackQemuVm(vmr *VmRef, snapshot string) (exitStatus string, err error) { + err = c.CheckVmRef(vmr) + if err != nil { + return "", err + } + url := fmt.Sprintf("/nodes/%s/%s/%d/snapshot/%s/rollback", vmr.node, vmr.vmType, vmr.vmId, snapshot) + var taskResponse map[string]interface{} + _, err = c.session.PostJSON(url, nil, nil, nil, &taskResponse) + exitStatus, err = c.WaitForCompletion(taskResponse) + return +} + +// SetVmConfig - send config options +func (c *Client) SetVmConfig(vmr *VmRef, vmParams map[string]interface{}) (exitStatus interface{}, err error) { + reqbody := ParamsToBody(vmParams) + url := fmt.Sprintf("/nodes/%s/%s/%d/config", vmr.node, vmr.vmType, vmr.vmId) + resp, err := c.session.Post(url, nil, nil, &reqbody) + if err == nil { + taskResponse, err := ResponseJSON(resp) + if err != nil { + return nil, err + } + exitStatus, err = c.WaitForCompletion(taskResponse) + } + return +} + +func (c *Client) ResizeQemuDisk(vmr *VmRef, disk string, moreSizeGB int) (exitStatus interface{}, err error) { + // PUT + //disk:virtio0 + //size:+2G + if disk == "" { + disk = "virtio0" + } + size := fmt.Sprintf("+%dG", moreSizeGB) + reqbody := ParamsToBody(map[string]interface{}{"disk": disk, "size": size}) + url := fmt.Sprintf("/nodes/%s/%s/%d/resize", vmr.node, vmr.vmType, vmr.vmId) + resp, err := c.session.Put(url, nil, nil, &reqbody) + if err == nil { + taskResponse, err := ResponseJSON(resp) + if err != nil { + return nil, err + } + exitStatus, err = c.WaitForCompletion(taskResponse) + } + return +} + +// GetNextID - Get next free VMID +func (c *Client) GetNextID(currentID int) (nextID int, err error) { + var data map[string]interface{} + var url string + if currentID >= 100 { + url = fmt.Sprintf("/cluster/nextid?vmid=%d", currentID) + } else { + url = "/cluster/nextid" + } + _, err = c.session.GetJSON(url, nil, nil, &data) + if err == nil { + if data["errors"] != nil { + if currentID >= 100 { + return c.GetNextID(currentID + 1) + } else { + return -1, errors.New("error using /cluster/nextid") + } + } + nextID, err = strconv.Atoi(data["data"].(string)) + } + return +} + +// CreateVMDisk - Create single disk for VM on host node. +func (c *Client) CreateVMDisk( + nodeName string, + storageName string, + fullDiskName string, + diskParams map[string]interface{}, +) error { + + reqbody := ParamsToBody(diskParams) + url := fmt.Sprintf("/nodes/%s/storage/%s/content", nodeName, storageName) + resp, err := c.session.Post(url, nil, nil, &reqbody) + if err == nil { + taskResponse, err := ResponseJSON(resp) + if err != nil { + return err + } + if diskName, containsData := taskResponse["data"]; !containsData || diskName != fullDiskName { + return errors.New(fmt.Sprintf("Cannot create VM disk %s", fullDiskName)) + } + } else { + return err + } + + return nil +} + +// createVMDisks - Make disks parameters and create all VM disks on host node. +func (c *Client) createVMDisks( + node string, + vmParams map[string]interface{}, +) (disks []string, err error) { + var createdDisks []string + vmID := vmParams["vmid"].(int) + for deviceName, deviceConf := range vmParams { + rxStorageModels := `(ide|sata|scsi|virtio)\d+` + if matched, _ := regexp.MatchString(rxStorageModels, deviceName); matched { + deviceConfMap := ParseConf(deviceConf.(string), ",", "=") + // This if condition to differentiate between `disk` and `cdrom`. + if media, containsFile := deviceConfMap["media"]; containsFile && media == "disk" { + fullDiskName := deviceConfMap["file"].(string) + storageName, volumeName := getStorageAndVolumeName(fullDiskName, ":") + diskParams := map[string]interface{}{ + "vmid": vmID, + "filename": volumeName, + "size": deviceConfMap["size"], + } + err := c.CreateVMDisk(node, storageName, fullDiskName, diskParams) + if err != nil { + return createdDisks, err + } else { + createdDisks = append(createdDisks, fullDiskName) + } + } + } + } + + return createdDisks, nil +} + +// DeleteVMDisks - Delete VM disks from host node. +// By default the VM disks are deteled when the VM is deleted, +// so mainly this is used to delete the disks in case VM creation didn't complete. +func (c *Client) DeleteVMDisks( + node string, + disks []string, +) error { + for _, fullDiskName := range disks { + storageName, volumeName := getStorageAndVolumeName(fullDiskName, ":") + url := fmt.Sprintf("/nodes/%s/storage/%s/content/%s", node, storageName, volumeName) + _, err := c.session.Post(url, nil, nil, nil) + if err != nil { + return err + } + } + + return nil +} + +// getStorageAndVolumeName - Extract disk storage and disk volume, since disk name is saved +// in Proxmox with its storage. +func getStorageAndVolumeName( + fullDiskName string, + separator string, +) (storageName string, diskName string) { + storageAndVolumeName := strings.Split(fullDiskName, separator) + storageName, volumeName := storageAndVolumeName[0], storageAndVolumeName[1] + + // when disk type is dir, volumeName is `file=local:100/vm-100-disk-0.raw` + re := regexp.MustCompile(`\d+/(?P\S+.\S+)`) + match := re.FindStringSubmatch(volumeName) + if len(match) == 2 { + volumeName = match[1] + } + + return storageName, volumeName +} diff --git a/vendor/github.com/Telmate/proxmox-api-go/proxmox/config_qemu.go b/vendor/github.com/Telmate/proxmox-api-go/proxmox/config_qemu.go new file mode 100644 index 000000000..7c0ffd897 --- /dev/null +++ b/vendor/github.com/Telmate/proxmox-api-go/proxmox/config_qemu.go @@ -0,0 +1,706 @@ +package proxmox + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/rand" + "net" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +type ( + QemuDevices map[int]map[string]interface{} + QemuDevice map[string]interface{} + QemuDeviceParam []string +) + +// ConfigQemu - Proxmox API QEMU options +type ConfigQemu struct { + Name string `json:"name"` + Description string `json:"desc"` + Onboot bool `json:"onboot"` + Agent string `json:"agent"` + Memory int `json:"memory"` + QemuOs string `json:"os"` + QemuCores int `json:"cores"` + QemuSockets int `json:"sockets"` + QemuIso string `json:"iso"` + FullClone *int `json:"fullclone"` + QemuDisks QemuDevices `json:"disk"` + QemuNetworks QemuDevices `json:"network"` + + // Deprecated single disk. + DiskSize float64 `json:"diskGB"` + Storage string `json:"storage"` + StorageType string `json:"storageType"` // virtio|scsi (cloud-init defaults to scsi) + + // Deprecated single nic. + QemuNicModel string `json:"nic"` + QemuBrige string `json:"bridge"` + QemuVlanTag int `json:"vlan"` + QemuMacAddr string `json:"mac"` + + // cloud-init options + CIuser string `json:"ciuser"` + CIpassword string `json:"cipassword"` + + Searchdomain string `json:"searchdomain"` + Nameserver string `json:"nameserver"` + Sshkeys string `json:"sshkeys"` + + // arrays are hard, support 2 interfaces for now + Ipconfig0 string `json:"ipconfig0"` + Ipconfig1 string `json:"ipconfig1"` +} + +// CreateVm - Tell Proxmox API to make the VM +func (config ConfigQemu) CreateVm(vmr *VmRef, client *Client) (err error) { + if config.HasCloudInit() { + return errors.New("Cloud-init parameters only supported on clones or updates") + } + vmr.SetVmType("qemu") + + params := map[string]interface{}{ + "vmid": vmr.vmId, + "name": config.Name, + "onboot": config.Onboot, + "agent": config.Agent, + "ide2": config.QemuIso + ",media=cdrom", + "ostype": config.QemuOs, + "sockets": config.QemuSockets, + "cores": config.QemuCores, + "cpu": "host", + "memory": config.Memory, + "description": config.Description, + } + + // Create disks config. + config.CreateQemuDisksParams(vmr.vmId, params, false) + + // Create networks config. + config.CreateQemuNetworksParams(vmr.vmId, params) + + exitStatus, err := client.CreateQemuVm(vmr.node, params) + if err != nil { + return fmt.Errorf("Error creating VM: %v, error status: %s (params: %v)", err, exitStatus, params) + } + return +} + +// HasCloudInit - are there cloud-init options? +func (config ConfigQemu) HasCloudInit() bool { + return config.CIuser != "" || + config.CIpassword != "" || + config.Searchdomain != "" || + config.Nameserver != "" || + config.Sshkeys != "" || + config.Ipconfig0 != "" || + config.Ipconfig1 != "" +} + +/* + +CloneVm +Example: Request + +nodes/proxmox1-xx/qemu/1012/clone + +newid:145 +name:tf-clone1 +target:proxmox1-xx +full:1 +storage:xxx + +*/ +func (config ConfigQemu) CloneVm(sourceVmr *VmRef, vmr *VmRef, client *Client) (err error) { + vmr.SetVmType("qemu") + fullclone := "1" + if config.FullClone != nil { + fullclone = strconv.Itoa(*config.FullClone) + } + storage := config.Storage + if disk0Storage, ok := config.QemuDisks[0]["storage"].(string); ok && len(disk0Storage) > 0 { + storage = disk0Storage + } + params := map[string]interface{}{ + "newid": vmr.vmId, + "target": vmr.node, + "name": config.Name, + "storage": storage, + "full": fullclone, + } + _, err = client.CloneQemuVm(sourceVmr, params) + if err != nil { + return + } + return config.UpdateConfig(vmr, client) +} + +func (config ConfigQemu) UpdateConfig(vmr *VmRef, client *Client) (err error) { + configParams := map[string]interface{}{ + "name": config.Name, + "description": config.Description, + "onboot": config.Onboot, + "agent": config.Agent, + "sockets": config.QemuSockets, + "cores": config.QemuCores, + "memory": config.Memory, + } + + // Create disks config. + config.CreateQemuDisksParams(vmr.vmId, configParams, true) + + // Create networks config. + config.CreateQemuNetworksParams(vmr.vmId, configParams) + + // cloud-init options + if config.CIuser != "" { + configParams["ciuser"] = config.CIuser + } + if config.CIpassword != "" { + configParams["cipassword"] = config.CIpassword + } + if config.Searchdomain != "" { + configParams["searchdomain"] = config.Searchdomain + } + if config.Nameserver != "" { + configParams["nameserver"] = config.Nameserver + } + if config.Sshkeys != "" { + sshkeyEnc := url.PathEscape(config.Sshkeys + "\n") + sshkeyEnc = strings.Replace(sshkeyEnc, "+", "%2B", -1) + sshkeyEnc = strings.Replace(sshkeyEnc, "@", "%40", -1) + sshkeyEnc = strings.Replace(sshkeyEnc, "=", "%3D", -1) + configParams["sshkeys"] = sshkeyEnc + } + if config.Ipconfig0 != "" { + configParams["ipconfig0"] = config.Ipconfig0 + } + if config.Ipconfig1 != "" { + configParams["ipconfig1"] = config.Ipconfig1 + } + _, err = client.SetVmConfig(vmr, configParams) + return err +} + +func NewConfigQemuFromJson(io io.Reader) (config *ConfigQemu, err error) { + config = &ConfigQemu{QemuVlanTag: -1} + err = json.NewDecoder(io).Decode(config) + if err != nil { + log.Fatal(err) + return nil, err + } + log.Println(config) + return +} + +var ( + rxIso = regexp.MustCompile(`(.*?),media`) + rxDeviceID = regexp.MustCompile(`\d+`) + rxDiskName = regexp.MustCompile(`(virtio|scsi)\d+`) + rxDiskType = regexp.MustCompile(`\D+`) + rxNicName = regexp.MustCompile(`net\d+`) +) + +func NewConfigQemuFromApi(vmr *VmRef, client *Client) (config *ConfigQemu, err error) { + var vmConfig map[string]interface{} + for ii := 0; ii < 3; ii++ { + vmConfig, err = client.GetVmConfig(vmr) + if err != nil { + log.Fatal(err) + return nil, err + } + // this can happen: + // {"data":{"lock":"clone","digest":"eb54fb9d9f120ba0c3bdf694f73b10002c375c38","description":" qmclone temporary file\n"}}) + if vmConfig["lock"] == nil { + break + } else { + time.Sleep(8 * time.Second) + } + } + + if vmConfig["lock"] != nil { + return nil, errors.New("vm locked, could not obtain config") + } + + // vmConfig Sample: map[ cpu:host + // net0:virtio=62:DF:XX:XX:XX:XX,bridge=vmbr0 + // ide2:local:iso/xxx-xx.iso,media=cdrom memory:2048 + // smbios1:uuid=8b3bf833-aad8-4545-xxx-xxxxxxx digest:aa6ce5xxxxx1b9ce33e4aaeff564d4 sockets:1 + // name:terraform-ubuntu1404-template bootdisk:virtio0 + // virtio0:ProxmoxxxxISCSI:vm-1014-disk-2,size=4G + // description:Base image + // cores:2 ostype:l26 + + name := "" + if _, isSet := vmConfig["name"]; isSet { + name = vmConfig["name"].(string) + } + description := "" + if _, isSet := vmConfig["description"]; isSet { + description = vmConfig["description"].(string) + } + onboot := true + if _, isSet := vmConfig["onboot"]; isSet { + onboot = Itob(int(vmConfig["onboot"].(float64))) + } + agent := "1" + if _, isSet := vmConfig["agent"]; isSet { + agent = vmConfig["agent"].(string) + } + ostype := "other" + if _, isSet := vmConfig["ostype"]; isSet { + ostype = vmConfig["ostype"].(string) + } + memory := 0.0 + if _, isSet := vmConfig["memory"]; isSet { + memory = vmConfig["memory"].(float64) + } + cores := 1.0 + if _, isSet := vmConfig["cores"]; isSet { + cores = vmConfig["cores"].(float64) + } + sockets := 1.0 + if _, isSet := vmConfig["sockets"]; isSet { + sockets = vmConfig["sockets"].(float64) + } + config = &ConfigQemu{ + Name: name, + Description: strings.TrimSpace(description), + Onboot: onboot, + Agent: agent, + QemuOs: ostype, + Memory: int(memory), + QemuCores: int(cores), + QemuSockets: int(sockets), + QemuVlanTag: -1, + QemuDisks: QemuDevices{}, + QemuNetworks: QemuDevices{}, + } + + if vmConfig["ide2"] != nil { + isoMatch := rxIso.FindStringSubmatch(vmConfig["ide2"].(string)) + config.QemuIso = isoMatch[1] + } + + if _, isSet := vmConfig["ciuser"]; isSet { + config.CIuser = vmConfig["ciuser"].(string) + } + if _, isSet := vmConfig["cipassword"]; isSet { + config.CIpassword = vmConfig["cipassword"].(string) + } + if _, isSet := vmConfig["searchdomain"]; isSet { + config.Searchdomain = vmConfig["searchdomain"].(string) + } + if _, isSet := vmConfig["sshkeys"]; isSet { + config.Sshkeys, _ = url.PathUnescape(vmConfig["sshkeys"].(string)) + } + if _, isSet := vmConfig["ipconfig0"]; isSet { + config.Ipconfig0 = vmConfig["ipconfig0"].(string) + } + if _, isSet := vmConfig["ipconfig1"]; isSet { + config.Ipconfig1 = vmConfig["ipconfig1"].(string) + } + + // Add disks. + diskNames := []string{} + + for k, _ := range vmConfig { + if diskName := rxDiskName.FindStringSubmatch(k); len(diskName) > 0 { + diskNames = append(diskNames, diskName[0]) + } + } + + for _, diskName := range diskNames { + diskConfStr := vmConfig[diskName] + diskConfList := strings.Split(diskConfStr.(string), ",") + + // + id := rxDeviceID.FindStringSubmatch(diskName) + diskID, _ := strconv.Atoi(id[0]) + diskType := rxDiskType.FindStringSubmatch(diskName)[0] + storageName, fileName := ParseSubConf(diskConfList[0], ":") + + // + diskConfMap := QemuDevice{ + "type": diskType, + "storage": storageName, + "file": fileName, + } + + // Add rest of device config. + diskConfMap.readDeviceConfig(diskConfList[1:]) + + // And device config to disks map. + if len(diskConfMap) > 0 { + config.QemuDisks[diskID] = diskConfMap + } + } + + // Add networks. + nicNameRe := regexp.MustCompile(`net\d+`) + nicNames := []string{} + + for k, _ := range vmConfig { + if nicName := nicNameRe.FindStringSubmatch(k); len(nicName) > 0 { + nicNames = append(nicNames, nicName[0]) + } + } + + for _, nicName := range nicNames { + nicConfStr := vmConfig[nicName] + nicConfList := strings.Split(nicConfStr.(string), ",") + + // + id := rxDeviceID.FindStringSubmatch(nicName) + nicID, _ := strconv.Atoi(id[0]) + model, macaddr := ParseSubConf(nicConfList[0], "=") + + // Add model and MAC address. + nicConfMap := QemuDevice{ + "model": model, + "macaddr": macaddr, + } + + // Add rest of device config. + nicConfMap.readDeviceConfig(nicConfList[1:]) + + // And device config to networks. + if len(nicConfMap) > 0 { + config.QemuNetworks[nicID] = nicConfMap + } + } + + return +} + +// Useful waiting for ISO install to complete +func WaitForShutdown(vmr *VmRef, client *Client) (err error) { + for ii := 0; ii < 100; ii++ { + vmState, err := client.GetVmState(vmr) + if err != nil { + log.Print("Wait error:") + log.Println(err) + } else if vmState["status"] == "stopped" { + return nil + } + time.Sleep(5 * time.Second) + } + return errors.New("Not shutdown within wait time") +} + +// This is because proxmox create/config API won't let us make usernet devices +func SshForwardUsernet(vmr *VmRef, client *Client) (sshPort string, err error) { + vmState, err := client.GetVmState(vmr) + if err != nil { + return "", err + } + if vmState["status"] == "stopped" { + return "", errors.New("VM must be running first") + } + sshPort = strconv.Itoa(vmr.VmId() + 22000) + _, err = client.MonitorCmd(vmr, "netdev_add user,id=net1,hostfwd=tcp::"+sshPort+"-:22") + if err != nil { + return "", err + } + _, err = client.MonitorCmd(vmr, "device_add virtio-net-pci,id=net1,netdev=net1,addr=0x13") + if err != nil { + return "", err + } + return +} + +// device_del net1 +// netdev_del net1 +func RemoveSshForwardUsernet(vmr *VmRef, client *Client) (err error) { + vmState, err := client.GetVmState(vmr) + if err != nil { + return err + } + if vmState["status"] == "stopped" { + return errors.New("VM must be running first") + } + _, err = client.MonitorCmd(vmr, "device_del net1") + if err != nil { + return err + } + _, err = client.MonitorCmd(vmr, "netdev_del net1") + if err != nil { + return err + } + return nil +} + +func MaxVmId(client *Client) (max int, err error) { + resp, err := client.GetVmList() + vms := resp["data"].([]interface{}) + max = 0 + for vmii := range vms { + vm := vms[vmii].(map[string]interface{}) + vmid := int(vm["vmid"].(float64)) + if vmid > max { + max = vmid + } + } + return +} + +func SendKeysString(vmr *VmRef, client *Client, keys string) (err error) { + vmState, err := client.GetVmState(vmr) + if err != nil { + return err + } + if vmState["status"] == "stopped" { + return errors.New("VM must be running first") + } + for _, r := range keys { + c := string(r) + lower := strings.ToLower(c) + if c != lower { + c = "shift-" + lower + } else { + switch c { + case "!": + c = "shift-1" + case "@": + c = "shift-2" + case "#": + c = "shift-3" + case "$": + c = "shift-4" + case "%%": + c = "shift-5" + case "^": + c = "shift-6" + case "&": + c = "shift-7" + case "*": + c = "shift-8" + case "(": + c = "shift-9" + case ")": + c = "shift-0" + case "_": + c = "shift-minus" + case "+": + c = "shift-equal" + case " ": + c = "spc" + case "/": + c = "slash" + case "\\": + c = "backslash" + case ",": + c = "comma" + case "-": + c = "minus" + case "=": + c = "equal" + case ".": + c = "dot" + case "?": + c = "shift-slash" + } + } + _, err = client.MonitorCmd(vmr, "sendkey "+c) + if err != nil { + return err + } + time.Sleep(100) + } + return nil +} + +// Create parameters for each Nic device. +func (c ConfigQemu) CreateQemuNetworksParams(vmID int, params map[string]interface{}) error { + + // For backward compatibility. + if len(c.QemuNetworks) == 0 && len(c.QemuNicModel) > 0 { + deprecatedStyleMap := QemuDevice{ + "model": c.QemuNicModel, + "bridge": c.QemuBrige, + "macaddr": c.QemuMacAddr, + } + + if c.QemuVlanTag > 0 { + deprecatedStyleMap["tag"] = strconv.Itoa(c.QemuVlanTag) + } + + c.QemuNetworks[0] = deprecatedStyleMap + } + + // For new style with multi net device. + for nicID, nicConfMap := range c.QemuNetworks { + + nicConfParam := QemuDeviceParam{} + + // Set Nic name. + qemuNicName := "net" + strconv.Itoa(nicID) + + // Set Mac address. + if nicConfMap["macaddr"] == nil || nicConfMap["macaddr"].(string) == "" { + // Generate Mac based on VmID and NicID so it will be the same always. + macaddr := make(net.HardwareAddr, 6) + rand.Seed(time.Now().UnixNano()) + rand.Read(macaddr) + macaddr[0] = (macaddr[0] | 2) & 0xfe // fix from github issue #18 + macAddrUppr := strings.ToUpper(fmt.Sprintf("%v", macaddr)) + // use model=mac format for older proxmox compatability + macAddr := fmt.Sprintf("%v=%v", nicConfMap["model"], macAddrUppr) + + // Add Mac to source map so it will be returned. (useful for some use case like Terraform) + nicConfMap["macaddr"] = macAddrUppr + // and also add it to the parameters which will be sent to Proxmox API. + nicConfParam = append(nicConfParam, macAddr) + } else { + macAddr := fmt.Sprintf("%v=%v", nicConfMap["model"], nicConfMap["macaddr"].(string)) + nicConfParam = append(nicConfParam, macAddr) + } + + // Set bridge if not nat. + if nicConfMap["bridge"].(string) != "nat" { + bridge := fmt.Sprintf("bridge=%v", nicConfMap["bridge"]) + nicConfParam = append(nicConfParam, bridge) + } + + // Keys that are not used as real/direct conf. + ignoredKeys := []string{"id", "bridge", "macaddr", "model"} + + // Rest of config. + nicConfParam = nicConfParam.createDeviceParam(nicConfMap, ignoredKeys) + + // Add nic to Qemu prams. + params[qemuNicName] = strings.Join(nicConfParam, ",") + } + + return nil +} + +// Create parameters for each disk. +func (c ConfigQemu) CreateQemuDisksParams( + vmID int, + params map[string]interface{}, + cloned bool, +) error { + + // For backward compatibility. + if len(c.QemuDisks) == 0 && len(c.Storage) > 0 { + + dType := c.StorageType + if dType == "" { + if c.HasCloudInit() { + dType = "scsi" + } else { + dType = "virtio" + } + } + deprecatedStyleMap := QemuDevice{ + "type": dType, + "storage": c.Storage, + "size": c.DiskSize, + "storage_type": "lvm", // default old style + "cache": "none", // default old value + } + + c.QemuDisks[0] = deprecatedStyleMap + } + + // For new style with multi disk device. + for diskID, diskConfMap := range c.QemuDisks { + + // skip the first disk for clones (may not always be right, but a template probably has at least 1 disk) + if diskID == 0 && cloned { + continue + } + diskConfParam := QemuDeviceParam{ + "media=disk", + } + + // Device name. + deviceType := diskConfMap["type"].(string) + qemuDiskName := deviceType + strconv.Itoa(diskID) + + // Set disk storage. + // Disk size. + diskSizeGB := fmt.Sprintf("size=%v", diskConfMap["size"]) + diskConfParam = append(diskConfParam, diskSizeGB) + + // Disk name. + var diskFile string + // Currently ZFS local, LVM, and Directory are considered. + // Other formats are not verified, but could be added if they're needed. + rxStorageTypes := `(zfspool|lvm)` + storageType := diskConfMap["storage_type"].(string) + if matched, _ := regexp.MatchString(rxStorageTypes, storageType); matched { + diskFile = fmt.Sprintf("file=%v:vm-%v-disk-%v", diskConfMap["storage"], vmID, diskID) + } else { + diskFile = fmt.Sprintf("file=%v:%v/vm-%v-disk-%v.%v", diskConfMap["storage"], vmID, vmID, diskID, diskConfMap["format"]) + } + diskConfParam = append(diskConfParam, diskFile) + + // Set cache if not none (default). + if diskConfMap["cache"].(string) != "none" { + diskCache := fmt.Sprintf("cache=%v", diskConfMap["cache"]) + diskConfParam = append(diskConfParam, diskCache) + } + + // Keys that are not used as real/direct conf. + ignoredKeys := []string{"id", "type", "storage", "storage_type", "size", "cache"} + + // Rest of config. + diskConfParam = diskConfParam.createDeviceParam(diskConfMap, ignoredKeys) + + // Add back to Qemu prams. + params[qemuDiskName] = strings.Join(diskConfParam, ",") + } + + return nil +} + +// Create the parameters for each device that will be sent to Proxmox API. +func (p QemuDeviceParam) createDeviceParam( + deviceConfMap QemuDevice, + ignoredKeys []string, +) QemuDeviceParam { + + for key, value := range deviceConfMap { + if ignored := inArray(ignoredKeys, key); !ignored { + var confValue interface{} + if bValue, ok := value.(bool); ok && bValue { + confValue = "1" + } else if sValue, ok := value.(string); ok && len(sValue) > 0 { + confValue = sValue + } else if iValue, ok := value.(int); ok && iValue > 0 { + confValue = iValue + } + if confValue != nil { + deviceConf := fmt.Sprintf("%v=%v", key, confValue) + p = append(p, deviceConf) + } + } + } + + return p +} + +// readDeviceConfig - get standard sub-conf strings where `key=value` and update conf map. +func (confMap QemuDevice) readDeviceConfig(confList []string) error { + // Add device config. + for _, conf := range confList { + key, value := ParseSubConf(conf, "=") + confMap[key] = value + } + return nil +} + +func (c ConfigQemu) String() string { + jsConf, _ := json.Marshal(c) + return string(jsConf) +} diff --git a/vendor/github.com/Telmate/proxmox-api-go/proxmox/session.go b/vendor/github.com/Telmate/proxmox-api-go/proxmox/session.go new file mode 100644 index 000000000..72f72eb6a --- /dev/null +++ b/vendor/github.com/Telmate/proxmox-api-go/proxmox/session.go @@ -0,0 +1,319 @@ +package proxmox + +// inspired by https://github.com/openstack/golang-client/blob/master/openstack/session.go + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "net/http/httputil" + "net/url" +) + +var Debug = new(bool) + +type Response struct { + Resp *http.Response + Body []byte +} + +type Session struct { + httpClient *http.Client + ApiUrl string + AuthTicket string + CsrfToken string + Headers http.Header +} + +func NewSession(apiUrl string, hclient *http.Client, tls *tls.Config) (session *Session, err error) { + if hclient == nil { + // Only build a transport if we're also building the client + tr := &http.Transport{ + TLSClientConfig: tls, + DisableCompression: true, + } + hclient = &http.Client{Transport: tr} + } + session = &Session{ + httpClient: hclient, + ApiUrl: apiUrl, + AuthTicket: "", + CsrfToken: "", + Headers: http.Header{}, + } + return session, nil +} + +func ParamsToBody(params map[string]interface{}) (body []byte) { + vals := url.Values{} + for k, intrV := range params { + var v string + switch intrV.(type) { + // Convert true/false bool to 1/0 string where Proxmox API can understand it. + case bool: + if intrV.(bool) { + v = "1" + } else { + v = "0" + } + default: + v = fmt.Sprintf("%v", intrV) + } + vals.Set(k, v) + } + body = bytes.NewBufferString(vals.Encode()).Bytes() + return +} + +func decodeResponse(resp *http.Response, v interface{}) error { + if resp.Body == nil { + return nil + } + rbody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("error reading response body: %s", err) + } + if err = json.Unmarshal(rbody, &v); err != nil { + return err + } + return nil +} + +func ResponseJSON(resp *http.Response) (jbody map[string]interface{}, err error) { + err = decodeResponse(resp, &jbody) + return jbody, err +} + +func TypedResponse(resp *http.Response, v interface{}) error { + var intermediate struct { + Data struct { + Result json.RawMessage `json:"result"` + } `json:"data"` + } + err := decodeResponse(resp, &intermediate) + if err != nil { + return fmt.Errorf("error reading response envelope: %v", err) + } + if err = json.Unmarshal(intermediate.Data.Result, v); err != nil { + return fmt.Errorf("error unmarshalling result %v", err) + } + return nil +} + +func (s *Session) Login(username string, password string) (err error) { + reqbody := ParamsToBody(map[string]interface{}{"username": username, "password": password}) + olddebug := *Debug + *Debug = false // don't share passwords in debug log + resp, err := s.Post("/access/ticket", nil, nil, &reqbody) + *Debug = olddebug + if err != nil { + return err + } + if resp == nil { + return errors.New("Login error reading response") + } + dr, _ := httputil.DumpResponse(resp, true) + jbody, err := ResponseJSON(resp) + if err != nil { + return err + } + if jbody == nil || jbody["data"] == nil { + return fmt.Errorf("Invalid login response:\n-----\n%s\n-----", dr) + } + dat := jbody["data"].(map[string]interface{}) + s.AuthTicket = dat["ticket"].(string) + s.CsrfToken = dat["CSRFPreventionToken"].(string) + return nil +} + +func (s *Session) NewRequest(method, url string, headers *http.Header, body io.Reader) (req *http.Request, err error) { + req, err = http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + if headers != nil { + req.Header = *headers + } + if s.AuthTicket != "" { + req.Header.Add("Cookie", "PVEAuthCookie="+s.AuthTicket) + req.Header.Add("CSRFPreventionToken", s.CsrfToken) + } + return +} + +func (s *Session) Do(req *http.Request) (*http.Response, error) { + // Add session headers + for k := range s.Headers { + req.Header.Set(k, s.Headers.Get(k)) + } + + if *Debug { + d, _ := httputil.DumpRequestOut(req, true) + log.Printf(">>>>>>>>>> REQUEST:\n", string(d)) + } + + resp, err := s.httpClient.Do(req) + + if err != nil { + return nil, err + } + + if *Debug { + dr, _ := httputil.DumpResponse(resp, true) + log.Printf("<<<<<<<<<< RESULT:\n", string(dr)) + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return resp, errors.New(resp.Status) + } + + return resp, nil +} + +// Perform a simple get to an endpoint +func (s *Session) Request( + method string, + url string, + params *url.Values, + headers *http.Header, + body *[]byte, +) (resp *http.Response, err error) { + // add params to url here + url = s.ApiUrl + url + if params != nil { + url = url + "?" + params.Encode() + } + + // Get the body if one is present + var buf io.Reader + if body != nil { + buf = bytes.NewReader(*body) + } + + req, err := s.NewRequest(method, url, headers, buf) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + + return s.Do(req) +} + +// Perform a simple get to an endpoint and unmarshall returned JSON +func (s *Session) RequestJSON( + method string, + url string, + params *url.Values, + headers *http.Header, + body interface{}, + responseContainer interface{}, +) (resp *http.Response, err error) { + var bodyjson []byte + if body != nil { + bodyjson, err = json.Marshal(body) + if err != nil { + return nil, err + } + } + + // if headers == nil { + // headers = &http.Header{} + // headers.Add("Content-Type", "application/json") + // } + + resp, err = s.Request(method, url, params, headers, &bodyjson) + if err != nil { + return resp, err + } + + // err = util.CheckHTTPResponseStatusCode(resp) + // if err != nil { + // return nil, err + // } + + rbody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return resp, errors.New("error reading response body") + } + if err = json.Unmarshal(rbody, &responseContainer); err != nil { + return resp, err + } + + return resp, nil +} + +func (s *Session) Delete( + url string, + params *url.Values, + headers *http.Header, +) (resp *http.Response, err error) { + return s.Request("DELETE", url, params, headers, nil) +} + +func (s *Session) Get( + url string, + params *url.Values, + headers *http.Header, +) (resp *http.Response, err error) { + return s.Request("GET", url, params, headers, nil) +} + +func (s *Session) GetJSON( + url string, + params *url.Values, + headers *http.Header, + responseContainer interface{}, +) (resp *http.Response, err error) { + return s.RequestJSON("GET", url, params, headers, nil, responseContainer) +} + +func (s *Session) Head( + url string, + params *url.Values, + headers *http.Header, +) (resp *http.Response, err error) { + return s.Request("HEAD", url, params, headers, nil) +} + +func (s *Session) Post( + url string, + params *url.Values, + headers *http.Header, + body *[]byte, +) (resp *http.Response, err error) { + if headers == nil { + headers = &http.Header{} + headers.Add("Content-Type", "application/x-www-form-urlencoded") + } + return s.Request("POST", url, params, headers, body) +} + +func (s *Session) PostJSON( + url string, + params *url.Values, + headers *http.Header, + body interface{}, + responseContainer interface{}, +) (resp *http.Response, err error) { + return s.RequestJSON("POST", url, params, headers, body, responseContainer) +} + +func (s *Session) Put( + url string, + params *url.Values, + headers *http.Header, + body *[]byte, +) (resp *http.Response, err error) { + if headers == nil { + headers = &http.Header{} + headers.Add("Content-Type", "application/x-www-form-urlencoded") + } + return s.Request("PUT", url, params, headers, body) +} diff --git a/vendor/github.com/Telmate/proxmox-api-go/proxmox/util.go b/vendor/github.com/Telmate/proxmox-api-go/proxmox/util.go new file mode 100644 index 000000000..5841c421f --- /dev/null +++ b/vendor/github.com/Telmate/proxmox-api-go/proxmox/util.go @@ -0,0 +1,62 @@ +package proxmox + +import ( + "strconv" + "strings" +) + +func inArray(arr []string, str string) bool { + for _, elem := range arr { + if elem == str { + return true + } + } + + return false +} + +func Itob(i int) bool { + if i == 1 { + return true + } + return false +} + +// ParseSubConf - Parse standard sub-conf strings `key=value`. +func ParseSubConf( + element string, + separator string, +) (key string, value interface{}) { + if strings.Contains(element, separator) { + conf := strings.Split(element, separator) + key, value := conf[0], conf[1] + var interValue interface{} + + // Make sure to add value in right type, + // because all subconfig are returned as strings from Proxmox API. + if iValue, err := strconv.ParseInt(value, 10, 64); err == nil { + interValue = int(iValue) + } else if bValue, err := strconv.ParseBool(value); err == nil { + interValue = bValue + } else { + interValue = value + } + return key, interValue + } + return +} + +// ParseConf - Parse standard device conf string `key1=val1,key2=val2`. +func ParseConf( + kvString string, + confSeparator string, + subConfSeparator string, +) QemuDevice { + var confMap = QemuDevice{} + confList := strings.Split(kvString, confSeparator) + for _, item := range confList { + key, value := ParseSubConf(item, subConfSeparator) + confMap[key] = value + } + return confMap +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 4e6e76bba..14a2c6cf8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -45,6 +45,8 @@ github.com/NaverCloudPlatform/ncloud-sdk-go/sdk github.com/NaverCloudPlatform/ncloud-sdk-go/common github.com/NaverCloudPlatform/ncloud-sdk-go/request github.com/NaverCloudPlatform/ncloud-sdk-go/oauth +# github.com/Telmate/proxmox-api-go v0.0.0-20190410200643-f08824d5082d +github.com/Telmate/proxmox-api-go/proxmox # github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20170113022742-e6dbea820a9f github.com/aliyun/aliyun-oss-go-sdk/oss # github.com/antchfx/xpath v0.0.0-20170728053731-b5c552e1acbd diff --git a/website/source/docs/builders/proxmox.html.md b/website/source/docs/builders/proxmox.html.md new file mode 100644 index 000000000..ea8beb58d --- /dev/null +++ b/website/source/docs/builders/proxmox.html.md @@ -0,0 +1,201 @@ +--- +description: | + The proxmox Packer builder is able to create new images for use with + Proxmox VE. The builder takes an ISO source, runs any provisioning + necessary on the image after launching it, then creates a virtual machine + template. +layout: docs +page_title: 'Proxmox - Builders' +sidebar_current: 'docs-builders-proxmox' +--- + +# Proxmox Builder + +Type: `proxmox` + +The `proxmox` Packer builder is able to create new images for use with +[Proxmox](https://www.proxmox.com/en/proxmox-ve). The builder takes an ISO +image, runs any provisioning necessary on the image after launching it, then +creates a virtual machine template. This template can then be used as to +create new virtual machines within Proxmox. + +The builder does *not* manage templates. Once it creates a template, it is up +to you to use it or delete it. + +## Configuration Reference + +There are many configuration options available for the builder. They are +segmented below into two categories: required and optional parameters. Within +each category, the available configuration keys are alphabetized. + +In addition to the options listed here, a +[communicator](/docs/templates/communicator.html) can be configured for this +builder. + +### Required: + +- `proxmox_url` (string) - URL to the Proxmox API, including the full path, + so `https://:/api2/json` for example. + Can also be set via the `PROXMOX_URL` environment variable. + +- `username` (string) - Username when authenticating to Proxmox, including + the realm. For example `user@pve` to use the local Proxmox realm. + Can also be set via the `PROXMOX_USERNAME` environment variable. + +- `password` (string) - Password for the user. + Can also be set via the `PROXMOX_PASSWORD` environment variable. + +- `node` (string) - Which node in the Proxmox cluster to start the virtual + machine on during creation. + +- `iso_file` (string) - Path to the ISO file to boot from, expressed as a + proxmox datastore path, for example + `local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso` + +### Optional: +- `insecure_skip_tls_verify` (bool) - Skip validating the certificate. + +- `vm_name` (string) - Name of the virtual machine during creation. If not + given, a random uuid will be used. + +- `vm_id` (int) - The ID used to reference the virtual machine. This will + also be the ID of the final template. If not given, the next free ID on + the node will be used. + +- `memory` (int) - How much memory, in megabytes, to give the virtual + machine. Defaults to `512`. + +- `cores` (int) - How many CPU cores to give the virtual machine. Defaults + to `1`. + +- `sockets` (int) - How many CPU sockets to give the virtual machine. + Defaults to `1` + +- `os` (string) - The operating system. Can be `linux`, `windows`, `solaris` + or `other`. Defaults to `other`. + +- `network_adapters` (array of objects) - Network adapters attached to the + virtual machine. Example: + + ```json + [ + { + "model": "virtio", + "bridge": "vmbr0", + "vlan_tag": "10" + } + ] + ``` + + - `bridge` (string) - Required. Which Proxmox bridge to attach the + adapter to. + + - `model` (string) - Model of the virtual network adapter. Can be + `rtl8139`, `ne2k_pci`, `e1000`, `pcnet`, `virtio`, `ne2k_isa`, + `i82551`, `i82557b`, `i82559er`, `vmxnet3`, `e1000-82540em`, + `e1000-82544gc` or `e1000-82545em`. Defaults to `e1000`. + + - `mac_address` (string) - Give the adapter a specific MAC address. If + not set, defaults to a random MAC. + + - `vlan_tag` (string) - If the adapter should tag packets. Defaults to + no tagging. + +- `disks` (array of objects) - Disks attached to the virtual machine. + Example: + + ```json + [ + { + "type": "scsi", + "disk_size": "5G", + "storage_pool": "local-lvm", + "storage_pool_type": "lvm" + } + ] + ``` + + - `storage_pool` (string) - Required. Name of the Proxmox storage pool + to store the virtual machine disk on. A `local-lvm` pool is allocated + by the installer, for example. + + - `storage_pool_type` (string) - Required. The type of the pool, can + be `lvm`, `lvm-thin`, `zfs` or `directory`. + + - `type` (string) - The type of disk. Can be `scsi`, `sata`, `virtio` or + `ide`. Defaults to `scsi`. + + - `disk_size` (string) - The size of the disk, including a unit suffix, such + as `10G` to indicate 10 gigabytes. + + - `cache_mode` (string) - How to cache operations to the disk. Can be + `none`, `writethrough`, `writeback`, `unsafe` or `directsync`. + Defaults to `none`. + + - `format` (string) - The format of the file backing the disk. Can be + `raw`, `cow`, `qcow`, `qed`, `qcow2`, `vmdk` or `cloop`. Defaults to + `raw`. + +- `template_name` (string) - Name of the template. Defaults to the generated + name used during creation. + +- `template_description` (string) - Description of the template, visible in + the Proxmox interface. + +- `unmount_iso` (bool) - If true, remove the mounted ISO from the template + after finishing. Defaults to `false`. + + +## Example: Fedora with kickstart + +Here is a basic example creating a Fedora 29 server image with a Kickstart +file served with Packer's HTTP server. Note that the iso file needs to be +manually downloaded. + +``` json +{ + "variables": { + "username": "apiuser@pve", + "password": "supersecret" + }, + "builders": [ + { + "type": "proxmox", + "proxmox_url": "https://my-proxmox.my-domain:8006/api2/json", + "insecure_skip_tls_verify": true, + "username": "{{user `username`}}", + "password": "{{user `password`}}", + + "node": "my-proxmox", + "network_adapters": [ + { + "bridge": "vmbr0" + } + ], + "disks": [ + { + "type": "scsi", + "disk_size": "5G", + "storage_pool": "local-lvm", + "storage_pool_type": "lvm" + } + ], + + "iso_file": "local:iso/Fedora-Server-dvd-x86_64-29-1.2.iso", + "http_directory":"config", + "boot_wait": "10s", + "boot_command": [ + " ip=dhcp inst.cmdline inst.ks=http://{{.HTTPIP}}:{{.HTTPPort}}/ks.cfg" + ], + + "ssh_username": "root", + "ssh_timeout": "15m", + "ssh_password": "packer", + + "unmount_iso": true, + "template_name": "fedora-29", + "template_description": "Fedora 29-1.2, generated on {{ isotime \"2006-01-02T15:04:05Z\" }}" + } + ] +} +```