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..b8debaafb --- /dev/null +++ b/builder/proxmox/bootcommand_driver.go @@ -0,0 +1,123 @@ +package proxmox + +import ( + "fmt" + "strings" + "time" + "unicode" + + "github.com/Telmate/proxmox-api-go/proxmox" + "github.com/hashicorp/packer/common/bootcommand" +) + +type proxmoxDriver struct { + client *proxmox.Client + vmRef *proxmox.VmRef + specialMap map[string]string + runeMap map[rune]string + interval time.Duration +} + +func NewProxmoxDriver(c *proxmox.Client, 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) + } + + const shiftFormat = "shift-%c" + const shiftedChars = "~!@#$%^&*()_+{}|:\"<>?" // Copied from bootcommand/driver.go + + keyShift := unicode.IsUpper(key) || strings.ContainsRune(shiftedChars, key) + + var keys string + if keyShift { + keys = fmt.Sprintf(shiftFormat, 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..f44bcc7a3 --- /dev/null +++ b/builder/proxmox/builder.go @@ -0,0 +1,129 @@ +package proxmox + +import ( + "crypto/tls" + "fmt" + "log" + + "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(ui packer.Ui, hook packer.Hook, cache packer.Cache) (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(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 (b *Builder) Cancel() { + if b.runner != nil { + log.Println("Cancelling the step runner...") + b.runner.Cancel() + } +} + +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..e35c408f3 --- /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:"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/step_convert_to_template.go b/builder/proxmox/step_convert_to_template.go new file mode 100644 index 000000000..b19ec6fa8 --- /dev/null +++ b/builder/proxmox/step_convert_to_template.go @@ -0,0 +1,46 @@ +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{} + +func (s *stepConvertToTemplate) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + client := state.Get("proxmoxClient").(*proxmox.Client) + 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_finalize_template_config.go b/builder/proxmox/step_finalize_template_config.go new file mode 100644 index 000000000..ab74b245b --- /dev/null +++ b/builder/proxmox/step_finalize_template_config.go @@ -0,0 +1,65 @@ +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{} + +func (s *stepFinalizeTemplateConfig) 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) + 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 !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_start_vm.go b/builder/proxmox/step_start_vm.go new file mode 100644 index 000000000..f3a839c11 --- /dev/null +++ b/builder/proxmox/step_start_vm.go @@ -0,0 +1,143 @@ +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 + } +} + +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").(*proxmox.Client) + 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_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..f76cd5e52 --- /dev/null +++ b/builder/proxmox/step_type_boot_command.go @@ -0,0 +1,110 @@ +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" + "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 +} + +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").(*proxmox.Client) + 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) {} + +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/command/plugin.go b/command/plugin.go index b0b7149c5..054988726 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" @@ -117,6 +118,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),