diff --git a/README.md b/README.md index b97192d7..9d83e141 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,6 @@ modules from a Git repository or local directory. hash portion with `%s` so it can be filled in by terraform-applier (e.g. `https://github.com/kubernetes/kubernetes/commit/%s`) . - `DRY_RUN` - (bool) (default: `false`) If `true`, terraform-applier will stop after running `plan`, whether there are changes to be made or not - `FULL_RUN_INTERVAL_SECONDS` - (int) (default: `3600`) Number of seconds between automatic full runs . Set to `0` to disable -- `INIT_ARGS` - (string) (default: `""`) A comma separated list of arguments to be passed to the `init` command. This is primarily useful for - configuring backend options that are omitted from the code. If you include a `%s` in the string, terraform-applier will replace - it with the basename of the module being applied. This can be used to configure the name of the state file - - For instance, for a module with the path `/src/modules/vpc`, an `INIT_ARGS` value of `-backend-config=key=prod-%s` would be - formatted as `-backend-config=key=prod-vpc` and could be used, in this example, to write state to an S3 object with the key - `prod-vpc`. - `LISTEN_ADDRESS` - (string) (default: `:8080`) The address the applier webserver will listen on - `LOG_LEVEL` - (string) (default: `INFO`) `TRACE|DEBUG|INFO|WARN|ERROR|FATAL`, case insensitive - `POLL_INTERVAL_SECONDS` - (int) (default: `5`) Number of seconds to wait between each check for new commits to the repo diff --git a/go.mod b/go.mod index a0379d63..037f553a 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/hashicorp/go-uuid v1.0.0 // indirect github.com/hashicorp/go-version v1.3.0 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect + github.com/hashicorp/terraform-json v0.12.0 // indirect github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 // indirect github.com/klauspost/compress v1.11.2 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect @@ -34,6 +35,7 @@ require ( github.com/prometheus/common v0.26.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect github.com/ulikunitz/xz v0.5.8 // indirect + github.com/zclconf/go-cty v1.8.4 // indirect go.opencensus.io v0.22.0 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 // indirect diff --git a/go.sum b/go.sum index 296d1175..5a6ad569 100644 --- a/go.sum +++ b/go.sum @@ -124,6 +124,7 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.14.0 h1:UQoUcxKTZZXhyyK68Cwn4mApT4mnFPmEXPiqaHL9r+w= github.com/hashicorp/terraform-exec v0.14.0/go.mod h1:qrAASDq28KZiMPDnQ02sFS9udcqEkRly002EA2izXTA= +github.com/hashicorp/terraform-json v0.12.0 h1:8czPgEEWWPROStjkWPUnTQDXmpmZPlkQAwYYLETaTvw= github.com/hashicorp/terraform-json v0.12.0/go.mod h1:pmbq9o4EuL43db5+0ogX10Yofv1nozM+wskr/bGFJpI= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -225,6 +226,7 @@ github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6e github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= +github.com/zclconf/go-cty v1.8.4 h1:pwhhz5P+Fjxse7S7UriBrMu6AUJSZM5pKqGem1PjGAs= github.com/zclconf/go-cty v1.8.4/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= diff --git a/main.go b/main.go index 8ca85005..b14528a7 100644 --- a/main.go +++ b/main.go @@ -10,9 +10,9 @@ import ( "syscall" "time" + "github.com/hashicorp/terraform-exec/tfexec" "github.com/hashicorp/terraform-exec/tfinstall" "github.com/utilitywarehouse/terraform-applier/git" - "github.com/utilitywarehouse/terraform-applier/terraform" "github.com/utilitywarehouse/terraform-applier/log" "github.com/utilitywarehouse/terraform-applier/metrics" @@ -25,7 +25,6 @@ var ( diffURLFormat = os.Getenv("DIFF_URL_FORMAT") dryRun = os.Getenv("DRY_RUN") fullRunInterval = os.Getenv("FULL_RUN_INTERVAL_SECONDS") - initArgs = os.Getenv("INIT_ARGS") listenAddress = os.Getenv("LISTEN_ADDRESS") logLevel = os.Getenv("LOG_LEVEL") pollInterval = os.Getenv("POLL_INTERVAL_SECONDS") @@ -116,6 +115,27 @@ func findTerraformExecPath(ctx context.Context, path, version string) (string, f return execPath, cleanup, nil } +// terraformVersionString returns the terraform version from the terraform binary +// indicated by execPath +func terraformVersionString(ctx context.Context, execPath string) (string, error) { + tmpDir, err := ioutil.TempDir("", "tfversion") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpDir) + + tf, err := tfexec.NewTerraform(tmpDir, execPath) + if err != nil { + return "", err + } + version, _, err := tf.Version(context.Background(), true) + if err != nil { + return "", err + } + + return version.String(), nil +} + func main() { log.Level = log.LevelFromString(logLevel) @@ -138,30 +158,26 @@ func main() { // No limit needed, as a single fatal error will exit the program anyway. errors := make(chan error) - // Terraform client + // Find the requested version of terraform and log the version + // information execPath, cleanup, err := findTerraformExecPath(context.Background(), terraformPath, terraformVersion) defer cleanup() if err != nil { log.Fatal("error finding terraform: %s", err) } - client := &terraform.Client{ - ExecPath: execPath, - Metrics: metrics, - } - versionOutput, err := client.Version() + version, err := terraformVersionString(context.Background(), execPath) if err != nil { - log.Fatal("error running `terraform version` version=%s error=%s", terraformVersion, err) + log.Fatal("error running `terraform version`: %s", err) } - log.Info(versionOutput) + log.Info("Using terraform version: %s", version) dr, _ := strconv.ParseBool(dryRun) applier := &run.Applier{ - Clock: clock, - DryRun: dr, - Errors: errors, - InitArgs: initArgs, - Metrics: metrics, - TerraformClient: client, + Clock: clock, + DryRun: dr, + Errors: errors, + Metrics: metrics, + TerraformExecPath: execPath, } gitUtil := &git.Util{ diff --git a/run/applier.go b/run/applier.go index 5d813c57..69c6d37c 100644 --- a/run/applier.go +++ b/run/applier.go @@ -1,27 +1,29 @@ package run import ( + "bytes" + "context" "fmt" + "io" "io/ioutil" "os" "path/filepath" - "strings" + "regexp" "time" + "github.com/hashicorp/terraform-exec/tfexec" "github.com/utilitywarehouse/terraform-applier/log" "github.com/utilitywarehouse/terraform-applier/metrics" "github.com/utilitywarehouse/terraform-applier/sysutil" - "github.com/utilitywarehouse/terraform-applier/terraform" ) // ApplyAttempt stores the data from an attempt at applying a single module type ApplyAttempt struct { - DryRun bool - ErrorMessage string - Finish time.Time - Output []terraform.Output - Module string - Start time.Time + DryRun bool + Finish time.Time + Output string + Module string + Start time.Time } // FormattedStart returns the Start time in the format "YYYY-MM-DD hh:mm:ss -0000 GMT" @@ -51,12 +53,11 @@ type ApplierInterface interface { // Applier inits, plans and applies terraform modules type Applier struct { - Clock sysutil.ClockInterface - DryRun bool - Errors chan<- error - InitArgs string - Metrics metrics.PrometheusInterface - TerraformClient terraform.ClientInterface + Clock sysutil.ClockInterface + DryRun bool + Errors chan<- error + Metrics metrics.PrometheusInterface + TerraformExecPath string } // Apply runs terraform for each of the modules and reports the successes and failures @@ -79,59 +80,90 @@ func (a *Applier) Apply(modulesPath string, modules []string) ([]ApplyAttempt, [ log.Info("-> %s", module) appliedModule := ApplyAttempt{ - DryRun: a.DryRun, - ErrorMessage: "", - Finish: time.Time{}, - Output: []terraform.Output{}, - Module: module, - Start: a.Clock.Now(), + DryRun: a.DryRun, + Finish: time.Time{}, + Output: "", + Module: module, + Start: a.Clock.Now(), } - // Run terraform from the module's counterpart in the temporary directory - a.TerraformClient.SetWorkingDir(filepath.Join(tmpDir, filepath.Base(module))) + tmpModulePath := filepath.Join(tmpDir, filepath.Base(module)) - // Bool to track whether this was a successful run or not - success := true - - var initArgs []string - if a.InitArgs != "" { - formattedInitArgs := fmt.Sprintf(a.InitArgs, filepath.Base(module)) - initArgs = strings.Split(formattedInitArgs, ",") + out, err := a.applyModule(context.Background(), tmpModulePath) + appliedModule.Finish = a.Clock.Now() + appliedModule.Output = out + if err != nil { + log.Warn("error applying %s: %s", module, err) + failures = append(failures, appliedModule) + } else { + successes = append(successes, appliedModule) } + } + + return successes, failures +} + +// tfLogMessageRe matches `[LEVEL] running Terraform command:` at the start of a +// line ($1) and the actual terraform command ($2) +var tfLogMessageRe = regexp.MustCompile(`(^\[[A-Z]+\] running Terraform command: )(.+)`) + +// tfLogger implements tfexec.printfer. It logs messages from tfexec and also +// writes them to the io.Writer w +type tfLogger struct { + w io.Writer +} + +// Printf logs the message and also writes it to the given io.Writer +func (l *tfLogger) Printf(format string, v ...interface{}) { + // Extract the terraform command from the log line + msg := tfLogMessageRe.ReplaceAllString(fmt.Sprintf(format, v...), `$2`) + log.Info("+ %s", msg) + // Write the command to the output with a faux shell prompt and a new + // line at the end. This aids readability. + fmt.Fprint(l.w, "$ "+msg+"\n") +} + +func (a *Applier) applyModule(ctx context.Context, modulePath string) (string, error) { + var out bytes.Buffer - // Init - initOut, err := a.TerraformClient.Init(module, initArgs) - appliedModule.Output = append(appliedModule.Output, initOut) + tf, err := tfexec.NewTerraform(modulePath, a.TerraformExecPath) + if err != nil { + return "", err + } + tf.SetLogger(&tfLogger{w: &out}) + tf.SetStdout(&out) + tf.SetStderr(&out) + + // Sometimes the error text would be useful in the command output that's + // displayed in the UI. For this reason, we append the error to the + // output before we return it. + errReturn := func(out bytes.Buffer, err error) (string, error) { if err != nil { - appliedModule.ErrorMessage = err.Error() - success = false - } else { - // Plan - planOut, planFile, err := a.TerraformClient.Plan(module) - appliedModule.Output = append(appliedModule.Output, planOut) - if err != nil { - appliedModule.ErrorMessage = err.Error() - success = false - } else if len(planFile) > 0 && !a.DryRun { - // Apply (if there are changes to apply and dry run isn't set) - applyOut, err := a.TerraformClient.Apply(module, planFile) - appliedModule.Output = append(appliedModule.Output, applyOut) - if err != nil { - appliedModule.ErrorMessage = err.Error() - success = false - } - } + return fmt.Sprintf("%s\n%s", out.String(), err.Error()), err } - appliedModule.Finish = a.Clock.Now() + return out.String(), nil + } - if success { - successes = append(successes, appliedModule) - } else { - log.Warn("%v\n%v", appliedModule.ErrorMessage, appliedModule.Output) - failures = append(failures, appliedModule) + if err := tf.Init(ctx, tfexec.Upgrade(true)); err != nil { + return errReturn(out, err) + } + fmt.Fprint(&out, "\n") + + planOut := filepath.Join(modulePath, "plan.out") + + changes, err := tf.Plan(ctx, tfexec.Out(planOut)) + if err != nil { + return errReturn(out, err) + } + fmt.Fprint(&out, "\n") + + if changes && !a.DryRun { + if err := tf.Apply(ctx, tfexec.DirOrPlan(planOut)); err != nil { + return errReturn(out, err) } + fmt.Fprint(&out, "\n") } - return successes, failures + return out.String(), nil } diff --git a/templates/status.html b/templates/status.html index eb2716bd..72a370fa 100644 --- a/templates/status.html +++ b/templates/status.html @@ -59,11 +59,9 @@

