diff --git a/builtin/bins/provisioner-puppet/main.go b/builtin/bins/provisioner-puppet/main.go new file mode 100644 index 000000000..63797cb2e --- /dev/null +++ b/builtin/bins/provisioner-puppet/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/provisioners/puppet" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProvisionerFunc: puppet.Provisioner, + }) +} diff --git a/builtin/provisioners/puppet/bolt/bolt.go b/builtin/provisioners/puppet/bolt/bolt.go new file mode 100644 index 000000000..0fc70e32e --- /dev/null +++ b/builtin/provisioners/puppet/bolt/bolt.go @@ -0,0 +1,74 @@ +package bolt + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "runtime" + "strings" + "time" +) + +type Result struct { + Items []struct { + Node string `json:"node"` + Status string `json:"status"` + Result map[string]string `json:"result"` + } `json:"items"` + NodeCount int `json:"node_count"` + ElapsedTime int `json:"elapsed_time"` +} + +func runCommand(command string, timeout time.Duration) ([]byte, error) { + var cmdargs []string + + if runtime.GOOS == "windows" { + cmdargs = []string{"cmd", "/C"} + } else { + cmdargs = []string{"/bin/sh", "-c"} + } + cmdargs = append(cmdargs, command) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, cmdargs[0], cmdargs[1:]...) + return cmd.Output() +} + +func Task(connInfo map[string]string, timeout time.Duration, sudo bool, task string, args map[string]string) (*Result, error) { + cmdargs := []string{ + "bolt", "task", "run", "--nodes", connInfo["type"] + "://" + connInfo["host"], "-u", connInfo["user"], + } + + if connInfo["type"] == "winrm" { + cmdargs = append(cmdargs, "-p", "\""+connInfo["password"]+"\"", "--no-ssl") + } else { + if sudo { + cmdargs = append(cmdargs, "--run-as", "root") + } + + cmdargs = append(cmdargs, "--no-host-key-check") + } + + cmdargs = append(cmdargs, "--format", "json", "--connect-timeout", "120", task) + + if args != nil { + for key, value := range args { + cmdargs = append(cmdargs, strings.Join([]string{key, value}, "=")) + } + } + + out, err := runCommand(strings.Join(cmdargs, " "), timeout) + if err != nil { + return nil, fmt.Errorf("Bolt: \"%s\": %s: %s", strings.Join(cmdargs, " "), out, err) + } + + result := new(Result) + if err = json.Unmarshal(out, result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/builtin/provisioners/puppet/linux_provisioner.go b/builtin/provisioners/puppet/linux_provisioner.go new file mode 100644 index 000000000..f480b0e72 --- /dev/null +++ b/builtin/provisioners/puppet/linux_provisioner.go @@ -0,0 +1,65 @@ +package puppet + +import ( + "fmt" + "io" + + "github.com/hashicorp/terraform/communicator/remote" +) + +func (p *provisioner) linuxUploadFile(f io.Reader, dir string, filename string) error { + _, err := p.runCommand("mkdir -p " + dir) + if err != nil { + return fmt.Errorf("Failed to make directory %s: %s", dir, err) + } + + err = p.comm.Upload("/tmp/"+filename, f) + if err != nil { + return fmt.Errorf("Failed to upload %s to /tmp: %s", filename, err) + } + + _, err = p.runCommand(fmt.Sprintf("mv /tmp/%s %s/%s", filename, dir, filename)) + return err +} + +func (p *provisioner) linuxDefaultCertname() (string, error) { + certname, err := p.runCommand("hostname -f") + if err != nil { + return "", err + } + + return certname, nil +} + +func (p *provisioner) linuxInstallPuppetAgent() error { + _, err := p.runCommand(fmt.Sprintf("curl -kO https://%s:8140/packages/current/install.bash", p.Server)) + if err != nil { + return err + } + + _, err = p.runCommand("bash -- ./install.bash --puppet-service-ensure stopped") + if err != nil { + return err + } + + _, err = p.runCommand("rm -f install.bash") + return err +} + +func (p *provisioner) linuxRunPuppetAgent() error { + _, err := p.runCommand(fmt.Sprintf( + "/opt/puppetlabs/puppet/bin/puppet agent --test --server %s --environment %s", + p.Server, + p.Environment, + )) + + // Puppet exits 2 if changes have been successfully made. + if err != nil { + errStruct, _ := err.(*remote.ExitError) + if errStruct.ExitStatus == 2 { + return nil + } + } + + return err +} diff --git a/builtin/provisioners/puppet/linux_provisioner_test.go b/builtin/provisioners/puppet/linux_provisioner_test.go new file mode 100644 index 000000000..828d66708 --- /dev/null +++ b/builtin/provisioners/puppet/linux_provisioner_test.go @@ -0,0 +1,379 @@ +package puppet + +import ( + "io" + "strings" + "testing" + + "github.com/hashicorp/terraform/communicator" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func TestResourceProvisioner_linuxUploadFile(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + Uploads map[string]string + File io.Reader + Dir string + Filename string + }{ + "Successful upload": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "mkdir -p /etc/puppetlabs/puppet": true, + "mv /tmp/csr_attributes.yaml /etc/puppetlabs/puppet/csr_attributes.yaml": true, + }, + Uploads: map[string]string{ + "/tmp/csr_attributes.yaml": "", + }, + Dir: "/etc/puppetlabs/puppet", + Filename: "csr_attributes.yaml", + File: strings.NewReader(""), + }, + "Failure when creating the directory": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "mkdir -p /etc/puppetlabs/puppet": true, + }, + Dir: "/etc/puppetlabs/puppet", + Filename: "csr_attributes.yaml", + File: strings.NewReader(""), + CommandFunc: func(r *remote.Cmd) error { + r.SetExitStatus(1, &remote.ExitError{ + Command: "mkdir -p /etc/puppetlabs/puppet", + ExitStatus: 1, + Err: nil, + }) + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + c.Uploads = tc.Uploads + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + err = p.linuxUploadFile(tc.File, tc.Dir, tc.Filename) + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} + +func TestResourceProvisioner_linuxDefaultCertname(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + }{ + "No sudo": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "hostname -f": true, + }, + }, + "With sudo": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": true, + }, + Commands: map[string]bool{ + "sudo hostname -f": true, + }, + }, + "Failed execution": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "hostname -f": true, + }, + CommandFunc: func(r *remote.Cmd) error { + if r.Command == "hostname -f" { + r.SetExitStatus(1, &remote.ExitError{ + Command: "hostname -f", + ExitStatus: 1, + Err: nil, + }) + } else { + r.SetExitStatus(0, nil) + } + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + _, err = p.linuxDefaultCertname() + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} + +func TestResourceProvisioner_linuxInstallPuppetAgent(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + }{ + "Everything runs succcessfully": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "curl -kO https://puppet.test.com:8140/packages/current/install.bash": true, + "bash -- ./install.bash --puppet-service-ensure stopped": true, + "rm -f install.bash": true, + }, + }, + "Respects the use_sudo config flag": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": true, + }, + Commands: map[string]bool{ + "sudo curl -kO https://puppet.test.com:8140/packages/current/install.bash": true, + "sudo bash -- ./install.bash --puppet-service-ensure stopped": true, + "sudo rm -f install.bash": true, + }, + }, + "When the curl command fails": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "curl -kO https://puppet.test.com:8140/packages/current/install.bash": true, + "bash -- ./install.bash --puppet-service-ensure stopped": false, + "rm -f install.bash": false, + }, + CommandFunc: func(r *remote.Cmd) error { + if r.Command == "curl -kO https://puppet.test.com:8140/packages/current/install.bash" { + r.SetExitStatus(1, &remote.ExitError{ + Command: "curl -kO https://puppet.test.com:8140/packages/current/install.bash", + ExitStatus: 1, + Err: nil, + }) + } else { + r.SetExitStatus(0, nil) + } + return nil + }, + ExpectedError: true, + }, + "When the install script fails": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "curl -kO https://puppet.test.com:8140/packages/current/install.bash": true, + "bash -- ./install.bash --puppet-service-ensure stopped": true, + "rm -f install.bash": false, + }, + CommandFunc: func(r *remote.Cmd) error { + if r.Command == "bash -- ./install.bash --puppet-service-ensure stopped" { + r.SetExitStatus(1, &remote.ExitError{ + Command: "bash -- ./install.bash --puppet-service-ensure stopped", + ExitStatus: 1, + Err: nil, + }) + } else { + r.SetExitStatus(0, nil) + } + return nil + }, + ExpectedError: true, + }, + "When the cleanup rm fails": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "curl -kO https://puppet.test.com:8140/packages/current/install.bash": true, + "bash -- ./install.bash --puppet-service-ensure stopped": true, + "rm -f install.bash": true, + }, + CommandFunc: func(r *remote.Cmd) error { + if r.Command == "rm -f install.bash" { + r.SetExitStatus(1, &remote.ExitError{ + Command: "rm -f install.bash", + ExitStatus: 1, + Err: nil, + }) + } else { + r.SetExitStatus(0, nil) + } + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + err = p.linuxInstallPuppetAgent() + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} + +func TestResourceProvisioner_linuxRunPuppetAgent(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + }{ + "When puppet returns 0": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + "/opt/puppetlabs/puppet/bin/puppet agent --test --server puppet.test.com --environment production": true, + }, + }, + "When puppet returns 2 (changes applied without error)": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + r.SetExitStatus(2, &remote.ExitError{ + Command: "/opt/puppetlabs/puppet/bin/puppet agent --test --server puppet.test.com", + ExitStatus: 2, + Err: nil, + }) + return nil + }, + ExpectedError: false, + }, + "When puppet returns something not 0 or 2": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + r.SetExitStatus(1, &remote.ExitError{ + Command: "/opt/puppetlabs/puppet/bin/puppet agent --test --server puppet.test.com", + ExitStatus: 1, + Err: nil, + }) + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + err = p.linuxRunPuppetAgent() + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} diff --git a/builtin/provisioners/puppet/resource_provisioner.go b/builtin/provisioners/puppet/resource_provisioner.go new file mode 100644 index 000000000..35c7dd2a3 --- /dev/null +++ b/builtin/provisioners/puppet/resource_provisioner.go @@ -0,0 +1,332 @@ +package puppet + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/hashicorp/terraform/builtin/provisioners/puppet/bolt" + "github.com/hashicorp/terraform/communicator" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/helper/validation" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/go-linereader" + "gopkg.in/yaml.v2" +) + +type provisioner struct { + Server string + ServerUser string + OSType string + Certname string + Environment string + Autosign bool + OpenSource bool + UseSudo bool + BoltTimeout time.Duration + CustomAttributes map[string]interface{} + ExtensionRequests map[string]interface{} + + runPuppetAgent func() error + installPuppetAgent func() error + uploadFile func(f io.Reader, dir string, filename string) error + defaultCertname func() (string, error) + + instanceState *terraform.InstanceState + output terraform.UIOutput + comm communicator.Communicator +} + +type csrAttributes struct { + CustomAttributes map[string]string `yaml:"custom_attributes"` + ExtensionRequests map[string]string `yaml:"extension_requests"` +} + +// Provisioner returns a Puppet resource provisioner. +func Provisioner() terraform.ResourceProvisioner { + return &schema.Provisioner{ + Schema: map[string]*schema.Schema{ + "server": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "server_user": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "root", + }, + "os_type": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"linux", "windows"}, false), + }, + "use_sudo": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "autosign": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "open_source": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "certname": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "extension_requests": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + }, + "custom_attributes": &schema.Schema{ + Type: schema.TypeMap, + Optional: true, + }, + "environment": &schema.Schema{ + Type: schema.TypeString, + Default: "production", + Optional: true, + }, + "bolt_timeout": &schema.Schema{ + Type: schema.TypeString, + Default: "5m", + Optional: true, + ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) { + _, err := time.ParseDuration(val.(string)) + if err != nil { + errs = append(errs, err) + } + return warns, errs + }, + }, + }, + ApplyFunc: applyFn, + } +} + +func applyFn(ctx context.Context) error { + output := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput) + state := ctx.Value(schema.ProvRawStateKey).(*terraform.InstanceState) + configData := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData) + + p, err := decodeConfig(configData) + if err != nil { + return err + } + + p.instanceState = state + p.output = output + + if p.OSType == "" { + switch connType := state.Ephemeral.ConnInfo["type"]; connType { + case "ssh", "": + p.OSType = "linux" + case "winrm": + p.OSType = "windows" + default: + return fmt.Errorf("Unsupported connection type: %s", connType) + } + } + + switch p.OSType { + case "linux": + p.runPuppetAgent = p.linuxRunPuppetAgent + p.installPuppetAgent = p.linuxInstallPuppetAgent + p.uploadFile = p.linuxUploadFile + p.defaultCertname = p.linuxDefaultCertname + case "windows": + p.runPuppetAgent = p.windowsRunPuppetAgent + p.installPuppetAgent = p.windowsInstallPuppetAgent + p.uploadFile = p.windowsUploadFile + p.UseSudo = false + p.defaultCertname = p.windowsDefaultCertname + default: + return fmt.Errorf("Unsupported OS type: %s", p.OSType) + } + + comm, err := communicator.New(state) + if err != nil { + return err + } + + retryCtx, cancel := context.WithTimeout(ctx, comm.Timeout()) + defer cancel() + + err = communicator.Retry(retryCtx, func() error { + return comm.Connect(output) + }) + if err != nil { + return err + } + defer comm.Disconnect() + + p.comm = comm + + if p.OpenSource { + p.installPuppetAgent = p.installPuppetAgentOpenSource + } + + csrAttrs := new(csrAttributes) + csrAttrs.CustomAttributes = make(map[string]string) + for k, v := range p.CustomAttributes { + csrAttrs.CustomAttributes[k] = v.(string) + } + + csrAttrs.ExtensionRequests = make(map[string]string) + for k, v := range p.ExtensionRequests { + csrAttrs.ExtensionRequests[k] = v.(string) + } + + if p.Autosign { + if p.Certname == "" { + p.Certname, _ = p.defaultCertname() + } + + autosignToken, err := p.generateAutosignToken(p.Certname) + if err != nil { + return fmt.Errorf("Failed to generate an autosign token: %s", err) + } + csrAttrs.CustomAttributes["challengePassword"] = autosignToken + } + + if err = p.writeCSRAttributes(csrAttrs); err != nil { + return fmt.Errorf("Failed to write csr_attributes.yaml: %s", err) + } + + if err = p.installPuppetAgent(); err != nil { + return err + } + + if err = p.runPuppetAgent(); err != nil { + return err + } + + return nil +} + +func (p *provisioner) writeCSRAttributes(attrs *csrAttributes) (rerr error) { + content, err := yaml.Marshal(attrs) + if err != nil { + return fmt.Errorf("Failed to marshal CSR attributes to YAML: %s", err) + } + + configDir := map[string]string{ + "linux": "/etc/puppetlabs/puppet", + "windows": "C:\\ProgramData\\PuppetLabs\\Puppet\\etc", + } + + return p.uploadFile(bytes.NewBuffer(content), configDir[p.OSType], "csr_attributes.yaml") +} + +func (p *provisioner) generateAutosignToken(certname string) (string, error) { + task := "autosign::generate_token" + + masterConnInfo := map[string]string{ + "type": "ssh", + "host": p.Server, + "user": p.ServerUser, + } + + result, err := bolt.Task( + masterConnInfo, + p.BoltTimeout, + p.ServerUser != "root", + task, + map[string]string{"certname": certname}, + ) + if err != nil { + return "", err + } + + if result.Items[0].Status != "success" { + return "", fmt.Errorf("Bolt %s failed on %s: %v", + task, + result.Items[0].Node, + result.Items[0].Result["_error"], + ) + } + + return result.Items[0].Result["_output"], nil +} + +func (p *provisioner) installPuppetAgentOpenSource() error { + result, err := bolt.Task( + p.instanceState.Ephemeral.ConnInfo, + p.BoltTimeout, + p.UseSudo, + "puppet_agent::install", + nil, + ) + + if err != nil || result.Items[0].Status != "success" { + return fmt.Errorf("puppet_agent::install failed: %s\n%+v", err, result) + } + + return nil +} + +func (p *provisioner) runCommand(command string) (stdout string, err error) { + if p.UseSudo { + command = "sudo " + command + } + + var stdoutBuffer bytes.Buffer + outR, outW := io.Pipe() + errR, errW := io.Pipe() + outTee := io.TeeReader(outR, &stdoutBuffer) + go p.copyToOutput(outTee) + go p.copyToOutput(errR) + defer outW.Close() + defer errW.Close() + + cmd := &remote.Cmd{ + Command: command, + Stdout: outW, + Stderr: errW, + } + + err = p.comm.Start(cmd) + if err != nil { + err = fmt.Errorf("Error executing command %q: %v", cmd.Command, err) + return stdout, err + } + + err = cmd.Wait() + stdout = strings.TrimSpace(stdoutBuffer.String()) + + return stdout, err +} + +func (p *provisioner) copyToOutput(reader io.Reader) { + lr := linereader.New(reader) + for line := range lr.Ch { + p.output.Output(line) + } +} + +func decodeConfig(d *schema.ResourceData) (*provisioner, error) { + p := &provisioner{ + UseSudo: d.Get("use_sudo").(bool), + Server: d.Get("server").(string), + ServerUser: d.Get("server_user").(string), + OSType: strings.ToLower(d.Get("os_type").(string)), + Autosign: d.Get("autosign").(bool), + OpenSource: d.Get("open_source").(bool), + Certname: strings.ToLower(d.Get("certname").(string)), + ExtensionRequests: d.Get("extension_requests").(map[string]interface{}), + CustomAttributes: d.Get("custom_attributes").(map[string]interface{}), + Environment: d.Get("environment").(string), + } + p.BoltTimeout, _ = time.ParseDuration(d.Get("bolt_timeout").(string)) + + return p, nil +} diff --git a/builtin/provisioners/puppet/resource_provisioner_test.go b/builtin/provisioners/puppet/resource_provisioner_test.go new file mode 100644 index 000000000..866d663b0 --- /dev/null +++ b/builtin/provisioners/puppet/resource_provisioner_test.go @@ -0,0 +1,129 @@ +package puppet + +import ( + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +func TestResourceProvisioner_impl(t *testing.T) { + var _ terraform.ResourceProvisioner = Provisioner() +} + +func TestProvisioner(t *testing.T) { + if err := Provisioner().(*schema.Provisioner).InternalValidate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisioner_Validate_good_server(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "server": "puppet.test.com", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) > 0 { + t.Fatalf("Errors: %v", errs) + } +} + +func TestProvisioner_Validate_bad_no_server(t *testing.T) { + c := testConfig(t, map[string]interface{}{}) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) == 0 { + t.Fatalf("Should have errors") + } +} + +func TestProvisioner_Validate_bad_os_type(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "server": "puppet.test.com", + "os_type": "OS/2", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) == 0 { + t.Fatalf("Should have errors") + } +} + +func TestProvisioner_Validate_good_os_type_linux(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "server": "puppet.test.com", + "os_type": "linux", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) > 0 { + t.Fatalf("Errors: %v", errs) + } +} + +func TestProvisioner_Validate_good_os_type_windows(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "server": "puppet.test.com", + "os_type": "windows", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) > 0 { + t.Fatalf("Errors: %v", errs) + } +} + +func TestProvisioner_Validate_bad_bolt_timeout(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "server": "puppet.test.com", + "bolt_timeout": "123oeau", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) == 0 { + t.Fatalf("Should have errors") + } +} + +func TestProvisioner_Validate_good_bolt_timeout(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "server": "puppet.test.com", + "bolt_timeout": "123m", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) > 0 { + t.Fatalf("Errors: %v", warn) + } +} + +func testConfig(t *testing.T, c map[string]interface{}) *terraform.ResourceConfig { + r, err := config.NewRawConfig(c) + if err != nil { + t.Fatalf("bad: %s", err) + } + + return terraform.NewResourceConfig(r) +} diff --git a/builtin/provisioners/puppet/windows_provisioner.go b/builtin/provisioners/puppet/windows_provisioner.go new file mode 100644 index 000000000..eeb2154e4 --- /dev/null +++ b/builtin/provisioners/puppet/windows_provisioner.go @@ -0,0 +1,71 @@ +package puppet + +import ( + "fmt" + "io" + "strings" + + "github.com/hashicorp/terraform/communicator/remote" +) + +const ( + getHostByName = "([System.Net.Dns]::GetHostByName(($env:computerName))).Hostname" + domainQuery = "(Get-WmiObject -Query 'select DNSDomain from Win32_NetworkAdapterConfiguration where IPEnabled = True').DNSDomain" +) + +func (p *provisioner) windowsUploadFile(f io.Reader, dir string, filename string) error { + _, err := p.runCommand("powershell.exe new-item -itemtype directory -force -path " + dir) + if err != nil { + return fmt.Errorf("Failed to make directory %s: %s", dir, err) + } + + return p.comm.Upload(dir+"\\"+filename, f) +} + +func (p *provisioner) windowsDefaultCertname() (string, error) { + certname, err := p.runCommand(fmt.Sprintf(`powershell -Command "& {%s}"`, getHostByName)) + if err != nil { + return "", err + } + + // Sometimes System.Net.Dns::GetHostByName does not return a full FQDN, so + // we have to look up the domain separately. + if strings.Contains(certname, ".") { + return certname, nil + } + + domain, err := p.runCommand(fmt.Sprintf(`powershell -Command "& {%s}"`, domainQuery)) + if err != nil { + return "", err + } + + return strings.ToLower(certname + "." + domain), nil +} + +func (p *provisioner) windowsInstallPuppetAgent() error { + _, err := p.runCommand(fmt.Sprintf( + `powershell -Command "& {[Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}; `+ + `(New-Object System.Net.WebClient).DownloadFile('https://%s:8140/packages/current/install.ps1', `+ + `'install.ps1')}"`, + p.Server, + )) + if err != nil { + return err + } + + _, err = p.runCommand(`powershell -Command "& .\install.ps1 -PuppetServiceEnsure stopped"`) + + return err +} + +func (p *provisioner) windowsRunPuppetAgent() error { + _, err := p.runCommand(fmt.Sprintf("puppet agent --test --server %s --environment %s", p.Server, p.Environment)) + if err != nil { + errStruct, _ := err.(*remote.ExitError) + if errStruct.ExitStatus == 2 { + return nil + } + } + + return err +} diff --git a/builtin/provisioners/puppet/windows_provisioner_test.go b/builtin/provisioners/puppet/windows_provisioner_test.go new file mode 100644 index 000000000..5c823ab51 --- /dev/null +++ b/builtin/provisioners/puppet/windows_provisioner_test.go @@ -0,0 +1,393 @@ +package puppet + +import ( + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/hashicorp/terraform/communicator" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +const ( + getHostByNameCmd = `powershell -Command "& {([System.Net.Dns]::GetHostByName(($env:computerName))).Hostname}"` + domainQueryCmd = `powershell -Command "& {(Get-WmiObject -Query 'select DNSDomain from Win32_NetworkAdapterConfiguration where IPEnabled = True').DNSDomain}"` + downloadInstallerCmd = `powershell -Command "& {[Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}; (New-Object System.Net.WebClient).DownloadFile('https://puppet.test.com:8140/packages/current/install.ps1', 'install.ps1')}"` + runInstallerCmd = `powershell -Command "& .\install.ps1 -PuppetServiceEnsure stopped"` + runPuppetCmd = "puppet agent --test --server puppet.test.com --environment production" +) + +func TestResourceProvisioner_windowsUploadFile(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + Uploads map[string]string + File io.Reader + Dir string + Filename string + }{ + "Successful upload": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + `powershell.exe new-item -itemtype directory -force -path C:\ProgramData\PuppetLabs\puppet\etc`: true, + }, + Uploads: map[string]string{ + `C:\ProgramData\PuppetLabs\puppet\etc\csr_attributes.yaml`: "", + }, + Dir: `C:\ProgramData\PuppetLabs\puppet\etc`, + Filename: "csr_attributes.yaml", + File: strings.NewReader(""), + }, + "Failure when creating the directory": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + `powershell.exe new-item -itemtype directory -force -path C:\ProgramData\PuppetLabs\puppet\etc`: true, + }, + Dir: `C:\ProgramData\PuppetLabs\puppet\etc`, + Filename: "csr_attributes.yaml", + File: strings.NewReader(""), + CommandFunc: func(r *remote.Cmd) error { + r.SetExitStatus(1, &remote.ExitError{ + Command: `powershell.exe new-item -itemtype directory -force -path C:\ProgramData\PuppetLabs\puppet\etc`, + ExitStatus: 1, + Err: nil, + }) + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + c.Uploads = tc.Uploads + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + err = p.windowsUploadFile(tc.File, tc.Dir, tc.Filename) + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} + +func TestResourceProvisioner_windowsDefaultCertname(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + }{ + "GetHostByName failure": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + switch r.Command { + case getHostByNameCmd: + r.SetExitStatus(1, &remote.ExitError{ + Command: getHostByNameCmd, + ExitStatus: 1, + Err: nil, + }) + default: + return fmt.Errorf("Command not found!") + } + + return nil + }, + ExpectedError: true, + }, + "GetHostByName returns FQDN": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + switch r.Command { + case getHostByNameCmd: + r.Stdout.Write([]byte("example.test.com\n")) + time.Sleep(200 * time.Millisecond) + r.SetExitStatus(0, nil) + default: + return fmt.Errorf("Command not found!") + } + + return nil + }, + }, + "GetHostByName returns hostname, DNSDomain query succeeds": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + switch r.Command { + case getHostByNameCmd: + r.Stdout.Write([]byte("example\n")) + time.Sleep(200 * time.Millisecond) + r.SetExitStatus(0, nil) + case domainQueryCmd: + r.Stdout.Write([]byte("test.com\n")) + time.Sleep(200 * time.Millisecond) + r.SetExitStatus(0, nil) + default: + return fmt.Errorf("Command not found!") + } + + return nil + }, + }, + "GetHostByName returns hostname, DNSDomain query fails": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + switch r.Command { + case getHostByNameCmd: + r.Stdout.Write([]byte("example\n")) + time.Sleep(200 * time.Millisecond) + r.SetExitStatus(0, nil) + case domainQueryCmd: + r.SetExitStatus(1, &remote.ExitError{ + Command: domainQueryCmd, + ExitStatus: 1, + Err: nil, + }) + default: + return fmt.Errorf("Command not found!") + } + + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + _, err = p.windowsDefaultCertname() + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} + +func TestResourceProvisioner_windowsInstallPuppetAgent(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + }{ + "Everything runs succcessfully": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + downloadInstallerCmd: true, + runInstallerCmd: true, + }, + }, + "Installer download fails": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": true, + }, + CommandFunc: func(r *remote.Cmd) error { + switch r.Command { + case downloadInstallerCmd: + r.SetExitStatus(1, &remote.ExitError{ + Command: downloadInstallerCmd, + ExitStatus: 1, + Err: nil, + }) + default: + return fmt.Errorf("Command not found!") + } + + return nil + }, + ExpectedError: true, + }, + "Install script fails": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + switch r.Command { + case downloadInstallerCmd: + r.SetExitStatus(0, nil) + case runInstallerCmd: + r.SetExitStatus(1, &remote.ExitError{ + Command: runInstallerCmd, + ExitStatus: 1, + Err: nil, + }) + default: + return fmt.Errorf("Command not found!") + } + + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + err = p.windowsInstallPuppetAgent() + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} + +func TestResourceProvisioner_windowsRunPuppetAgent(t *testing.T) { + cases := map[string]struct { + Config map[string]interface{} + Commands map[string]bool + CommandFunc func(*remote.Cmd) error + ExpectedError bool + }{ + "When puppet returns 0": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + Commands: map[string]bool{ + runPuppetCmd: true, + }, + }, + "When puppet returns 2 (changes applied without error)": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + r.SetExitStatus(2, &remote.ExitError{ + Command: runPuppetCmd, + ExitStatus: 2, + Err: nil, + }) + return nil + }, + }, + "When puppet returns something not 0 or 2": { + Config: map[string]interface{}{ + "server": "puppet.test.com", + "use_sudo": false, + }, + CommandFunc: func(r *remote.Cmd) error { + r.SetExitStatus(1, &remote.ExitError{ + Command: runPuppetCmd, + ExitStatus: 1, + Err: nil, + }) + return nil + }, + ExpectedError: true, + }, + } + + for k, tc := range cases { + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, tc.Config), + ) + if err != nil { + t.Fatalf("Error: %v", err) + } + + c := new(communicator.MockCommunicator) + c.Commands = tc.Commands + if tc.CommandFunc != nil { + c.CommandFunc = tc.CommandFunc + } + p.comm = c + p.output = new(terraform.MockUIOutput) + + err = p.windowsRunPuppetAgent() + if tc.ExpectedError { + if err == nil { + t.Fatalf("Expected error, but no error returned") + } + } else { + if err != nil { + t.Fatalf("Test %q failed: %v", k, err) + } + } + } +} diff --git a/command/internal_plugin_list.go b/command/internal_plugin_list.go index 7993e9a54..e26c2c192 100644 --- a/command/internal_plugin_list.go +++ b/command/internal_plugin_list.go @@ -8,6 +8,7 @@ import ( fileprovisioner "github.com/hashicorp/terraform/builtin/provisioners/file" habitatprovisioner "github.com/hashicorp/terraform/builtin/provisioners/habitat" localexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/local-exec" + puppetprovisioner "github.com/hashicorp/terraform/builtin/provisioners/puppet" remoteexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/remote-exec" saltmasterlessprovisioner "github.com/hashicorp/terraform/builtin/provisioners/salt-masterless" @@ -21,6 +22,7 @@ var InternalProvisioners = map[string]plugin.ProvisionerFunc{ "file": fileprovisioner.Provisioner, "habitat": habitatprovisioner.Provisioner, "local-exec": localexecprovisioner.Provisioner, + "puppet": puppetprovisioner.Provisioner, "remote-exec": remoteexecprovisioner.Provisioner, "salt-masterless": saltmasterlessprovisioner.Provisioner, }