Skip to content

Commit

Permalink
Merge pull request #18 from utilitywarehouse/use-tf-exec
Browse files Browse the repository at this point in the history
Replace our own terraform client with tfexec
  • Loading branch information
ribbybibby authored Aug 20, 2021
2 parents 83d90bf + 200ff3c commit 35be4e9
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 311 deletions.
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
48 changes: 32 additions & 16 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
Expand Down Expand Up @@ -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)

Expand All @@ -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{
Expand Down
146 changes: 89 additions & 57 deletions run/applier.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
}
8 changes: 2 additions & 6 deletions templates/status.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,9 @@ <h4 class="panel-title">
</div>
<div id="failure-{{$i}}" class="panel-collapse collapse">
<ul class="list-group">
{{ range $i, $out := $module.Output }}
<li class="list-group-item">
<pre class="file-output">{{ printf "$ %s\n" $out.Command }}{{ $out.Output }}</pre>
<pre class="file-output">{{ $module.Output }}</pre>
</li>
{{end}}
</ul>
</div>
</div>
Expand Down Expand Up @@ -95,9 +93,7 @@ <h4 class="panel-title">
<div class="panel-collapse">
<ul class="list-group">
<li class="list-group-item">
{{ range $i, $out := $module.Output }}
<pre class="file-output">{{ printf "$ %s\n" $out.Command }}{{ $out.Output }}</pre>
{{end}}
<pre class="file-output">{{ $module.Output }}</pre>
</li>
</ul>
</div>
Expand Down
Loading

0 comments on commit 35be4e9

Please sign in to comment.