diff --git a/artifact.go b/artifact.go index ea36d8ff4..d4ee8c7b3 100644 --- a/artifact.go +++ b/artifact.go @@ -1,21 +1,9 @@ package main -import "fmt" - -const BuilderId = "LizaTretyakova.post-processor.vsphere-device" - -type ArtifactFile struct { - Name string `json:"name"` - Size int64 `json:"size"` -} +const BuilderId = "LizaTretyakova.vsphere" type Artifact struct { - BuildName string `json:"name"` - BuilderType string `json:"builder_type"` - BuildTime int64 `json:"build_time"` - ArtifactFiles []ArtifactFile `json:"files"` - ArtifactId string `json:"artifact_id"` - PackerRunUUID string `json:"packer_run_uuid"` + VMName string `json:"vm_name"` } func (a *Artifact) BuilderId() string { @@ -23,19 +11,15 @@ func (a *Artifact) BuilderId() string { } func (a *Artifact) Files() []string { - var files []string - for _, af := range a.ArtifactFiles { - files = append(files, af.Name) - } - return files + return []string{} } func (a *Artifact) Id() string { - return a.ArtifactId + return a.VMName } func (a *Artifact) String() string { - return fmt.Sprintf("%s-%s", a.BuildName, a.ArtifactId) + return a.VMName } func (a *Artifact) State(name string) interface{} { diff --git a/builder.go b/builder.go new file mode 100644 index 000000000..00ede0f07 --- /dev/null +++ b/builder.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "log" + + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/packer" + "github.com/mitchellh/multistep" + "github.com/hashicorp/packer/helper/communicator" + gossh "golang.org/x/crypto/ssh" + "github.com/hashicorp/packer/communicator/ssh" +) + +type Builder struct { + config *Config + runner multistep.Runner +} + +func (b *Builder) Prepare(raws ...interface{}) ([]string, error) { + c, warnings, errs := NewConfig(raws...) + if errs != nil { + return warnings, errs + } + b.config = c + + return warnings, nil +} + +func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) (packer.Artifact, error) { + // Set up the state. + state := new(multistep.BasicStateBag) + state.Put("hook", hook) + state.Put("ui", ui) + + // Build the steps. + steps := []multistep.Step{ + &StepConfigureHW{ + config: b.config, + }, + &StepCloneVM{ + config: b.config, + }, + &StepRun{}, + &communicator.StepConnect{ + Config: &b.config.Config, + Host: func(state multistep.StateBag) (string, error) { + return state.Get("ip").(string), nil + }, + SSHConfig: func(multistep.StateBag) (*gossh.ClientConfig, error) { + return &gossh.ClientConfig{ + User: b.config.Config.SSHUsername, + Auth: []gossh.AuthMethod{ + gossh.Password(b.config.Config.SSHPassword), + gossh.KeyboardInteractive( + ssh.PasswordKeyboardInteractive(b.config.Config.SSHPassword)), + }, + // TODO: add a proper verification + HostKeyCallback: gossh.InsecureIgnoreHostKey(), + }, nil + }, + }, + &common.StepProvision{}, + &StepShutdown{ + Command: b.config.ShutdownCommand, + }, + } + + // Run! + 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) + } + + // If we were interrupted or cancelled, then just exit. + if _, ok := state.GetOk(multistep.StateCancelled); ok { + return nil, errors.New("Build was cancelled.") + } + + if _, ok := state.GetOk(multistep.StateHalted); ok { + return nil, errors.New("Build was halted.") + } + + // No errors, must've worked + artifact := &Artifact{ + VMName: b.config.VMName, + } + return artifact, nil +} + +func (b *Builder) Cancel() { + if b.runner != nil { + log.Println("Cancelling the step runner...") + b.runner.Cancel() + } +} diff --git a/config.go b/config.go new file mode 100644 index 000000000..2dbc13c4a --- /dev/null +++ b/config.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + + "github.com/hashicorp/packer/common" + "github.com/hashicorp/packer/helper/communicator" + "github.com/hashicorp/packer/helper/config" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/packer/template/interpolate" + "strconv" +) + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + communicator.Config `mapstructure:",squash"` + + Url string `mapstructure:"url"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + + Template string `mapstructure:"template"` + VMName string `mapstructure:"vm_name"` + FolderName string `mapstructure:"folder_name"` + DCName string `mapstructure:"dc_name"` + + Cpus string `mapstructure:"cpus"` + ShutdownCommand string `mapstructure:"shutdown_command"` + Ram string `mapstructure:"RAM"` + //TODO: add more options + + ctx interpolate.Context +} + +func NewConfig(raws ...interface{}) (*Config, []string, error) { + c := new(Config) + err := config.Decode(c, &config.DecodeOpts{ + Interpolate: true, + InterpolateContext: &c.ctx, + }, raws...) + if err != nil { + return nil, nil, err + } + + // Accumulate any errors + errs := new(packer.MultiError) + + // Prepare config(s) + errs = packer.MultiErrorAppend(errs, c.Config.Prepare(&c.ctx)...) + + // Check the required params + if c.Url == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("URL required")) + } + if c.Username == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Username required")) + } + if c.Password == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Password required")) + } + if c.Template == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Template VM name required")) + } + if c.VMName == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Target VM name required")) + } + + // Verify numeric parameters if present + if c.Cpus != "" { + if _, err = strconv.Atoi(c.Cpus); err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Invalid number of cpu sockets")) + } + } + if c.Ram != "" { + if _, err = strconv.Atoi(c.Ram); err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("Invalid number for Ram")) + } + } + + // Warnings + var warnings []string + + if len(errs.Errors) > 0 { + return nil, warnings, errs + } + + return c, warnings, nil +} diff --git a/main.go b/main.go index de5038175..9beb821a0 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,6 @@ func main() { if err != nil { panic(err) } - server.RegisterPostProcessor(new(PostProcessor)) + server.RegisterBuilder(new(Builder)) server.Serve() } diff --git a/post-processor.go b/post-processor.go deleted file mode 100644 index 72d75590c..000000000 --- a/post-processor.go +++ /dev/null @@ -1,271 +0,0 @@ -package main - -import ( - "github.com/hashicorp/packer/common" - "github.com/hashicorp/packer/helper/config" - "github.com/hashicorp/packer/packer" - "github.com/hashicorp/packer/template/interpolate" - "github.com/vmware/govmomi" - "github.com/vmware/govmomi/find" - "github.com/vmware/govmomi/vim25/mo" - "github.com/vmware/govmomi/object" - "github.com/vmware/govmomi/vim25/types" - "context" - "fmt" - "net/url" - "strconv" -) - -type VMOptionalParams struct { - Cpu_sockets int - Ram int - // TODO: add more options -} - -type VMRequiredParams struct { - Url string - Username string - Password string - Dc_name string - Folder_name string - Vm_source_name string - Vm_target_name string -} - -const DefaultFolder = "" -const Unspecified = -1 - -var vm_opt_params VMOptionalParams -var vm_req_params VMRequiredParams - -type Config struct { - common.PackerConfig `mapstructure:",squash"` - - Url string `mapstructure:"url"` - Username string `mapstructure:"username"` - Password string `mapstructure:"password"` - Dc_name string `mapstructure:"dc_name"` - Vm_source_name string `mapstructure:"vm_source_name"` - Vm_target_name string `mapstructure:"vm_target_name"` - Cpu_sockets string `mapstructure:"cpus"` - Ram string `mapstructure:"RAM"` - - ctx interpolate.Context -} - -type PostProcessor struct { - config Config -} - -func (p *PostProcessor) Configure(raws ...interface{}) error { - err := config.Decode(&p.config, &config.DecodeOpts{ - Interpolate: true, - InterpolateContext: &p.config.ctx, - InterpolateFilter: &interpolate.RenderFilter{ - Exclude: []string{}, - }, - }, raws...) - if err != nil { - return err - } - - // Accumulate any errors - errs := new(packer.MultiError) - - // Check the required params - templates := map[string]*string{ - "url": &p.config.Url, - "username": &p.config.Username, - "password": &p.config.Password, - "vm_source_name": &p.config.Vm_source_name, - } - for key, ptr := range templates { - if *ptr == "" { - errs = packer.MultiErrorAppend( - errs, fmt.Errorf("%s must be set, %s is present", key, *ptr)) - } - } - - if len(errs.Errors) > 0 { - return errs - } - - // Set optional params - vm_req_params.Folder_name = DefaultFolder - vm_opt_params.Cpu_sockets = Unspecified - if p.config.Cpu_sockets != "" { - vm_opt_params.Cpu_sockets, err = strconv.Atoi(p.config.Cpu_sockets) - if err != nil { - return err - } - } - vm_opt_params.Ram = Unspecified - if p.config.Ram != "" { - vm_opt_params.Ram, err = strconv.Atoi(p.config.Ram) - if err != nil { - return err - } - } - - // Set required params - vm_req_params.Url = p.config.Url - vm_req_params.Username = p.config.Username - vm_req_params.Password = p.config.Password - vm_req_params.Dc_name = p.config.Dc_name - vm_req_params.Vm_source_name = p.config.Vm_source_name - vm_req_params.Vm_target_name = vm_req_params.Vm_source_name + "_clone" - if p.config.Vm_target_name != "" { - vm_req_params.Vm_target_name = p.config.Vm_target_name - } - - return nil -} - -func CloneVM(req_params VMRequiredParams, opt_params VMOptionalParams) error { - // Prepare entities: client (authentification), finder, folder, virtual machine - client, ctx, err := createClient(req_params.Url, req_params.Username, req_params.Password) - if err != nil { - return err - } - finder, ctx, err := createFinder(ctx, client, req_params.Dc_name) - if err != nil { - return err - } - folder, err := finder.FolderOrDefault(ctx, req_params.Folder_name) - if err != nil { - return err - } - vm_src, ctx, err := findVM_by_name(ctx, finder, req_params.Vm_source_name) - if err != nil { - return err - } - - // Creating spec's for cloning - var relocateSpec types.VirtualMachineRelocateSpec - - var confSpec types.VirtualMachineConfigSpec - // configure HW - if opt_params.Cpu_sockets != Unspecified { - confSpec.NumCPUs = int32(opt_params.Cpu_sockets) - } - if opt_params.Ram != Unspecified { - confSpec.MemoryMB = int64(opt_params.Ram) - } - - cloneSpec := types.VirtualMachineCloneSpec{ - Location: relocateSpec, - Config: &confSpec, - PowerOn: false, - } - - // Cloning itself - task, err := vm_src.Clone(ctx, folder, req_params.Vm_target_name, cloneSpec) - if err != nil { - return err - } - _, err = task.WaitForResult(ctx, nil) - if err != nil { - return err - } - - return nil -} - -func (p *PostProcessor) PostProcess(ui packer.Ui, source packer.Artifact) (packer.Artifact, bool, error) { - err := CloneVM(vm_req_params, vm_opt_params) - if err != nil { - return nil, false, err - } - - // Return: - // source -- the given artifact -- since we didn't change anything; - // false -- don't force packer to keep the source artifact - // nil -- no error occured here - return source, false, nil -} - -func createClient(URL, username, password string) (*govmomi.Client, context.Context, error) { - // create context - ctx := context.TODO() // an empty, default context (for those, who is unsure) - - // create a client - // (connected to the specified URL, - // logged in with the username-password) - u, err := url.Parse(URL) // create a URL object from string - if err != nil { - return nil, nil, err - } - u.User = url.UserPassword(username, password) // set username and password for automatical authentification - fmt.Println(u.String()) - client, err := govmomi.NewClient(ctx, u,true) // creating a client (logs in with given uname&pswd) - if err != nil { - return nil, nil, err - } - - return client, ctx, nil -} - -func createFinder(ctx context.Context, client *govmomi.Client, dc_name string) (*find.Finder, context.Context, error) { - // Create a finder to search for a vm with the specified name - finder := find.NewFinder(client.Client, false) - // Need to specify the datacenter - if dc_name == "" { - dc, err := finder.DefaultDatacenter(ctx) - if err != nil { - return nil, nil, err - } - var dc_mo mo.Datacenter - err = dc.Properties(ctx, dc.Reference(), []string{"name"}, &dc_mo) - if err != nil { - return nil, nil, err - } - dc_name = dc_mo.Name - finder.SetDatacenter(dc) - } else { - dc, err := finder.Datacenter(ctx, fmt.Sprintf("/%v", dc_name)) - if err != nil { - return nil, nil, err - } - finder.SetDatacenter(dc) - } - return finder, ctx, nil -} - -func findVM_by_name(ctx context.Context, finder *find.Finder, vm_name string) (*object.VirtualMachine, context.Context, error) { - vm_o, err := finder.VirtualMachine(ctx, vm_name) - if err != nil { - return nil, nil, err - } - return vm_o, ctx, nil -} - -func ReconfigureVM(URL, username, password, dc_name, vm_name string, cpus int) error { - client, ctx, err := createClient(URL, username, password) - if err != nil { - return err - } - finder, ctx, err := createFinder(ctx, client, dc_name) - if err != nil { - return err - } - vm_o, ctx, err := findVM_by_name(ctx, finder, vm_name) - if err != nil { - return err - } - - // creating new configuration for vm - vmConfigSpec := types.VirtualMachineConfigSpec{} - vmConfigSpec.NumCPUs = int32(cpus) - - // finally reconfiguring - task, err := vm_o.Reconfigure(ctx, vmConfigSpec) - if err != nil { - return err - } - _, err = task.WaitForResult(ctx, nil) - if err != nil { - return err - } - - return nil -} diff --git a/step_clone_vm.go b/step_clone_vm.go new file mode 100644 index 000000000..9f51972da --- /dev/null +++ b/step_clone_vm.go @@ -0,0 +1,172 @@ +package main + +import ( + "github.com/vmware/govmomi" + "context" + "github.com/mitchellh/multistep" + "github.com/vmware/govmomi/vim25/types" + "github.com/vmware/govmomi/object" + "github.com/hashicorp/packer/packer" + "github.com/vmware/govmomi/find" + "fmt" + "net/url" +) + +type CloneParameters struct { + client *govmomi.Client + folder *object.Folder + vmSrc *object.VirtualMachine + ctx context.Context + vmName string + confSpec *types.VirtualMachineConfigSpec +} + +type StepCloneVM struct{ + config *Config + success bool +} + +func (s *StepCloneVM) Run(state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + ui.Say("start cloning...") + + confSpec := state.Get("confSpec").(types.VirtualMachineConfigSpec) + + // Prepare entities: client (authentification), finder, folder, virtual machine + client, ctx, err := createClient(s.config.Url, s.config.Username, s.config.Password) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + finder, ctx, err := createFinder(ctx, client, s.config.DCName) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + folder, err := finder.FolderOrDefault(ctx, s.config.FolderName) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + vmSrc, err := finder.VirtualMachine(ctx, s.config.Template) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + cloneParameters := CloneParameters{ + client: client, + folder: folder, + vmSrc: vmSrc, + ctx: ctx, + vmName: s.config.VMName, + confSpec: &confSpec, + } + + vm, err := cloneVM(&cloneParameters) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + state.Put("vm", vm) + state.Put("ctx", ctx) + s.success = true + return multistep.ActionContinue +} + +func (s *StepCloneVM) Cleanup(state multistep.StateBag) { + if !s.success { + return + } + + _, cancelled := state.GetOk(multistep.StateCancelled) + _, halted := state.GetOk(multistep.StateHalted) + + if cancelled || halted { + vm := state.Get("vm").(*object.VirtualMachine) + ctx := state.Get("ctx").(context.Context) + ui := state.Get("ui").(packer.Ui) + + ui.Say("destroying VM...") + + task, err := vm.Destroy(ctx) + if err != nil { + ui.Error(err.Error()) + return + } + _, err = task.WaitForResult(ctx, nil) + if err != nil { + ui.Error(err.Error()) + return + } + } +} + +func cloneVM(params *CloneParameters) (vm *object.VirtualMachine, err error) { + vm = nil + err = nil + + // Creating specs for cloning + var relocateSpec types.VirtualMachineRelocateSpec + cloneSpec := types.VirtualMachineCloneSpec{ + Location: relocateSpec, + Config: params.confSpec, + PowerOn: false, + } + + // Cloning itself + task, err := params.vmSrc.Clone(params.ctx, params.folder, params.vmName, cloneSpec) + if err != nil { + return + } + + info, err := task.WaitForResult(params.ctx, nil) + if err != nil { + return + } + + vm = object.NewVirtualMachine(params.client.Client, info.Result.(types.ManagedObjectReference)) + return vm, nil +} + +func createClient(URL, username, password string) (*govmomi.Client, context.Context, error) { + // create context + ctx := context.TODO() // an empty, default context (for those, who is unsure) + + // create a client + // (connected to the specified URL, + // logged in with the username-password) + u, err := url.Parse(URL) // create a URL object from string + if err != nil { + return nil, nil, err + } + u.User = url.UserPassword(username, password) // set username and password for automatical authentification + fmt.Println(u.String()) + client, err := govmomi.NewClient(ctx, u,true) // creating a client (logs in with given uname&pswd) + if err != nil { + return nil, nil, err + } + + return client, ctx, nil +} + +func createFinder(ctx context.Context, client *govmomi.Client, dcName string) (*find.Finder, context.Context, error) { + // Create a finder to search for a vm with the specified name + finder := find.NewFinder(client.Client, false) + // Need to specify the datacenter + if dcName == "" { + dc, err := finder.DefaultDatacenter(ctx) + if err != nil { + return nil, nil, err + } + finder.SetDatacenter(dc) + } else { + dc, err := finder.Datacenter(ctx, dcName) + if err != nil { + return nil, nil, err + } + finder.SetDatacenter(dc) + } + return finder, ctx, nil +} diff --git a/step_configure_hw.go b/step_configure_hw.go new file mode 100644 index 000000000..d26c4ed11 --- /dev/null +++ b/step_configure_hw.go @@ -0,0 +1,44 @@ +package main + +import ( + "github.com/mitchellh/multistep" + "github.com/hashicorp/packer/packer" + "strconv" + "github.com/vmware/govmomi/vim25/types" +) + +type StepConfigureHW struct{ + config *Config +} + +func (s *StepConfigureHW) Run(state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + ui.Say("configuring virtual hardware...") + + var confSpec types.VirtualMachineConfigSpec + // configure HW + if s.config.Cpus != "" { + cpus, err := strconv.Atoi(s.config.Cpus) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + confSpec.NumCPUs = int32(cpus) + } + if s.config.Ram != "" { + ram, err := strconv.Atoi(s.config.Ram) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + confSpec.MemoryMB = int64(ram) + } + + state.Put("confSpec", confSpec) + + return multistep.ActionContinue +} + +func (s *StepConfigureHW) Cleanup(multistep.StateBag) {} diff --git a/step_run.go b/step_run.go new file mode 100644 index 000000000..b603cdd84 --- /dev/null +++ b/step_run.go @@ -0,0 +1,72 @@ +package main + +import ( + "github.com/mitchellh/multistep" + "github.com/hashicorp/packer/packer" + "github.com/vmware/govmomi/object" + "context" + "fmt" + "github.com/vmware/govmomi/vim25/types" +) + +type StepRun struct{ + // TODO: add boot time to provide a proper timeout during cleanup +} + +func (s *StepRun) Run(state multistep.StateBag) multistep.StepAction { + ui := state.Get("ui").(packer.Ui) + vm := state.Get("vm").(*object.VirtualMachine) + ctx := state.Get("ctx").(context.Context) + + ui.Say("VM power on...") + task, err := vm.PowerOn(ctx) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + _, err = task.WaitForResult(ctx, nil) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + ui.Say("VM waiting for IP...") + ip, err := vm.WaitForIP(ctx) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + state.Put("ip", ip) + ui.Say(fmt.Sprintf("VM ip %v", ip)) + return multistep.ActionContinue +} + +func (s *StepRun) Cleanup(state multistep.StateBag) { + _, cancelled := state.GetOk(multistep.StateCancelled) + _, halted := state.GetOk(multistep.StateHalted) + + if cancelled || halted { + vm := state.Get("vm").(*object.VirtualMachine) + ctx := state.Get("ctx").(context.Context) + ui := state.Get("ui").(packer.Ui) + + if state, err := vm.PowerState(ctx); state != types.VirtualMachinePowerStatePoweredOff && err == nil { + ui.Say("shutting down VM...") + + task, err := vm.PowerOff(ctx) + if err != nil { + ui.Error(err.Error()) + return + } + _, err = task.WaitForResult(ctx, nil) + if err != nil { + ui.Error(err.Error()) + return + } + } else if err != nil { + ui.Error(err.Error()) + return + } + } +} diff --git a/step_shutdown.go b/step_shutdown.go new file mode 100644 index 000000000..24bdb7ca7 --- /dev/null +++ b/step_shutdown.go @@ -0,0 +1,83 @@ +package main + +import ( + "github.com/mitchellh/multistep" + "github.com/hashicorp/packer/packer" + "github.com/vmware/govmomi/object" + "context" + "fmt" + "log" + "time" + "bytes" +) + +type StepShutdown struct{ + Command string +} + +func (s *StepShutdown) Run(state multistep.StateBag) multistep.StepAction { + // is set during the communicator.StepConnect + comm := state.Get("communicator").(packer.Communicator) + ui := state.Get("ui").(packer.Ui) + vm := state.Get("vm").(*object.VirtualMachine) + ctx := state.Get("ctx").(context.Context) + + ui.Say("VM shutdown...") + + if s.Command != "" { + ui.Say("Gracefully halting virtual machine...") + log.Printf("Executing shutdown command: %s", s.Command) + + var stdout, stderr bytes.Buffer + cmd := &packer.RemoteCmd{ + Command: s.Command, + Stdout: &stdout, + Stderr: &stderr, + } + if err := comm.Start(cmd); err != nil { + err := fmt.Errorf("Failed to send shutdown command: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + + // TODO: add timeout + for !cmd.Exited { + ui.Say("Waiting for remote cmd to finish...") + time.Sleep(150 * time.Millisecond) + } + if cmd.ExitStatus != 0 && cmd.ExitStatus != packer.CmdDisconnect { + err := fmt.Errorf("Cmd exit status %v, not 0", cmd.ExitStatus) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } else if cmd.ExitStatus == packer.CmdDisconnect { + ui.Say("VM disconnected") + } + } else { + ui.Say("Forcibly halting virtual machine...") + + err := vm.ShutdownGuest(ctx) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + + task, err := vm.PowerOff(ctx) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + _, err = task.WaitForResult(ctx, nil) + if err != nil { + state.Put("error", err) + return multistep.ActionHalt + } + } + + ui.Say("VM stopped") + return multistep.ActionContinue +} + +func (s *StepShutdown) Cleanup(state multistep.StateBag) {} +