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 }