github.com/replicatedhq/ship@v0.55.0/pkg/lifecycle/terraform/terraformer.go (about)

     1  package terraform
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"io"
     7  	"os/exec"
     8  	"path"
     9  	"strings"
    10  	"sync"
    11  
    12  	"github.com/go-kit/kit/log"
    13  	"github.com/go-kit/kit/log/level"
    14  	"github.com/pkg/errors"
    15  	"github.com/replicatedhq/ship/pkg/api"
    16  	"github.com/replicatedhq/ship/pkg/lifecycle"
    17  	"github.com/replicatedhq/ship/pkg/lifecycle/daemon"
    18  	"github.com/replicatedhq/ship/pkg/lifecycle/daemon/daemontypes"
    19  	"github.com/replicatedhq/ship/pkg/lifecycle/terraform/tfplan"
    20  	"github.com/replicatedhq/ship/pkg/state"
    21  	"github.com/spf13/afero"
    22  	"github.com/spf13/viper"
    23  )
    24  
    25  const tfSep = "------------------------------------------------------------------------"
    26  const tfNoChanges = "No changes. Infrastructure is up-to-date."
    27  const tfSkipping = "Not running Terraform."
    28  
    29  type ForkTerraformer struct {
    30  	Logger            log.Logger
    31  	Daemon            daemontypes.Daemon
    32  	PlanConfirmer     tfplan.PlanConfirmer
    33  	Terraform         func(string) *exec.Cmd
    34  	Viper             *viper.Viper
    35  	FS                afero.Afero
    36  	StateManager      state.Manager
    37  	StateRestorer     stateRestorer
    38  	StateSaver        stateSaver
    39  	YesApplyTerraform bool
    40  }
    41  
    42  func NewTerraformer(
    43  	logger log.Logger,
    44  	daemon daemontypes.Daemon,
    45  	planner tfplan.PlanConfirmer,
    46  	viper *viper.Viper,
    47  	statemanager state.Manager,
    48  	fs afero.Afero,
    49  ) lifecycle.Terraformer {
    50  	terraformPath := viper.GetString("terraform-exec-path")
    51  	return &ForkTerraformer{
    52  		Logger:        logger,
    53  		Daemon:        daemon,
    54  		PlanConfirmer: planner,
    55  		Terraform: func(cmdPath string) *exec.Cmd {
    56  			cmd := exec.Command(terraformPath)
    57  			cmd.Dir = cmdPath
    58  			return cmd
    59  		},
    60  		Viper:             viper,
    61  		FS:                fs,
    62  		StateManager:      statemanager,
    63  		StateRestorer:     restoreState,
    64  		StateSaver:        persistState,
    65  		YesApplyTerraform: viper.GetBool("terraform-yes") || viper.GetBool("terraform-apply-yes"),
    66  	}
    67  }
    68  
    69  // WithStatusReceiver is a no-op for the Terraformer implementation using Daemon
    70  func (t *ForkTerraformer) WithStatusReceiver(status daemontypes.StatusReceiver) lifecycle.Terraformer {
    71  	return &ForkTerraformer{
    72  		Logger:        t.Logger,
    73  		Daemon:        t.Daemon,
    74  		PlanConfirmer: t.PlanConfirmer,
    75  		Terraform:     t.Terraform,
    76  		Viper:         t.Viper,
    77  		FS:            t.FS,
    78  		StateManager:  t.StateManager,
    79  		StateSaver:    t.StateSaver,
    80  		StateRestorer: t.StateRestorer,
    81  	}
    82  }
    83  
    84  func (t *ForkTerraformer) Execute(ctx context.Context, release api.Release, step api.Terraform, terraformConfirmedChan chan bool) error {
    85  	debug := level.Debug(log.With(t.Logger, "struct", "ForkTerraformer", "method", "execute"))
    86  
    87  	shouldExecute := t.evaluateWhen(step.When, release)
    88  	if !shouldExecute {
    89  		debug.Log("event", "terraform.skipping")
    90  		t.Daemon.PushMessageStep(
    91  			ctx,
    92  			daemontypes.Message{
    93  				Contents: tfSkipping,
    94  				Level:    "Info",
    95  			},
    96  			daemon.MessageActions())
    97  		return nil
    98  	}
    99  
   100  	dir := path.Join(release.FindRenderRoot(), step.Path)
   101  	if err := t.FS.MkdirAll(dir, 0755); err != nil {
   102  		return errors.Wrapf(err, "mkdirall %s", dir)
   103  	}
   104  
   105  	debug.Log("event", "terraform.state.restore")
   106  	if err := t.StateRestorer(debug, t.FS, t.StateManager, dir); err != nil {
   107  		return errors.Wrapf(err, "restore terraform state")
   108  	}
   109  
   110  	debug.Log("event", "terraform.init")
   111  	if err := t.init(dir); err != nil {
   112  		return errors.Wrap(err, "init")
   113  	}
   114  
   115  	debug.Log("event", "terraform.plan")
   116  	plan, hasChanges, err := t.plan(dir)
   117  	if err != nil {
   118  		return errors.Wrap(err, "plan")
   119  	}
   120  	if !hasChanges {
   121  		return nil
   122  	}
   123  
   124  	if !t.YesApplyTerraform {
   125  		debug.Log("event", "terraform.auto-apply")
   126  		shouldApply, err := t.PlanConfirmer.ConfirmPlan(ctx, ansiToHTML(plan), release, terraformConfirmedChan)
   127  		if err != nil {
   128  			return errors.Wrap(err, "confirm plan")
   129  		}
   130  
   131  		if !shouldApply {
   132  			debug.Log("event", "terraform.apply.skip")
   133  			return nil
   134  		}
   135  	}
   136  
   137  	// capacity is whatever's required for tests to proceed
   138  	applyMsgs := make(chan daemontypes.Message, 20)
   139  
   140  	debug.Log("event", "streamStep.push")
   141  	// returns when the applyMsgs channel closes
   142  	go t.Daemon.PushStreamStep(ctx, applyMsgs)
   143  
   144  	// blocks until all of stdout/stderr has been sent on applyMsgs channel
   145  	html, err := t.apply(dir, applyMsgs)
   146  	close(applyMsgs)
   147  	if err != nil {
   148  		t.Daemon.PushMessageStep(
   149  			ctx,
   150  			daemontypes.Message{
   151  				Contents:    html,
   152  				TrustedHTML: true,
   153  				Level:       "error",
   154  			},
   155  			failedApplyActions(),
   156  		)
   157  		retry := <-t.Daemon.TerraformConfirmedChan()
   158  		t.Daemon.CleanPreviousStep()
   159  		if retry {
   160  			return t.Execute(ctx, release, step, terraformConfirmedChan)
   161  		}
   162  		return errors.Wrap(err, "apply")
   163  	}
   164  
   165  	if !viper.GetBool("terraform-yes") {
   166  		t.Daemon.PushMessageStep(
   167  			ctx,
   168  			daemontypes.Message{
   169  				Contents:    html,
   170  				TrustedHTML: true,
   171  			},
   172  			daemon.MessageActions(),
   173  		)
   174  		<-t.Daemon.MessageConfirmedChan()
   175  	}
   176  
   177  	err = t.StateSaver(debug, t.FS, t.StateManager, dir)
   178  	if err != nil {
   179  		return errors.Wrapf(err, "persist terraform state")
   180  	}
   181  
   182  	return nil
   183  }
   184  
   185  func (t *ForkTerraformer) init(dir string) error {
   186  	debug := level.Debug(log.With(t.Logger, "step.type", "terraform", "terraform.phase", "init"))
   187  
   188  	cmd := t.Terraform(dir)
   189  	cmd.Args = append(cmd.Args, "init", "-input=false")
   190  
   191  	var stderr bytes.Buffer
   192  	cmd.Stderr = &stderr
   193  
   194  	out, err := cmd.Output()
   195  	debug.Log("stdout", string(out))
   196  	debug.Log("stderr", stderr.String())
   197  	if err != nil {
   198  		return errors.Wrap(err, string(out)+"\n"+stderr.String())
   199  	}
   200  
   201  	return nil
   202  }
   203  
   204  // plan returns a human readable plan and a changes-required flag
   205  func (t *ForkTerraformer) plan(dir string) (string, bool, error) {
   206  	debug := level.Debug(log.With(t.Logger, "step.type", "terraform", "terraform.phase", "plan"))
   207  	warn := level.Warn(log.With(t.Logger, "step.type", "terraform", "terraform.phase", "plan"))
   208  
   209  	// we really shouldn't write plan to a file, but this will do for now
   210  	cmd := t.Terraform(dir)
   211  	cmd.Args = append(cmd.Args, "plan", "-input=false", "-out=plan.tfplan")
   212  
   213  	var stderr bytes.Buffer
   214  	cmd.Stderr = &stderr
   215  
   216  	out, err := cmd.Output()
   217  	debug.Log("stdout", string(out))
   218  	debug.Log("stderr", stderr.String())
   219  	if err != nil {
   220  		return "", false, errors.Wrap(err, string(out)+"\n"+stderr.String())
   221  		// return "", false, errors.Wrap(err, "exec terraform plan")
   222  	}
   223  	plan := string(out)
   224  
   225  	if strings.Contains(plan, tfNoChanges) {
   226  		debug.Log("changes", false)
   227  		return "", false, nil
   228  	}
   229  	debug.Log("changes", true)
   230  
   231  	// Drop 1st and 3rd sections with notes on state and how to apply
   232  	sections := strings.Split(plan, tfSep)
   233  	if len(sections) != 3 {
   234  		warn.Log("plan.output.sections", len(sections))
   235  		return plan, true, nil
   236  	}
   237  
   238  	return sections[1], true, nil
   239  }
   240  
   241  // apply returns the full stdout and stderr rendered as HTML
   242  func (t *ForkTerraformer) apply(dir string, msgs chan<- daemontypes.Message) (string, error) {
   243  	debug := level.Debug(log.With(t.Logger, "step.type", "terraform", "terraform.phase", "apply"))
   244  
   245  	cmd := t.Terraform(dir)
   246  	cmd.Args = append(cmd.Args, "apply", "-input=false", "-auto-approve=true", "plan.tfplan")
   247  
   248  	stdout, err := cmd.StdoutPipe()
   249  	if err != nil {
   250  		return "", errors.Wrap(err, "get stdout pipe")
   251  	}
   252  	stderr, err := cmd.StderrPipe()
   253  	if err != nil {
   254  		return "", errors.Wrap(err, "get stderr pipe")
   255  	}
   256  
   257  	if err := cmd.Start(); err != nil {
   258  		return "", errors.Wrap(err, "command start")
   259  	}
   260  
   261  	// something to show while waiting for output
   262  	msgs <- daemontypes.Message{
   263  		Contents:    ansiToHTML("terraform apply"),
   264  		TrustedHTML: true,
   265  	}
   266  
   267  	var accm string
   268  	var readErr error
   269  	var mtx sync.Mutex
   270  	var wg sync.WaitGroup
   271  
   272  	wg.Add(2)
   273  
   274  	var pushAccmHTML = func(r io.Reader, name string) {
   275  		defer wg.Done()
   276  
   277  		b := make([]byte, 4096)
   278  		for {
   279  			n, err := r.Read(b)
   280  			if n > 0 {
   281  				latest := string(b[0:n])
   282  				debug.Log(name, latest)
   283  				mtx.Lock()
   284  				accm += latest
   285  				msg := daemontypes.Message{
   286  					Contents:    ansiToHTML(accm),
   287  					TrustedHTML: true,
   288  				}
   289  				msgs <- msg
   290  				mtx.Unlock()
   291  			}
   292  			if err == io.EOF {
   293  				return
   294  			}
   295  			if err != nil {
   296  				mtx.Lock()
   297  				readErr = errors.Wrapf(err, "read %s", name)
   298  				mtx.Unlock()
   299  				return
   300  			}
   301  		}
   302  	}
   303  
   304  	go pushAccmHTML(stdout, "stdout")
   305  	go pushAccmHTML(stderr, "stderr")
   306  
   307  	wg.Wait()
   308  
   309  	if readErr != nil {
   310  		return "", readErr
   311  	}
   312  
   313  	err = cmd.Wait()
   314  
   315  	return ansiToHTML(accm), errors.Wrapf(err, "command wait, output %s", accm)
   316  }
   317  
   318  func failedApplyActions() []daemontypes.Action {
   319  	return []daemontypes.Action{
   320  		{
   321  			ButtonType:  "primary",
   322  			Text:        "Retry",
   323  			LoadingText: "Retrying",
   324  			OnClick: daemontypes.ActionRequest{
   325  				URI:    "/terraform/apply",
   326  				Method: "POST",
   327  			},
   328  		},
   329  	}
   330  }