diff --git a/provisioner/converge/provisioner.go b/provisioner/converge/provisioner.go new file mode 100644 index 000000000..dab861115 --- /dev/null +++ b/provisioner/converge/provisioner.go @@ -0,0 +1,237 @@ +// This package implements a provisioner for Packer that executes +// Converge to provision a remote machine + +package converge + +import ( + "bytes" + "errors" + "fmt" + + "strings" + + "encoding/json" + + "github.com/mitchellh/packer/common" + "github.com/mitchellh/packer/helper/config" + "github.com/mitchellh/packer/packer" + "github.com/mitchellh/packer/template/interpolate" +) + +// Config for Converge provisioner +type Config struct { + common.PackerConfig `mapstructure:",squash"` + + // Bootstrapping + Bootstrap bool `mapstructure:"bootstrap"` + Version string `mapstructure:"version"` + BootstrapCommand string `mapstructure:"bootstrap_command"` + PreventBootstrapSudo bool `mapstructure:"prevent_bootstrap_sudo"` + + // Modules + ModuleDirs []ModuleDir `mapstructure:"module_dirs"` + + // Execution + Module string `mapstructure:"module"` + WorkingDirectory string `mapstructure:"working_directory"` + Params map[string]string `mapstucture:"params"` + ExecuteCommand string `mapstructure:"execute_command"` + PreventSudo bool `mapstructure:"prevent_sudo"` + + ctx interpolate.Context +} + +// ModuleDir is a directory to transfer to the remote system +type ModuleDir struct { + Source string `mapstructure:"source"` + Destination string `mapstructure:"destination"` + Exclude []string `mapstructure:"exclude"` +} + +// Provisioner for Converge +type Provisioner struct { + config Config +} + +// Prepare provisioner somehow. TODO: actual docs +func (p *Provisioner) Prepare(raws ...interface{}) error { + err := config.Decode( + &p.config, + &config.DecodeOpts{ + Interpolate: true, + InterpolateContext: &p.config.ctx, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{ + "execute_command", + "bootstrap_command", + }, + }, + }, + raws..., + ) + if err != nil { + return err + } + + // require a single module + if p.config.Module == "" { + return errors.New("Converge requires a module to provision the system") + } + + // set defaults + if p.config.WorkingDirectory == "" { + p.config.WorkingDirectory = "/tmp" + } + + if p.config.ExecuteCommand == "" { + p.config.ExecuteCommand = "cd {{.WorkingDirectory}} && {{if .Sudo}}sudo {{end}}converge apply --local --log-level=WARNING --paramsJSON '{{.ParamsJSON}}' {{.Module}}" + } + + if p.config.BootstrapCommand == "" { + p.config.BootstrapCommand = "curl -s https://get.converge.sh | {{if .Sudo}}sudo {{end}}sh {{if ne .Version \"\"}}-s -- -v {{.Version}}{{end}}" + } + + // validate sources and destinations + for i, dir := range p.config.ModuleDirs { + if dir.Source == "" { + return fmt.Errorf("Source (\"source\" key) is required in Converge module dir #%d", i) + } + if dir.Destination == "" { + return fmt.Errorf("Destination (\"destination\" key) is required in Converge module dir #%d", i) + } + } + + return err +} + +// Provision node somehow. TODO: actual docs +func (p *Provisioner) Provision(ui packer.Ui, comm packer.Communicator) error { + ui.Say("Provisioning with Converge") + + // bootstrapping + if err := p.maybeBootstrap(ui, comm); err != nil { + return err // error messages are already user-friendly + } + + // send module directories to the remote host + if err := p.sendModuleDirectories(ui, comm); err != nil { + return err // error messages are already user-friendly + } + + // apply all the modules + if err := p.applyModules(ui, comm); err != nil { + return err // error messages are already user-friendly + } + + return nil +} + +func (p *Provisioner) maybeBootstrap(ui packer.Ui, comm packer.Communicator) error { + if !p.config.Bootstrap { + return nil + } + ui.Message("bootstrapping converge") + + p.config.ctx.Data = struct { + Version string + Sudo bool + }{ + Version: p.config.Version, + Sudo: !p.config.PreventBootstrapSudo, + } + command, err := interpolate.Render(p.config.BootstrapCommand, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate bootstrap command: %s", err) + } + + var out bytes.Buffer + cmd := &packer.RemoteCmd{ + Command: command, + Stdin: nil, + Stdout: &out, + Stderr: &out, + } + + if err = comm.Start(cmd); err != nil { + return fmt.Errorf("Error bootstrapping converge: %s", err) + } + + cmd.Wait() + if cmd.ExitStatus != 0 { + ui.Error(out.String()) + return errors.New("Error bootstrapping converge") + } + + ui.Message(strings.TrimSpace(out.String())) + return nil +} + +func (p *Provisioner) sendModuleDirectories(ui packer.Ui, comm packer.Communicator) error { + for _, dir := range p.config.ModuleDirs { + if err := comm.UploadDir(dir.Destination, dir.Source, dir.Exclude); err != nil { + return fmt.Errorf("Could not upload %q: %s", dir.Source, err) + } + ui.Message(fmt.Sprintf("transferred %q to %q", dir.Source, dir.Destination)) + } + + return nil +} + +func (p *Provisioner) applyModules(ui packer.Ui, comm packer.Communicator) error { + // create params JSON file + params, err := json.Marshal(p.config.Params) + if err != nil { + return fmt.Errorf("Could not marshal parameters as JSON: %s", err) + } + + p.config.ctx.Data = struct { + ParamsJSON, WorkingDirectory, Module string + Sudo bool + }{ + ParamsJSON: string(params), + WorkingDirectory: p.config.WorkingDirectory, + Module: p.config.Module, + Sudo: !p.config.PreventSudo, + } + command, err := interpolate.Render(p.config.ExecuteCommand, &p.config.ctx) + if err != nil { + return fmt.Errorf("Could not interpolate execute command: %s", err) + } + + // run Converge in the specified directory + var runOut bytes.Buffer + cmd := &packer.RemoteCmd{ + Command: command, + Stdin: nil, + Stdout: &runOut, + Stderr: &runOut, + } + if err := comm.Start(cmd); err != nil { + return fmt.Errorf("Error applying %q: %s", p.config.Module, err) + } + + cmd.Wait() + if cmd.ExitStatus == 127 { + ui.Error("Could not find Converge. Is it installed and in PATH?") + if !p.config.Bootstrap { + ui.Error("Bootstrapping was disabled for this run. That might be why Converge isn't present.") + } + + return errors.New("Could not find Converge") + + } else if cmd.ExitStatus != 0 { + ui.Error(strings.TrimSpace(runOut.String())) + ui.Error(fmt.Sprintf("exited with error code %d", cmd.ExitStatus)) + return fmt.Errorf("Error applying %q", p.config.Module) + } + + ui.Message(strings.TrimSpace(runOut.String())) + + return nil +} + +// Cancel the provisioning process +func (p *Provisioner) Cancel() { + // there's not an awful lot we can do to cancel Converge at the moment. + // The default semantics are fine. +} diff --git a/provisioner/converge/provisioner_test.go b/provisioner/converge/provisioner_test.go new file mode 100644 index 000000000..b4cd2d361 --- /dev/null +++ b/provisioner/converge/provisioner_test.go @@ -0,0 +1,119 @@ +package converge + +import ( + "testing" + + "github.com/mitchellh/packer/packer" +) + +func testConfig() map[string]interface{} { + return map[string]interface{}{ + "module_dirs": []map[string]interface{}{ + { + "source": "from", + "destination": "/opt/converge", + }, + }, + "module": "/opt/converge/test.hcl", + } +} + +func TestProvisioner_Impl(t *testing.T) { + var raw interface{} + raw = &Provisioner{} + if _, ok := raw.(packer.Provisioner); !ok { + t.Fatal("must be a Provisioner") + } +} + +func TestProvisionerPrepare(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + t.Run("working_directory", func(t *testing.T) { + var p Provisioner + config := testConfig() + + delete(config, "working_directory") + + if err := p.Prepare(config); err != nil { + t.Fatalf("err: %s", err) + } + + if p.config.WorkingDirectory != "/tmp" { + t.Fatalf("unexpected module directory: %s", p.config.WorkingDirectory) + } + }) + + t.Run("execute_command", func(t *testing.T) { + var p Provisioner + config := testConfig() + + delete(config, "execute_command") + + if err := p.Prepare(config); err != nil { + t.Fatalf("err: %s", err) + } + + if p.config.ExecuteCommand == "" { + t.Fatal("execute command unexpectedly blank") + } + }) + + t.Run("bootstrap_command", func(t *testing.T) { + var p Provisioner + config := testConfig() + + delete(config, "bootstrap_command") + + if err := p.Prepare(config); err != nil { + t.Fatalf("err: %s", err) + } + + if p.config.BootstrapCommand == "" { + t.Fatal("bootstrap command unexpectedly blank") + } + }) + }) + + t.Run("validate", func(t *testing.T) { + t.Run("module dir", func(t *testing.T) { + t.Run("missing source", func(t *testing.T) { + var p Provisioner + config := testConfig() + delete(config["module_dirs"].([]map[string]interface{})[0], "source") + + err := p.Prepare(config) + if err == nil { + t.Error("expected error") + } else if err.Error() != "Source (\"source\" key) is required in Converge module dir #0" { + t.Errorf("bad error message: %s", err) + } + }) + + t.Run("missing destination", func(t *testing.T) { + var p Provisioner + config := testConfig() + delete(config["module_dirs"].([]map[string]interface{})[0], "destination") + + err := p.Prepare(config) + if err == nil { + t.Error("expected error") + } else if err.Error() != "Destination (\"destination\" key) is required in Converge module dir #0" { + t.Errorf("bad error message: %s", err) + } + }) + }) + + t.Run("no module specified", func(t *testing.T) { + var p Provisioner + config := testConfig() + delete(config, "module") + + err := p.Prepare(config) + if err == nil { + t.Error("expected error") + } else if err.Error() != "Converge requires a module to provision the system" { + t.Errorf("bad error message: %s", err) + } + }) + }) +} diff --git a/website/source/docs/provisioners/converge.html.md b/website/source/docs/provisioners/converge.html.md new file mode 100644 index 000000000..c2df56d71 --- /dev/null +++ b/website/source/docs/provisioners/converge.html.md @@ -0,0 +1,118 @@ +--- +description: |- + The Converge Packer provisioner uses Converge modules to provision the machine. +layout: docs +page_title: Converge Provisioner +... + +# Converge Provisioner + +Type: `converge` + +The [Converge](http://converge.aster.is) Packer provisioner uses Converge +modules to provision the machine. It uploads module directories to use as +source, or you can use remote modules. + +The provisioner can optionally bootstrap the Converge client/server binary onto +new images. + +## Basic Example + +The example below is fully functional. + +``` {.javascript} +{ + "type": "converge", + "module": "https://raw.githubusercontent.com/asteris-llc/converge/master/samples/fileContent.hcl", + "params": { + "message": "Hello, Packer!" + } +} +``` + +## Configuration Reference + +The reference of available configuration options is listed below. The only +required element is "module". Every other option is optional. + +- `module` (string) - Path (or URL) to the root module that Converge will apply. + +Optional parameters: + +- `bootstrap` (boolean, defaults to false) - Set to allow the provisioner to + download the latest Converge bootstrap script and the specified `version` of + Converge from the internet. + +- `version` (string) - Set to a [released Converge version](https://github.com/asteris-llc/converge/releases) for bootstrap. + +- `module_dirs` (array of directory specifications) - Module directories to + transfer to the remote host for execution. See below for the specification. + +- `working_directory` (string) - The directory that Converge will change to + before execution. + +- `params` (maps of string to string) - parameters to pass into the root module. + +- `execute_command` (string) - the command used to execute Converge. This has + various + [configuration template variables](/docs/templates/configuration-templates.html) available. + +- `prevent_sudo` (bool) - stop Converge from running with adminstrator + privileges via sudo + +- `bootstrap_command` (string) - the command used to bootstrap Converge. This + has various + [configuration template variables](/docs/templates/configuration-templates.html) available. + +- `prevent_bootstrap_sudo` (bool) - stop Converge from bootstrapping with + administrator privileges via sudo + +### Module Directories + +The provisioner can transfer module directories to the remote host for +provisioning. Of these fields, `source` and `destination` are required in every +directory. + +- `source` (string) - the path to the folder on the local machine. + +- `destination` (string) - the path to the folder on the remote machine. Parent + directories will not be created; use the shell module to do this. + +- `exclude` (array of string) - files and directories to exclude from transfer. + +### Execute Command + +By default, Packer uses the following command (broken across multiple lines for readability) to execute Converge: + +``` {.liquid} +cd {{.WorkingDirectory}} && \ +{{if .Sudo}}sudo {{end}}converge apply \ + --local \ + --log-level=WARNING \ + --paramsJSON '{{.ParamsJSON}}' \ + {{.Module}} +``` + +This command can be customized using the `execute_command` configuration. As you +can see from the default value above, the value of this configuration can +contain various template variables: + +- `WorkingDirectory` - `directory` from the configuration. +- `Sudo` - the opposite of `prevent_sudo` from the configuration. +- `ParamsJSON` - The unquoted JSONified form of `params` from the configuration. +- `Module` - `module` from the configuration. + +### Bootstrap Command + +By default, Packer uses the following command to bootstrap Converge: + +``` {.liquid} +curl -s https://get.converge.sh | {{if .Sudo}}sudo {{end}}sh {{if ne .Version ""}}-s -- -v {{.Version}}{{end}} +``` + +This command can be customized using the `bootstrap_command` configuration. As you +can see from the default values above, the value of this configuration can +contain various template variables: + +- `Sudo` - the opposite of `prevent_bootstrap_sudo` from the configuration. +- `Version` - `version` from the configuration.