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