@@ -95,9 +93,7 @@

diff --git a/terraform/client.go b/terraform/client.go deleted file mode 100644 index 2c308801..00000000 --- a/terraform/client.go +++ /dev/null @@ -1,116 +0,0 @@ -package terraform - -import ( - "os/exec" - "path/filepath" - "strings" - - "github.com/utilitywarehouse/terraform-applier/log" - "github.com/utilitywarehouse/terraform-applier/metrics" -) - -// Output is the output of the terraform command -type Output struct { - Command string - Output string -} - -// ClientInterface allows for mocking out the functionality of Client when testing the full process of an apply run. -type ClientInterface interface { - Init(string, []string) (Output, error) - Plan(string) (Output, string, error) - Apply(string, string) (Output, error) - Exec(...string) (*Output, error) - SetWorkingDir(string) -} - -// Client for terraform -type Client struct { - ExecPath string - Metrics metrics.PrometheusInterface - - workDir string -} - -// SetWorkingDir changes the working directory -func (c *Client) SetWorkingDir(dir string) { - c.workDir = dir -} - -// Init runs terraform init with some predefined arguments, plus some user defined arguments -func (c *Client) Init(module string, args []string) (Output, error) { - args = append([]string{"init"}, args...) - args = append(args, "-reconfigure", "-no-color", "-upgrade=true") - - out, err := c.Exec(args...) - if err != nil { - if e, ok := err.(*exec.ExitError); ok { - c.Metrics.UpdateTerraformExitCodeCount(module, "init", e.ExitCode()) - } - return *out, err - } - c.Metrics.UpdateTerraformExitCodeCount(module, "init", 0) - return *out, nil -} - -// Plan runs terraform plan with some predefined arguments that are condusive to automation. It writes the plan to a file in the -// working directory, provided there are changes to be made. -func (c *Client) Plan(module string) (Output, string, error) { - planFile := filepath.Join(c.workDir, "plan.out") - - out, err := c.Exec("plan", "-input=false", "-no-color", "-detailed-exitcode", "-out="+planFile) - if err != nil { - if e, ok := err.(*exec.ExitError); ok { - c.Metrics.UpdateTerraformExitCodeCount(module, "plan", e.ExitCode()) - if e.ExitCode() == 2 { - return *out, planFile, nil - } - } - return *out, "", err - } - c.Metrics.UpdateTerraformExitCodeCount(module, "plan", 0) - return *out, "", nil -} - -// Apply applies a plan file -func (c *Client) Apply(module, planFile string) (Output, error) { - out, err := c.Exec("apply", "-no-color", "-auto-approve", planFile) - if err != nil { - if e, ok := err.(*exec.ExitError); ok { - c.Metrics.UpdateTerraformExitCodeCount(module, "apply", e.ExitCode()) - } - return *out, err - } - c.Metrics.UpdateTerraformExitCodeCount(module, "apply", 0) - return *out, nil -} - -// Version returns version info for terraform -func (c *Client) Version() (string, error) { - out, err := c.Exec("version", "-json") - if err != nil { - return "", err - } - - return out.Output, nil -} - -// Exec runs terraform -func (c *Client) Exec(args ...string) (*Output, error) { - var err error - - args = append([]string{c.ExecPath}, args...) - - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = c.workDir - - cmdStr := strings.Join(args, " ") - - log.Info("+ %s", cmdStr) - out, err := cmd.CombinedOutput() - - return &Output{ - Command: cmdStr, - Output: string(out), - }, err -} diff --git a/terraform/mock_client.go b/terraform/mock_client.go deleted file mode 100644 index 3cb6b6a6..00000000 --- a/terraform/mock_client.go +++ /dev/null @@ -1,110 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: terraform/client.go - -// Package terraform is a generated GoMock package. -package terraform - -import ( - gomock "github.com/golang/mock/gomock" - reflect "reflect" -) - -// MockClientInterface is a mock of ClientInterface interface -type MockClientInterface struct { - ctrl *gomock.Controller - recorder *MockClientInterfaceMockRecorder -} - -// MockClientInterfaceMockRecorder is the mock recorder for MockClientInterface -type MockClientInterfaceMockRecorder struct { - mock *MockClientInterface -} - -// NewMockClientInterface creates a new mock instance -func NewMockClientInterface(ctrl *gomock.Controller) *MockClientInterface { - mock := &MockClientInterface{ctrl: ctrl} - mock.recorder = &MockClientInterfaceMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use -func (m *MockClientInterface) EXPECT() *MockClientInterfaceMockRecorder { - return m.recorder -} - -// Init mocks base method -func (m *MockClientInterface) Init(arg0 string, arg1 []string) (Output, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Init", arg0, arg1) - ret0, _ := ret[0].(Output) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Init indicates an expected call of Init -func (mr *MockClientInterfaceMockRecorder) Init(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockClientInterface)(nil).Init), arg0, arg1) -} - -// Plan mocks base method -func (m *MockClientInterface) Plan(arg0 string) (Output, string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Plan", arg0) - ret0, _ := ret[0].(Output) - ret1, _ := ret[1].(string) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Plan indicates an expected call of Plan -func (mr *MockClientInterfaceMockRecorder) Plan(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Plan", reflect.TypeOf((*MockClientInterface)(nil).Plan), arg0) -} - -// Apply mocks base method -func (m *MockClientInterface) Apply(arg0, arg1 string) (Output, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Apply", arg0, arg1) - ret0, _ := ret[0].(Output) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Apply indicates an expected call of Apply -func (mr *MockClientInterfaceMockRecorder) Apply(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*MockClientInterface)(nil).Apply), arg0, arg1) -} - -// Exec mocks base method -func (m *MockClientInterface) Exec(arg0 ...string) (*Output, error) { - m.ctrl.T.Helper() - varargs := []interface{}{} - for _, a := range arg0 { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Exec", varargs...) - ret0, _ := ret[0].(*Output) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Exec indicates an expected call of Exec -func (mr *MockClientInterfaceMockRecorder) Exec(arg0 ...interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockClientInterface)(nil).Exec), arg0...) -} - -// SetWorkingDir mocks base method -func (m *MockClientInterface) SetWorkingDir(arg0 string) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetWorkingDir", arg0) -} - -// SetWorkingDir indicates an expected call of SetWorkingDir -func (mr *MockClientInterfaceMockRecorder) SetWorkingDir(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWorkingDir", reflect.TypeOf((*MockClientInterface)(nil).SetWorkingDir), arg0) -}