diff --git a/builtin/bins/provisioner-salt-masterless/main.go b/builtin/bins/provisioner-salt-masterless/main.go new file mode 100644 index 000000000..b7d683411 --- /dev/null +++ b/builtin/bins/provisioner-salt-masterless/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/hashicorp/terraform/builtin/provisioners/salt-masterless" + "github.com/hashicorp/terraform/plugin" +) + +func main() { + plugin.Serve(&plugin.ServeOpts{ + ProvisionerFunc: saltmasterless.Provisioner, + }) +} diff --git a/builtin/provisioners/salt-masterless/resource_provisioner.go b/builtin/provisioners/salt-masterless/resource_provisioner.go new file mode 100644 index 000000000..5ce12e600 --- /dev/null +++ b/builtin/provisioners/salt-masterless/resource_provisioner.go @@ -0,0 +1,467 @@ +// This package implements a provisioner for Terraform that executes a +// saltstack state within the remote machine +// +// Adapted from gitub.com/hashicorp/packer/provisioner/salt-masterless + +package saltmasterless + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/hashicorp/terraform/communicator" + "github.com/hashicorp/terraform/communicator/remote" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +type provisionFn func(terraform.UIOutput, communicator.Communicator) error + +type provisioner struct { + SkipBootstrap bool + BootstrapArgs string + LocalStateTree string + DisableSudo bool + CustomState string + MinionConfig string + LocalPillarRoots string + RemoteStateTree string + RemotePillarRoots string + TempConfigDir string + NoExitOnFailure bool + LogLevel string + SaltCallArgs string + CmdArgs string +} + +const DefaultStateTreeDir = "/srv/salt" +const DefaultPillarRootDir = "/srv/pillar" + +// Provisioner returns a salt-masterless provisioner +func Provisioner() terraform.ResourceProvisioner { + return &schema.Provisioner{ + Schema: map[string]*schema.Schema{ + "local_state_tree": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "local_pillar_roots": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "remote_state_tree": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "remote_pillar_roots": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "temp_config_dir": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + Default: "/tmp/salt", + }, + "skip_bootstrap": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + "no_exit_on_failure": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + "bootstrap_args": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "disable_sudo": &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + }, + "custom_state": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "minion_config": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "cmd_args": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "salt_call_args": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "log_level": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + }, + + ApplyFunc: applyFn, + ValidateFunc: validateFn, + } +} + +// Apply executes the file provisioner +func applyFn(ctx context.Context) error { + // Decode the raw config for this provisioner + var err error + + o := ctx.Value(schema.ProvOutputKey).(terraform.UIOutput) + d := ctx.Value(schema.ProvConfigDataKey).(*schema.ResourceData) + + p, err := decodeConfig(d) + if err != nil { + return err + } + + // Get a new communicator + comm, err := communicator.New(d.State()) + if err != nil { + return err + } + + var src, dst string + + o.Output("Provisioning with Salt...") + if !p.SkipBootstrap { + cmd := &remote.Cmd{ + // Fallback on wget if curl failed for any reason (such as not being installed) + Command: fmt.Sprintf("curl -L https://bootstrap.saltstack.com -o /tmp/install_salt.sh || wget -O /tmp/install_salt.sh https://bootstrap.saltstack.com"), + } + o.Output(fmt.Sprintf("Downloading saltstack bootstrap to /tmp/install_salt.sh")) + if err = comm.Start(cmd); err != nil { + return fmt.Errorf("Unable to download Salt: %s", err) + } + cmd = &remote.Cmd{ + Command: fmt.Sprintf("%s /tmp/install_salt.sh %s", p.sudo("sh"), p.BootstrapArgs), + } + o.Output(fmt.Sprintf("Installing Salt with command %s", cmd.Command)) + if err = comm.Start(cmd); err != nil { + return fmt.Errorf("Unable to install Salt: %s", err) + } + } + + o.Output(fmt.Sprintf("Creating remote temporary directory: %s", p.TempConfigDir)) + if err := p.createDir(o, comm, p.TempConfigDir); err != nil { + return fmt.Errorf("Error creating remote temporary directory: %s", err) + } + + if p.MinionConfig != "" { + o.Output(fmt.Sprintf("Uploading minion config: %s", p.MinionConfig)) + src = p.MinionConfig + dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "minion")) + if err = p.uploadFile(o, comm, dst, src); err != nil { + return fmt.Errorf("Error uploading local minion config file to remote: %s", err) + } + + // move minion config into /etc/salt + o.Output(fmt.Sprintf("Make sure directory %s exists", "/etc/salt")) + if err := p.createDir(o, comm, "/etc/salt"); err != nil { + return fmt.Errorf("Error creating remote salt configuration directory: %s", err) + } + src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "minion")) + dst = "/etc/salt/minion" + if err = p.moveFile(o, comm, dst, src); err != nil { + return fmt.Errorf("Unable to move %s/minion to /etc/salt/minion: %s", p.TempConfigDir, err) + } + } + + o.Output(fmt.Sprintf("Uploading local state tree: %s", p.LocalStateTree)) + src = p.LocalStateTree + dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "states")) + if err = p.uploadDir(o, comm, dst, src, []string{".git"}); err != nil { + return fmt.Errorf("Error uploading local state tree to remote: %s", err) + } + + // move state tree from temporary directory + src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "states")) + dst = p.RemoteStateTree + if err = p.removeDir(o, comm, dst); err != nil { + return fmt.Errorf("Unable to clear salt tree: %s", err) + } + if err = p.moveFile(o, comm, dst, src); err != nil { + return fmt.Errorf("Unable to move %s/states to %s: %s", p.TempConfigDir, dst, err) + } + + if p.LocalPillarRoots != "" { + o.Output(fmt.Sprintf("Uploading local pillar roots: %s", p.LocalPillarRoots)) + src = p.LocalPillarRoots + dst = filepath.ToSlash(filepath.Join(p.TempConfigDir, "pillar")) + if err = p.uploadDir(o, comm, dst, src, []string{".git"}); err != nil { + return fmt.Errorf("Error uploading local pillar roots to remote: %s", err) + } + + // move pillar root from temporary directory + src = filepath.ToSlash(filepath.Join(p.TempConfigDir, "pillar")) + dst = p.RemotePillarRoots + + if err = p.removeDir(o, comm, dst); err != nil { + return fmt.Errorf("Unable to clear pillar root: %s", err) + } + if err = p.moveFile(o, comm, dst, src); err != nil { + return fmt.Errorf("Unable to move %s/pillar to %s: %s", p.TempConfigDir, dst, err) + } + } + + o.Output(fmt.Sprintf("Running: salt-call --local %s", p.CmdArgs)) + cmd := &remote.Cmd{Command: p.sudo(fmt.Sprintf("salt-call --local %s", p.CmdArgs))} + if err = comm.Start(cmd); err != nil || cmd.ExitStatus != 0 { + if err == nil { + err = fmt.Errorf("Bad exit status: %d", cmd.ExitStatus) + } + + return fmt.Errorf("Error executing salt-call: %s", err) + } + + return nil +} + +// Prepends sudo to supplied command if config says to +func (p *provisioner) sudo(cmd string) string { + if p.DisableSudo { + return cmd + } + + return "sudo " + cmd +} + +func validateDirConfig(path string, name string, required bool) error { + if required == true && path == "" { + return fmt.Errorf("%s cannot be empty", name) + } else if required == false && path == "" { + return nil + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("%s: path '%s' is invalid: %s", name, path, err) + } else if !info.IsDir() { + return fmt.Errorf("%s: path '%s' must point to a directory", name, path) + } + return nil +} + +func validateFileConfig(path string, name string, required bool) error { + if required == true && path == "" { + return fmt.Errorf("%s cannot be empty", name) + } else if required == false && path == "" { + return nil + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("%s: path '%s' is invalid: %s", name, path, err) + } else if info.IsDir() { + return fmt.Errorf("%s: path '%s' must point to a file", name, path) + } + return nil +} + +func (p *provisioner) uploadFile(o terraform.UIOutput, comm communicator.Communicator, dst, src string) error { + f, err := os.Open(src) + if err != nil { + return fmt.Errorf("Error opening: %s", err) + } + defer f.Close() + + if err = comm.Upload(dst, f); err != nil { + return fmt.Errorf("Error uploading %s: %s", src, err) + } + return nil +} + +func (p *provisioner) moveFile(o terraform.UIOutput, comm communicator.Communicator, dst, src string) error { + o.Output(fmt.Sprintf("Moving %s to %s", src, dst)) + cmd := &remote.Cmd{Command: fmt.Sprintf(p.sudo("mv %s %s"), src, dst)} + if err := comm.Start(cmd); err != nil || cmd.ExitStatus != 0 { + if err == nil { + err = fmt.Errorf("Bad exit status: %d", cmd.ExitStatus) + } + + return fmt.Errorf("Unable to move %s to %s: %s", src, dst, err) + } + return nil +} + +func (p *provisioner) createDir(o terraform.UIOutput, comm communicator.Communicator, dir string) error { + o.Output(fmt.Sprintf("Creating directory: %s", dir)) + cmd := &remote.Cmd{ + Command: fmt.Sprintf("mkdir -p '%s'", dir), + } + if err := comm.Start(cmd); err != nil { + return err + } + if cmd.ExitStatus != 0 { + return fmt.Errorf("Non-zero exit status.") + } + return nil +} + +func (p *provisioner) removeDir(o terraform.UIOutput, comm communicator.Communicator, dir string) error { + o.Output(fmt.Sprintf("Removing directory: %s", dir)) + cmd := &remote.Cmd{ + Command: fmt.Sprintf("rm -rf '%s'", dir), + } + if err := comm.Start(cmd); err != nil { + return err + } + if cmd.ExitStatus != 0 { + return fmt.Errorf("Non-zero exit status.") + } + return nil +} + +func (p *provisioner) uploadDir(o terraform.UIOutput, comm communicator.Communicator, dst, src string, ignore []string) error { + if err := p.createDir(o, comm, dst); err != nil { + return err + } + + // Make sure there is a trailing "/" so that the directory isn't + // created on the other side. + if src[len(src)-1] != '/' { + src = src + "/" + } + return comm.UploadDir(dst, src) +} + +// Validate checks if the required arguments are configured +func validateFn(c *terraform.ResourceConfig) (ws []string, es []error) { + // require a salt state tree + localStateTreeTmp, ok := c.Get("local_state_tree") + var localStateTree string + if !ok { + es = append(es, + errors.New("Required local_state_tree is not set")) + } else { + localStateTree = localStateTreeTmp.(string) + } + err := validateDirConfig(localStateTree, "local_state_tree", true) + if err != nil { + es = append(es, err) + } + + var localPillarRoots string + localPillarRootsTmp, ok := c.Get("local_pillar_roots") + if !ok { + localPillarRoots = "" + } else { + localPillarRoots = localPillarRootsTmp.(string) + } + + err = validateDirConfig(localPillarRoots, "local_pillar_roots", false) + if err != nil { + es = append(es, err) + } + + var minionConfig string + minionConfigTmp, ok := c.Get("minion_config") + if !ok { + minionConfig = "" + } else { + minionConfig = minionConfigTmp.(string) + } + err = validateFileConfig(minionConfig, "minion_config", false) + if err != nil { + es = append(es, err) + } + + var remoteStateTree string + remoteStateTreeTmp, ok := c.Get("remote_state_tree") + if !ok { + remoteStateTree = "" + } else { + remoteStateTree = remoteStateTreeTmp.(string) + } + + var remotePillarRoots string + remotePillarRootsTmp, ok := c.Get("remote_pillar_roots") + if !ok { + remotePillarRoots = "" + } else { + remotePillarRoots = remotePillarRootsTmp.(string) + } + + if minionConfig != "" && (remoteStateTree != "" || remotePillarRoots != "") { + es = append(es, + errors.New("remote_state_tree and remote_pillar_roots only apply when minion_config is not used")) + } + + if len(es) > 0 { + return ws, es + } + + return ws, es +} + +func decodeConfig(d *schema.ResourceData) (*provisioner, error) { + p := &provisioner{ + LocalStateTree: d.Get("local_state_tree").(string), + LogLevel: d.Get("log_level").(string), + SaltCallArgs: d.Get("salt_call_args").(string), + CmdArgs: d.Get("cmd_args").(string), + MinionConfig: d.Get("minion_config").(string), + CustomState: d.Get("custom_state").(string), + DisableSudo: d.Get("disable_sudo").(bool), + BootstrapArgs: d.Get("bootstrap_args").(string), + NoExitOnFailure: d.Get("no_exit_on_failure").(bool), + SkipBootstrap: d.Get("skip_bootstrap").(bool), + TempConfigDir: d.Get("temp_config_dir").(string), + RemotePillarRoots: d.Get("remote_pillar_roots").(string), + RemoteStateTree: d.Get("remote_state_tree").(string), + LocalPillarRoots: d.Get("local_pillar_roots").(string), + } + + // build the command line args to pass onto salt + var cmdArgs bytes.Buffer + + if p.CustomState == "" { + cmdArgs.WriteString(" state.highstate") + } else { + cmdArgs.WriteString(" state.sls ") + cmdArgs.WriteString(p.CustomState) + } + + if p.MinionConfig == "" { + // pass --file-root and --pillar-root if no minion_config is supplied + if p.RemoteStateTree != "" { + cmdArgs.WriteString(" --file-root=") + cmdArgs.WriteString(p.RemoteStateTree) + } else { + cmdArgs.WriteString(" --file-root=") + cmdArgs.WriteString(DefaultStateTreeDir) + } + if p.RemotePillarRoots != "" { + cmdArgs.WriteString(" --pillar-root=") + cmdArgs.WriteString(p.RemotePillarRoots) + } else { + cmdArgs.WriteString(" --pillar-root=") + cmdArgs.WriteString(DefaultPillarRootDir) + } + } + + if !p.NoExitOnFailure { + cmdArgs.WriteString(" --retcode-passthrough") + } + + if p.LogLevel == "" { + cmdArgs.WriteString(" -l info") + } else { + cmdArgs.WriteString(" -l ") + cmdArgs.WriteString(p.LogLevel) + } + + if p.SaltCallArgs != "" { + cmdArgs.WriteString(" ") + cmdArgs.WriteString(p.SaltCallArgs) + } + + p.CmdArgs = cmdArgs.String() + + return p, nil +} diff --git a/builtin/provisioners/salt-masterless/resource_provisioner_test.go b/builtin/provisioners/salt-masterless/resource_provisioner_test.go new file mode 100644 index 000000000..a8bc0c935 --- /dev/null +++ b/builtin/provisioners/salt-masterless/resource_provisioner_test.go @@ -0,0 +1,458 @@ +package saltmasterless + +import ( + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/terraform" +) + +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) +} + +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 TestResourceProvisioner_Validate_good(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + defer os.RemoveAll(dir) // clean up + + c := testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + }) + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) > 0 { + t.Fatalf("Errors: %v", errs) + } +} + +func TestResourceProvider_Validate_missing_required(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "remote_state_tree": "_default", + }) + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) == 0 { + t.Fatalf("Should have errors") + } +} + +func TestResourceProvider_Validate_LocalStateTree_doesnt_exist(t *testing.T) { + c := testConfig(t, map[string]interface{}{ + "local_state_tree": "/i/dont/exist", + }) + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) == 0 { + t.Fatalf("Should have errors") + } +} + +func TestResourceProvisioner_Validate_invalid(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + defer os.RemoveAll(dir) // clean up + + c := testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + "i_am_not_valid": "_invalid", + }) + + warn, errs := Provisioner().Validate(c) + if len(warn) > 0 { + t.Fatalf("Warnings: %v", warn) + } + if len(errs) == 0 { + t.Fatalf("Should have errors") + } +} + +func TestProvisionerPrepare_CustomState(t *testing.T) { + c := map[string]interface{}{ + "local_state_tree": "/tmp/local_state_tree", + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("Error: %v", err) + } + + if !strings.Contains(p.CmdArgs, "state.highstate") { + t.Fatal("CmdArgs should contain state.highstate") + } + + if err != nil { + t.Fatalf("err: %s", err) + } + + c = map[string]interface{}{ + "local_state_tree": "/tmp/local_state_tree", + "custom_state": "custom", + } + + p, err = decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("Error: %v", err) + } + + if !strings.Contains(p.CmdArgs, "state.sls custom") { + t.Fatal("CmdArgs should contain state.sls custom") + } + + if err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestProvisionerPrepare_MinionConfig(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + defer os.RemoveAll(dir) // clean up + + c := testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + "minion_config": "i/dont/exist", + }) + + warns, errs := Provisioner().Validate(c) + + if len(warns) > 0 { + t.Fatalf("Warnings: %v", warns) + } + if len(errs) == 0 { + t.Fatalf("Should have error") + } + + tf, err := ioutil.TempFile("", "minion") + if err != nil { + t.Fatalf("error tempfile: %s", err) + } + + defer os.Remove(tf.Name()) + + c = testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + "minion_config": tf.Name(), + }) + + warns, errs = Provisioner().Validate(c) + + if len(warns) > 0 { + t.Fatalf("Warnings: %v", warns) + } + if len(errs) > 0 { + t.Fatalf("errs: %s", errs) + } +} + +func TestProvisionerPrepare_MinionConfig_RemoteStateTree(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + "minion_config": "i/dont/exist", + "remote_state_tree": "i/dont/exist/remote_state_tree", + }) + + warns, errs := Provisioner().Validate(c) + if len(warns) > 0 { + t.Fatalf("Warnings: %v", warns) + } + if len(errs) == 0 { + t.Fatalf("Should be error") + } +} + +func TestProvisionerPrepare_MinionConfig_RemotePillarRoots(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + "minion_config": "i/dont/exist", + "remote_pillar_roots": "i/dont/exist/remote_pillar_roots", + }) + + warns, errs := Provisioner().Validate(c) + if len(warns) > 0 { + t.Fatalf("Warnings: %v", warns) + } + if len(errs) == 0 { + t.Fatalf("Should be error") + } +} + +func TestProvisionerPrepare_LocalPillarRoots(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := testConfig(t, map[string]interface{}{ + "local_state_tree": dir, + "minion_config": "i/dont/exist", + "local_pillar_roots": "i/dont/exist/local_pillar_roots", + }) + + warns, errs := Provisioner().Validate(c) + if len(warns) > 0 { + t.Fatalf("Warnings: %v", warns) + } + if len(errs) == 0 { + t.Fatalf("Should be error") + } +} + +func TestProvisionerSudo(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + withSudo := p.sudo("echo hello") + if withSudo != "sudo echo hello" { + t.Fatalf("sudo command not generated correctly") + } + + c = map[string]interface{}{ + "local_state_tree": dir, + "disable_sudo": "true", + } + + p, err = decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + withoutSudo := p.sudo("echo hello") + if withoutSudo != "echo hello" { + t.Fatalf("sudo-less command not generated correctly") + } +} + +func TestProvisionerPrepare_RemoteStateTree(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + "remote_state_tree": "/remote_state_tree", + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "--file-root=/remote_state_tree") { + t.Fatal("--file-root should be set in CmdArgs") + } +} + +func TestProvisionerPrepare_RemotePillarRoots(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + "remote_pillar_roots": "/remote_pillar_roots", + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "--pillar-root=/remote_pillar_roots") { + t.Fatal("--pillar-root should be set in CmdArgs") + } +} + +func TestProvisionerPrepare_RemoteStateTree_Default(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "--file-root=/srv/salt") { + t.Fatal("--file-root should be set in CmdArgs") + } +} + +func TestProvisionerPrepare_RemotePillarRoots_Default(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "--pillar-root=/srv/pillar") { + t.Fatal("--pillar-root should be set in CmdArgs") + } +} + +func TestProvisionerPrepare_NoExitOnFailure(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "--retcode-passthrough") { + t.Fatal("--retcode-passthrough should be set in CmdArgs") + } + + c = map[string]interface{}{ + "no_exit_on_failure": true, + } + + p, err = decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if strings.Contains(p.CmdArgs, "--retcode-passthrough") { + t.Fatal("--retcode-passthrough should not be set in CmdArgs") + } +} + +func TestProvisionerPrepare_LogLevel(t *testing.T) { + dir, err := ioutil.TempDir("", "_terraform_saltmasterless_test") + if err != nil { + t.Fatalf("Error when creating temp dir: %v", err) + } + + c := map[string]interface{}{ + "local_state_tree": dir, + } + + p, err := decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "-l info") { + t.Fatal("-l info should be set in CmdArgs") + } + + c = map[string]interface{}{ + "log_level": "debug", + } + + p, err = decodeConfig( + schema.TestResourceDataRaw(t, Provisioner().(*schema.Provisioner).Schema, c), + ) + + if err != nil { + t.Fatalf("err: %s", err) + } + + if !strings.Contains(p.CmdArgs, "-l debug") { + t.Fatal("-l debug should be set in CmdArgs") + } +} diff --git a/command/internal_plugin_list.go b/command/internal_plugin_list.go index 321fbd09e..6834bf5f7 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" localexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/local-exec" remoteexecprovisioner "github.com/hashicorp/terraform/builtin/provisioners/remote-exec" + saltmasterlessprovisioner "github.com/hashicorp/terraform/builtin/provisioners/salt-masterless" "github.com/hashicorp/terraform/plugin" ) @@ -15,8 +16,9 @@ import ( var InternalProviders = map[string]plugin.ProviderFunc{} var InternalProvisioners = map[string]plugin.ProvisionerFunc{ - "chef": chefprovisioner.Provisioner, - "file": fileprovisioner.Provisioner, - "local-exec": localexecprovisioner.Provisioner, - "remote-exec": remoteexecprovisioner.Provisioner, + "chef": chefprovisioner.Provisioner, + "file": fileprovisioner.Provisioner, + "local-exec": localexecprovisioner.Provisioner, + "remote-exec": remoteexecprovisioner.Provisioner, + "salt-masterless": saltmasterlessprovisioner.Provisioner, } diff --git a/command/internal_plugin_test.go b/command/internal_plugin_test.go index 3c5eb01ad..8f9338ddf 100644 --- a/command/internal_plugin_test.go +++ b/command/internal_plugin_test.go @@ -13,7 +13,7 @@ import "testing" //} func TestInternalPlugin_InternalProvisioners(t *testing.T) { - for _, name := range []string{"chef", "file", "local-exec", "remote-exec"} { + for _, name := range []string{"chef", "file", "local-exec", "remote-exec", "salt-masterless"} { if _, ok := InternalProvisioners[name]; !ok { t.Errorf("Expected to find %s in InternalProvisioners", name) } diff --git a/scripts/generate-plugins.go b/scripts/generate-plugins.go index 15377141a..c61d8dc19 100644 --- a/scripts/generate-plugins.go +++ b/scripts/generate-plugins.go @@ -82,10 +82,11 @@ func makeProviderMap(items []plugin) string { // makeProvisionerMap creates a map of provisioners like this: // -// "chef": chefprovisioner.Provisioner, -// "file": fileprovisioner.Provisioner, -// "local-exec": localexecprovisioner.Provisioner, -// "remote-exec": remoteexecprovisioner.Provisioner, +// "chef": chefprovisioner.Provisioner, +// "salt-masterless": saltmasterlessprovisioner.Provisioner, +// "file": fileprovisioner.Provisioner, +// "local-exec": localexecprovisioner.Provisioner, +// "remote-exec": remoteexecprovisioner.Provisioner, // func makeProvisionerMap(items []plugin) string { output := "" diff --git a/website/docs/provisioners/salt-masterless.html.md b/website/docs/provisioners/salt-masterless.html.md new file mode 100644 index 000000000..30f1863e9 --- /dev/null +++ b/website/docs/provisioners/salt-masterless.html.md @@ -0,0 +1,93 @@ +--- +layout: "docs" +page_title: "Provisioner: salt-masterless" +sidebar_current: "docs-provisioners-salt-masterless" +description: |- + The salt-masterless Terraform provisioner provisions machines built by Terraform +--- + +# Salt Masterless Provisioner + +Type: `salt-masterless` + +The `salt-masterless` Terraform provisioner provisions machines built by Terraform +using [Salt](http://saltstack.com/) states, without connecting to a Salt master. The `salt-masterless` provisioner supports `ssh` [connections](/docs/provisioners/connection.html). + +## Requirements + +The `salt-masterless` provisioner has some prerequisites. `cURL` must be available on the remote host. + +## Example usage + +The example below is fully functional. + +```hcl + +provisioner "salt-masterless" { + "local_state_tree" = "/srv/salt" +} +``` + +## Argument Reference + +The reference of available configuration options is listed below. The only +required argument is the path to your local salt state tree. + +Optional: + +- `bootstrap_args` (string) - Arguments to send to the bootstrap script. Usage + is somewhat documented on + [github](https://github.com/saltstack/salt-bootstrap), but the [script + itself](https://github.com/saltstack/salt-bootstrap/blob/develop/bootstrap-salt.sh) + has more detailed usage instructions. By default, no arguments are sent to + the script. + +- `disable_sudo` (boolean) - By default, the bootstrap install command is prefixed with `sudo`. When using a + Docker builder, you will likely want to pass `true` since `sudo` is often not pre-installed. + +- `remote_pillar_roots` (string) - The path to your remote [pillar + roots](http://docs.saltstack.com/ref/configuration/master.html#pillar-configuration). + default: `/srv/pillar`. This option cannot be used with `minion_config`. + +- `remote_state_tree` (string) - The path to your remote [state + tree](http://docs.saltstack.com/ref/states/highstate.html#the-salt-state-tree). + default: `/srv/salt`. This option cannot be used with `minion_config`. + +- `local_pillar_roots` (string) - The path to your local [pillar + roots](http://docs.saltstack.com/ref/configuration/master.html#pillar-configuration). + This will be uploaded to the `remote_pillar_roots` on the remote. + +- `local_state_tree` (string) - The path to your local [state + tree](http://docs.saltstack.com/ref/states/highstate.html#the-salt-state-tree). + This will be uploaded to the `remote_state_tree` on the remote. + +- `custom_state` (string) - A state to be run instead of `state.highstate`. + Defaults to `state.highstate` if unspecified. + +- `minion_config` (string) - The path to your local [minion config + file](http://docs.saltstack.com/ref/configuration/minion.html). This will be + uploaded to the `/etc/salt` on the remote. This option overrides the + `remote_state_tree` or `remote_pillar_roots` options. + +- `grains_file` (string) - The path to your local [grains file](https://docs.saltstack.com/en/latest/topics/grains). This will be + uploaded to `/etc/salt/grains` on the remote. + +- `skip_bootstrap` (boolean) - By default the salt provisioner runs [salt + bootstrap](https://github.com/saltstack/salt-bootstrap) to install salt. Set + this to true to skip this step. + +- `temp_config_dir` (string) - Where your local state tree will be copied + before moving to the `/srv/salt` directory. Default is `/tmp/salt`. + +- `no_exit_on_failure` (boolean) - Terraform will exit if the `salt-call` command + fails. Set this option to true to ignore Salt failures. + +- `log_level` (string) - Set the logging level for the `salt-call` run. + +- `salt_call_args` (string) - Additional arguments to pass directly to `salt-call`. See + [salt-call](https://docs.saltstack.com/ref/cli/salt-call.html) documentation for more + information. By default no additional arguments (besides the ones Terraform generates) + are passed to `salt-call`. + +- `salt_bin_dir` (string) - Path to the `salt-call` executable. Useful if it is not + on the PATH.