diff --git a/plugin_test.go b/plugin_test.go index 4159d49..fb57da2 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -1,279 +1,411 @@ package main import ( + "fmt" + "io/ioutil" "os" "os/exec" - "testing" + "os/user" + "path/filepath" + "regexp" + "strings" + "time" - . "github.com/franela/goblin" + "github.com/Sirupsen/logrus" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/credentials/stscreds" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" ) -func TestPlugin(t *testing.T) { - g := Goblin(t) +type ( + // Config holds input parameters for the plugin + Config struct { + Actions []string + Vars map[string]string + Secrets map[string]string + InitOptions InitOptions + FmtOptions FmtOptions + Cacert string + Sensitive bool + RoleARN string + RootDir string + Parallelism int + Targets []string + VarFiles []string + TerraformDataDir string + } - g.Describe("CopyTfEnv", func() { - g.It("Should create copies of TF_VAR_ to lowercase", func() { - // Set some initial TF_VAR_ that are uppercase - os.Setenv("TF_VAR_SOMETHING", "some value") - os.Setenv("TF_VAR_SOMETHING_ELSE", "some other value") - os.Setenv("TF_VAR_BASE64", "dGVzdA==") + // Netrc is credentials for cloning + Netrc struct { + Machine string + Login string + Password string + } - CopyTfEnv() + // InitOptions include options for the Terraform's init command + InitOptions struct { + BackendConfig []string `json:"backend-config"` + Lock *bool `json:"lock"` + LockTimeout string `json:"lock-timeout"` + } - // Make sure new env vars exist with proper values - g.Assert(os.Getenv("TF_VAR_something")).Equal("some value") - g.Assert(os.Getenv("TF_VAR_something_else")).Equal("some other value") - g.Assert(os.Getenv("TF_VAR_base64")).Equal("dGVzdA==") - }) - }) + // FmtOptions fmt options for the Terraform's fmt command + FmtOptions struct { + List *bool `json:"list"` + Write *bool `json:"write"` + Diff *bool `json:"diff"` + Check *bool `json:"check"` + } - g.Describe("tfApply", func() { - g.It("Should return correct apply commands given the arguments", func() { - type args struct { - config Config + // Plugin represents the plugin instance to be executed + Plugin struct { + Config Config + Netrc Netrc + Terraform Terraform + } +) + +// Exec executes the plugin +func (p Plugin) Exec() error { + // Install specified version of terraform + if p.Terraform.Version != "" { + err := installTerraform(p.Terraform.Version) + + if err != nil { + return err + } + } + + if p.Config.RoleARN != "" { + assumeRole(p.Config.RoleARN) + } + + // writing the .netrc file with Github credentials in it. + err := writeNetrc(p.Netrc.Machine, p.Netrc.Login, p.Netrc.Password) + if err != nil { + return err + } + + var terraformDataDir string = ".terraform" + if p.Config.TerraformDataDir != "" { + terraformDataDir = p.Config.TerraformDataDir + os.Setenv("TF_DATA_DIR", p.Config.TerraformDataDir) + } + + var commands []*exec.Cmd + + commands = append(commands, exec.Command("terraform", "version")) + + CopyTfEnv() + + if p.Config.Cacert != "" { + commands = append(commands, installCaCert(p.Config.Cacert)) + } + + commands = append(commands, deleteCache(terraformDataDir)) + commands = append(commands, initCommand(p.Config.InitOptions)) + commands = append(commands, getModules()) + + // Add commands listed from Actions + for _, action := range p.Config.Actions { + switch action { + case "fmt": + commands = append(commands, tfFmt(p.Config)) + case "validate": + commands = append(commands, tfValidate()) + case "plan": + commands = append(commands, tfPlan(p.Config, false)) + case "plan-destroy": + commands = append(commands, tfPlan(p.Config, true)) + case "apply": + commands = append(commands, tfApply(p.Config)) + case "destroy": + commands = append(commands, tfDestroy(p.Config)) + default: + return fmt.Errorf("valid actions are: fmt, validate, plan, apply, plan-destroy, destroy. You provided %s", action) + } + } + + commands = append(commands, deleteCache(terraformDataDir)) + + for _, c := range commands { + if c.Dir == "" { + wd, err := os.Getwd() + if err == nil { + c.Dir = wd } + } + if p.Config.RootDir != "" { + c.Dir = c.Dir + "/" + p.Config.RootDir + } + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if !p.Config.Sensitive { + trace(c) + } - tests := []struct { - name string - args args - want *exec.Cmd - }{ - { - "default", - args{config: Config{}}, - exec.Command("terraform", "apply", "plan.tfout"), - }, - { - "with parallelism", - args{config: Config{Parallelism: 5}}, - exec.Command("terraform", "apply", "-parallelism=5", "plan.tfout"), - }, - { - "with targets", - args{config: Config{Targets: []string{"target1", "target2"}}}, - exec.Command("terraform", "apply", "--target", "target1", "--target", "target2", "plan.tfout"), - }, - } + err := c.Run() + if err != nil { + logrus.WithFields(logrus.Fields{ + "error": err, + }).Fatal("Failed to execute a command") + } + logrus.Debug("Command completed successfully") + } - for _, tt := range tests { - g.Assert(tfApply(tt.args.config)).Equal(tt.want) - } - }) - }) - - g.Describe("tfDestroy", func() { - g.It("Should return correct destroy commands given the arguments", func() { - type args struct { - config Config - } - - tests := []struct { - name string - args args - want *exec.Cmd - }{ - { - "default", - args{config: Config{}}, - exec.Command("terraform", "destroy", "-force"), - }, - { - "with parallelism", - args{config: Config{Parallelism: 5}}, - exec.Command("terraform", "destroy", "-parallelism=5", "-force"), - }, - { - "with targets", - args{config: Config{Targets: []string{"target1", "target2"}}}, - exec.Command("terraform", "destroy", "-target=target1", "-target=target2", "-force"), - }, - { - "with vars", - args{config: Config{Vars: map[string]string{"username": "someuser", "password": "1pass"}}}, - exec.Command("terraform", "destroy", "-var", "username=someuser", "-var", "password=1pass", "-force"), - }, - { - "with var-files", - args{config: Config{VarFiles: []string{"common.tfvars", "prod.tfvars"}}}, - exec.Command("terraform", "destroy", "-var-file=common.tfvars", "-var-file=prod.tfvars", "-force"), - }, - } - - for _, tt := range tests { - g.Assert(tfDestroy(tt.args.config)).Equal(tt.want) - } - }) - }) - - g.Describe("tfValidate", func() { - g.It("Should return correct validate command", func() { - type args struct { - config Config - } - - tests := []struct { - name string - args args - want *exec.Cmd - }{ - { - "default", - args{config: Config{VarFiles: []string{"common.tfvars", "prod.tfvars"}}}, - exec.Command("terraform", "validate"), - }, - { - "with no vars", - args{config: Config{Vars: map[string]string{"var1": "value1", "var2": "value2"}}}, - exec.Command("terraform", "validate"), - }, - { - "with no var-files", - args{config: Config{VarFiles: []string{"common.tfvars", "prod.tfvars"}}}, - exec.Command("terraform", "validate"), - }, - } - - for _, tt := range tests { - g.Assert(tfValidate()).Equal(tt.want) - } - }) - }) - - g.Describe("tfPlan", func() { - g.It("Should return correct plan commands given the arguments", func() { - type args struct { - config Config - } - - tests := []struct { - name string - args args - destroy bool - want *exec.Cmd - }{ - { - "default", - args{config: Config{}}, - false, - exec.Command("terraform", "plan", "-out=plan.tfout"), - }, - { - "destroy", - args{config: Config{}}, - true, - exec.Command("terraform", "plan", "-destroy"), - }, - { - "with vars", - args{config: Config{Vars: map[string]string{"username": "someuser", "password": "1pass"}}}, - false, - exec.Command("terraform", "plan", "-out=plan.tfout", "-var", "username=someuser", "-var", "password=1pass"), - }, - { - "with var-files", - args{config: Config{VarFiles: []string{"common.tfvars", "prod.tfvars"}}}, - false, - exec.Command("terraform", "plan", "-out=plan.tfout", "-var-file=common.tfvars", "-var-file=prod.tfvars"), - }, - } - - for _, tt := range tests { - g.Assert(tfPlan(tt.args.config, tt.destroy)).Equal(tt.want) - } - }) - }) - g.Describe("tfFmt", func() { - g.It("Should return correct fmt commands given the arguments", func() { - type args struct { - config Config - } - - affirmative := true - negative := false - - tests := []struct { - name string - args args - want *exec.Cmd - }{ - { - "default", - args{config: Config{}}, - exec.Command("terraform", "fmt"), - }, - { - "with list", - args{config: Config{FmtOptions: FmtOptions{List: &affirmative}}}, - exec.Command("terraform", "fmt", "-list=true"), - }, - { - "with write", - args{config: Config{FmtOptions: FmtOptions{Write: &affirmative}}}, - exec.Command("terraform", "fmt", "-write=true"), - }, - { - "with diff", - args{config: Config{FmtOptions: FmtOptions{Diff: &affirmative}}}, - exec.Command("terraform", "fmt", "-diff=true"), - }, - { - "with check", - args{config: Config{FmtOptions: FmtOptions{Check: &affirmative}}}, - exec.Command("terraform", "fmt", "-check=true"), - }, - { - "with combination", - args{config: Config{FmtOptions: FmtOptions{ - List: &negative, - Write: &negative, - Diff: &affirmative, - Check: &affirmative, - }}}, - exec.Command("terraform", "fmt", "-list=false", "-write=false", "-diff=true", "-check=true"), - }, - } - - for _, tt := range tests { - g.Assert(tfFmt(tt.args.config)).Equal(tt.want) - } - }) - }) - - g.Describe("tfDataDir", func() { - g.It("Should override the terraform data dir environment variable when provided", func() { - type args struct { - config Config - } - - tests := []struct { - name string - args args - want *exec.Cmd - }{ - { - "with TerraformDataDir", - args{config: Config{TerraformDataDir: ".overriden_terraform_dir"}}, - exec.Command("terraform", "apply", ".overriden_terraform_dir.plan.tfout"), - }, - { - "with TerraformDataDir value as .terraform", - args{config: Config{TerraformDataDir: ".terraform"}}, - exec.Command("terraform", "apply", "plan.tfout"), - }, - { - "without TerraformDataDir", - args{config: Config{}}, - exec.Command("terraform", "apply", "plan.tfout"), - }, - } - - for _, tt := range tests { - os.Setenv("TF_DATA_DIR", tt.args.config.TerraformDataDir) - applied := tfApply(tt.args.config) - - g.Assert(applied).Equal(tt.want) - - } - }) - }) + return nil } + +// CopyTfEnv creates copies of TF_VAR_ to lowercase +func CopyTfEnv() { + tfVar := regexp.MustCompile(`^TF_VAR_.*$`) + for _, e := range os.Environ() { + pair := strings.SplitN(e, "=", 2) + if tfVar.MatchString(pair[0]) { + name := strings.Split(pair[0], "TF_VAR_") + os.Setenv(fmt.Sprintf("TF_VAR_%s", strings.ToLower(name[1])), pair[1]) + } + } +} + +func assumeRole(roleArn string) { + client := sts.New(session.New()) + duration := time.Hour * 1 + stsProvider := &stscreds.AssumeRoleProvider{ + Client: client, + Duration: duration, + RoleARN: roleArn, + RoleSessionName: "drone", + } + + value, err := credentials.NewCredentials(stsProvider).Get() + if err != nil { + logrus.WithFields(logrus.Fields{ + "error": err, + }).Fatal("Error assuming role!") + } + os.Setenv("AWS_ACCESS_KEY_ID", value.AccessKeyID) + os.Setenv("AWS_SECRET_ACCESS_KEY", value.SecretAccessKey) + os.Setenv("AWS_SESSION_TOKEN", value.SessionToken) +} + +func deleteCache(terraformDataDir string) *exec.Cmd { + return exec.Command( + "rm", + "-rf", + terraformDataDir, + ) +} + +func getModules() *exec.Cmd { + return exec.Command( + "terraform", + "get", + ) +} + +func initCommand(config InitOptions) *exec.Cmd { + args := []string{ + "init", + } + + for _, v := range config.BackendConfig { + args = append(args, fmt.Sprintf("-backend-config=%s", v)) + } + + // True is default in TF + if config.Lock != nil { + args = append(args, fmt.Sprintf("-lock=%t", *config.Lock)) + } + + // "0s" is default in TF + if config.LockTimeout != "" { + args = append(args, fmt.Sprintf("-lock-timeout=%s", config.LockTimeout)) + } + + // Fail Terraform execution on prompt + args = append(args, "-input=false") + + return exec.Command( + "terraform", + args..., + ) +} + +func installCaCert(cacert string) *exec.Cmd { + ioutil.WriteFile("/usr/local/share/ca-certificates/ca_cert.crt", []byte(cacert), 0644) + return exec.Command( + "update-ca-certificates", + ) +} + +func trace(cmd *exec.Cmd) { + fmt.Println("$", strings.Join(cmd.Args, " ")) +} + +func tfApply(config Config) *exec.Cmd { + args := []string{ + "apply", + } + for _, v := range config.Targets { + args = append(args, "--target", fmt.Sprintf("%s", v)) + } + if config.Parallelism > 0 { + args = append(args, fmt.Sprintf("-parallelism=%d", config.Parallelism)) + } + if config.InitOptions.Lock != nil { + args = append(args, fmt.Sprintf("-lock=%t", *config.InitOptions.Lock)) + } + if config.InitOptions.LockTimeout != "" { + args = append(args, fmt.Sprintf("-lock-timeout=%s", config.InitOptions.LockTimeout)) + } + args = append(args, getTfoutPath()) + + return exec.Command( + "terraform", + args..., + ) +} + +func tfDestroy(config Config) *exec.Cmd { + args := []string{ + "destroy", + } + for _, v := range config.Targets { + args = append(args, fmt.Sprintf("-target=%s", v)) + } + args = append(args, varFiles(config.VarFiles)...) + args = append(args, vars(config.Vars)...) + if config.Parallelism > 0 { + args = append(args, fmt.Sprintf("-parallelism=%d", config.Parallelism)) + } + if config.InitOptions.Lock != nil { + args = append(args, fmt.Sprintf("-lock=%t", *config.InitOptions.Lock)) + } + if config.InitOptions.LockTimeout != "" { + args = append(args, fmt.Sprintf("-lock-timeout=%s", config.InitOptions.LockTimeout)) + } + args = append(args, "-force") + return exec.Command( + "terraform", + args..., + ) +} + +func tfPlan(config Config, destroy bool) *exec.Cmd { + args := []string{ + "plan", + } + + if destroy { + args = append(args, "-destroy") + } else { + args = append(args, fmt.Sprintf("-out=%s", getTfoutPath())) + } + + for _, v := range config.Targets { + args = append(args, "--target", fmt.Sprintf("%s", v)) + } + args = append(args, varFiles(config.VarFiles)...) + args = append(args, vars(config.Vars)...) + if config.Parallelism > 0 { + args = append(args, fmt.Sprintf("-parallelism=%d", config.Parallelism)) + } + if config.InitOptions.Lock != nil { + args = append(args, fmt.Sprintf("-lock=%t", *config.InitOptions.Lock)) + } + if config.InitOptions.LockTimeout != "" { + args = append(args, fmt.Sprintf("-lock-timeout=%s", config.InitOptions.LockTimeout)) + } + return exec.Command( + "terraform", + args..., + ) +} + +func tfValidate() *exec.Cmd { + args := []string{ + "validate", + } + return exec.Command( + "terraform", + args..., + ) +} + +func tfFmt(config Config) *exec.Cmd { + args := []string{ + "fmt", + } + if config.FmtOptions.List != nil { + args = append(args, fmt.Sprintf("-list=%t", *config.FmtOptions.List)) + } + if config.FmtOptions.Write != nil { + args = append(args, fmt.Sprintf("-write=%t", *config.FmtOptions.Write)) + } + if config.FmtOptions.Diff != nil { + args = append(args, fmt.Sprintf("-diff=%t", *config.FmtOptions.Diff)) + } + if config.FmtOptions.Check != nil { + args = append(args, fmt.Sprintf("-check=%t", *config.FmtOptions.Check)) + } + return exec.Command( + "terraform", + args..., + ) +} + +func getTfoutPath() string { + terraformDataDir := os.Getenv("TF_DATA_DIR") + if terraformDataDir == ".terraform" || terraformDataDir == "" { + return "plan.tfout" + } else { + return fmt.Sprintf("%s.plan.tfout", terraformDataDir) + } +} + +func vars(vs map[string]string) []string { + var args []string + for k, v := range vs { + args = append(args, "-var", fmt.Sprintf("%s=%s", k, v)) + } + return args +} + +func varFiles(vfs []string) []string { + var args []string + for _, v := range vfs { + args = append(args, fmt.Sprintf("-var-file=%s", v)) + } + return args +} + +// helper function to write a netrc file. +// The following code comes from the official Git plugin for Drone: +// https://github.com/drone-plugins/drone-git/blob/8386effd2fe8c8695cf979427f8e1762bd805192/utils.go#L43-L68 +func writeNetrc(machine, login, password string) error { + if machine == "" { + return nil + } + out := fmt.Sprintf( + netrcFile, + machine, + login, + password, + ) + + home := "/root" + u, err := user.Current() + if err == nil { + home = u.HomeDir + } + path := filepath.Join(home, ".netrc") + return ioutil.WriteFile(path, []byte(out), 0600) +} + +const netrcFile = ` +machine %s +login %s +password %s +`