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 @@
{{ printf "$ %s\n" $out.Command }}{{ $out.Output }}+
{{ $module.Output }}
{{ printf "$ %s\n" $out.Command }}{{ $out.Output }}- {{end}} +
{{ $module.Output }}