provisioner: new Puppet provisioner (#18851)

* Basic Puppet provisioner

* (fixup) fix snake_case use in Bolt

* (fixup) Remove unused ValidateFunc

* (fixup) Check bolt result status

* (lint) go fmt

* Requested changes

* Remove PE autodetection

* Apply suggestions from @svanharmelen

Co-Authored-By: rodjek <tim@sharpe.id.au>

* Tag all JSON fields in bolt output

* Defer comm.Disconnect() as suggested

* Make bolt timeout configurable

* Update builtin/provisioners/puppet/resource_provisioner.go

Co-Authored-By: rodjek <tim@sharpe.id.au>

* Make extension_requests and custom_attributes configurable
This commit is contained in:
Tim Sharpe 2019-06-11 05:31:21 +10:00 committed by Kristin Laemmert
parent 0abb0a05fb
commit 615110e13e
9 changed files with 1457 additions and 0 deletions

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}
}
}
}

View File

@ -8,6 +8,7 @@ import (
fileprovisioner "github.com/hashicorp/terraform/builtin/provisioners/file" fileprovisioner "github.com/hashicorp/terraform/builtin/provisioners/file"
habitatprovisioner "github.com/hashicorp/terraform/builtin/provisioners/habitat" habitatprovisioner "github.com/hashicorp/terraform/builtin/provisioners/habitat"
localexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/local-exec" 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" remoteexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/remote-exec"
saltmasterlessprovisioner "github.com/hashicorp/terraform/builtin/provisioners/salt-masterless" saltmasterlessprovisioner "github.com/hashicorp/terraform/builtin/provisioners/salt-masterless"
@ -21,6 +22,7 @@ var InternalProvisioners = map[string]plugin.ProvisionerFunc{
"file": fileprovisioner.Provisioner, "file": fileprovisioner.Provisioner,
"habitat": habitatprovisioner.Provisioner, "habitat": habitatprovisioner.Provisioner,
"local-exec": localexecprovisioner.Provisioner, "local-exec": localexecprovisioner.Provisioner,
"puppet": puppetprovisioner.Provisioner,
"remote-exec": remoteexecprovisioner.Provisioner, "remote-exec": remoteexecprovisioner.Provisioner,
"salt-masterless": saltmasterlessprovisioner.Provisioner, "salt-masterless": saltmasterlessprovisioner.Provisioner,
} }