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 }