github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/views/hook_ui.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package views 5 6 import ( 7 "bufio" 8 "bytes" 9 "fmt" 10 "strings" 11 "sync" 12 "time" 13 "unicode" 14 15 "github.com/zclconf/go-cty/cty" 16 17 "github.com/terramate-io/tf/addrs" 18 "github.com/terramate-io/tf/command/format" 19 "github.com/terramate-io/tf/plans" 20 "github.com/terramate-io/tf/providers" 21 "github.com/terramate-io/tf/states" 22 "github.com/terramate-io/tf/terraform" 23 ) 24 25 const defaultPeriodicUiTimer = 10 * time.Second 26 const maxIdLen = 80 27 28 func NewUiHook(view *View) *UiHook { 29 return &UiHook{ 30 view: view, 31 periodicUiTimer: defaultPeriodicUiTimer, 32 resources: make(map[string]uiResourceState), 33 } 34 } 35 36 type UiHook struct { 37 terraform.NilHook 38 39 view *View 40 viewLock sync.Mutex 41 42 periodicUiTimer time.Duration 43 44 resources map[string]uiResourceState 45 resourcesLock sync.Mutex 46 } 47 48 var _ terraform.Hook = (*UiHook)(nil) 49 50 // uiResourceState tracks the state of a single resource 51 type uiResourceState struct { 52 DispAddr string 53 IDKey, IDValue string 54 Op uiResourceOp 55 Start time.Time 56 57 DoneCh chan struct{} // To be used for cancellation 58 59 done chan struct{} // used to coordinate tests 60 } 61 62 // uiResourceOp is an enum for operations on a resource 63 type uiResourceOp byte 64 65 const ( 66 uiResourceUnknown uiResourceOp = iota 67 uiResourceCreate 68 uiResourceModify 69 uiResourceDestroy 70 uiResourceRead 71 uiResourceNoOp 72 ) 73 74 func (h *UiHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { 75 dispAddr := addr.String() 76 if gen != states.CurrentGen { 77 dispAddr = fmt.Sprintf("%s (deposed object %s)", dispAddr, gen) 78 } 79 80 var operation string 81 var op uiResourceOp 82 idKey, idValue := format.ObjectValueIDOrName(priorState) 83 switch action { 84 case plans.Delete: 85 operation = "Destroying..." 86 op = uiResourceDestroy 87 case plans.Create: 88 operation = "Creating..." 89 op = uiResourceCreate 90 case plans.Update: 91 operation = "Modifying..." 92 op = uiResourceModify 93 case plans.Read: 94 operation = "Reading..." 95 op = uiResourceRead 96 case plans.NoOp: 97 op = uiResourceNoOp 98 default: 99 // We don't expect any other actions in here, so anything else is a 100 // bug in the caller but we'll ignore it in order to be robust. 101 h.println(fmt.Sprintf("(Unknown action %s for %s)", action, dispAddr)) 102 return terraform.HookActionContinue, nil 103 } 104 105 var stateIdSuffix string 106 if idKey != "" && idValue != "" { 107 stateIdSuffix = fmt.Sprintf(" [%s=%s]", idKey, idValue) 108 } else { 109 // Make sure they are both empty so we can deal with this more 110 // easily in the other hook methods. 111 idKey = "" 112 idValue = "" 113 } 114 115 if operation != "" { 116 h.println(fmt.Sprintf( 117 h.view.colorize.Color("[reset][bold]%s: %s%s[reset]"), 118 dispAddr, 119 operation, 120 stateIdSuffix, 121 )) 122 } 123 124 key := addr.String() 125 uiState := uiResourceState{ 126 DispAddr: key, 127 IDKey: idKey, 128 IDValue: idValue, 129 Op: op, 130 Start: time.Now().Round(time.Second), 131 DoneCh: make(chan struct{}), 132 done: make(chan struct{}), 133 } 134 135 h.resourcesLock.Lock() 136 h.resources[key] = uiState 137 h.resourcesLock.Unlock() 138 139 // Start goroutine that shows progress 140 if op != uiResourceNoOp { 141 go h.stillApplying(uiState) 142 } 143 144 return terraform.HookActionContinue, nil 145 } 146 147 func (h *UiHook) stillApplying(state uiResourceState) { 148 defer close(state.done) 149 for { 150 select { 151 case <-state.DoneCh: 152 return 153 154 case <-time.After(h.periodicUiTimer): 155 // Timer up, show status 156 } 157 158 var msg string 159 switch state.Op { 160 case uiResourceModify: 161 msg = "Still modifying..." 162 case uiResourceDestroy: 163 msg = "Still destroying..." 164 case uiResourceCreate: 165 msg = "Still creating..." 166 case uiResourceRead: 167 msg = "Still reading..." 168 case uiResourceUnknown: 169 return 170 } 171 172 idSuffix := "" 173 if state.IDKey != "" { 174 idSuffix = fmt.Sprintf("%s=%s, ", state.IDKey, truncateId(state.IDValue, maxIdLen)) 175 } 176 177 h.println(fmt.Sprintf( 178 h.view.colorize.Color("[reset][bold]%s: %s [%s%s elapsed][reset]"), 179 state.DispAddr, 180 msg, 181 idSuffix, 182 time.Now().Round(time.Second).Sub(state.Start), 183 )) 184 } 185 } 186 187 func (h *UiHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, applyerr error) (terraform.HookAction, error) { 188 id := addr.String() 189 190 h.resourcesLock.Lock() 191 state := h.resources[id] 192 if state.DoneCh != nil { 193 close(state.DoneCh) 194 } 195 196 delete(h.resources, id) 197 h.resourcesLock.Unlock() 198 199 var stateIdSuffix string 200 if k, v := format.ObjectValueID(newState); k != "" && v != "" { 201 stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v) 202 } 203 204 var msg string 205 switch state.Op { 206 case uiResourceModify: 207 msg = "Modifications complete" 208 case uiResourceDestroy: 209 msg = "Destruction complete" 210 case uiResourceCreate: 211 msg = "Creation complete" 212 case uiResourceRead: 213 msg = "Read complete" 214 case uiResourceNoOp: 215 // We don't make any announcements about no-op changes 216 return terraform.HookActionContinue, nil 217 case uiResourceUnknown: 218 return terraform.HookActionContinue, nil 219 } 220 221 if applyerr != nil { 222 // Errors are collected and printed in ApplyCommand, no need to duplicate 223 return terraform.HookActionContinue, nil 224 } 225 226 addrStr := addr.String() 227 if depKey, ok := gen.(states.DeposedKey); ok { 228 addrStr = fmt.Sprintf("%s (deposed object %s)", addrStr, depKey) 229 } 230 231 colorized := fmt.Sprintf( 232 h.view.colorize.Color("[reset][bold]%s: %s after %s%s"), 233 addrStr, msg, time.Now().Round(time.Second).Sub(state.Start), stateIdSuffix) 234 235 h.println(colorized) 236 237 return terraform.HookActionContinue, nil 238 } 239 240 func (h *UiHook) PreProvisionInstanceStep(addr addrs.AbsResourceInstance, typeName string) (terraform.HookAction, error) { 241 h.println(fmt.Sprintf( 242 h.view.colorize.Color("[reset][bold]%s: Provisioning with '%s'...[reset]"), 243 addr, typeName, 244 )) 245 return terraform.HookActionContinue, nil 246 } 247 248 func (h *UiHook) ProvisionOutput(addr addrs.AbsResourceInstance, typeName string, msg string) { 249 var buf bytes.Buffer 250 251 prefix := fmt.Sprintf( 252 h.view.colorize.Color("[reset][bold]%s (%s):[reset] "), 253 addr, typeName, 254 ) 255 s := bufio.NewScanner(strings.NewReader(msg)) 256 s.Split(scanLines) 257 for s.Scan() { 258 line := strings.TrimRightFunc(s.Text(), unicode.IsSpace) 259 if line != "" { 260 buf.WriteString(fmt.Sprintf("%s%s\n", prefix, line)) 261 } 262 } 263 264 h.println(strings.TrimSpace(buf.String())) 265 } 266 267 func (h *UiHook) PreRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value) (terraform.HookAction, error) { 268 var stateIdSuffix string 269 if k, v := format.ObjectValueID(priorState); k != "" && v != "" { 270 stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v) 271 } 272 273 addrStr := addr.String() 274 if depKey, ok := gen.(states.DeposedKey); ok { 275 addrStr = fmt.Sprintf("%s (deposed object %s)", addrStr, depKey) 276 } 277 278 h.println(fmt.Sprintf( 279 h.view.colorize.Color("[reset][bold]%s: Refreshing state...%s"), 280 addrStr, stateIdSuffix)) 281 return terraform.HookActionContinue, nil 282 } 283 284 func (h *UiHook) PreImportState(addr addrs.AbsResourceInstance, importID string) (terraform.HookAction, error) { 285 h.println(fmt.Sprintf( 286 h.view.colorize.Color("[reset][bold]%s: Importing from ID %q..."), 287 addr, importID, 288 )) 289 return terraform.HookActionContinue, nil 290 } 291 292 func (h *UiHook) PostImportState(addr addrs.AbsResourceInstance, imported []providers.ImportedResource) (terraform.HookAction, error) { 293 h.println(fmt.Sprintf( 294 h.view.colorize.Color("[reset][bold][green]%s: Import prepared!"), 295 addr, 296 )) 297 for _, s := range imported { 298 h.println(fmt.Sprintf( 299 h.view.colorize.Color("[reset][green] Prepared %s for import"), 300 s.TypeName, 301 )) 302 } 303 304 return terraform.HookActionContinue, nil 305 } 306 307 func (h *UiHook) PrePlanImport(addr addrs.AbsResourceInstance, importID string) (terraform.HookAction, error) { 308 h.println(fmt.Sprintf( 309 h.view.colorize.Color("[reset][bold]%s: Preparing import... [id=%s]"), 310 addr, importID, 311 )) 312 313 return terraform.HookActionContinue, nil 314 } 315 316 func (h *UiHook) PreApplyImport(addr addrs.AbsResourceInstance, importing plans.ImportingSrc) (terraform.HookAction, error) { 317 h.println(fmt.Sprintf( 318 h.view.colorize.Color("[reset][bold]%s: Importing... [id=%s]"), 319 addr, importing.ID, 320 )) 321 322 return terraform.HookActionContinue, nil 323 } 324 325 func (h *UiHook) PostApplyImport(addr addrs.AbsResourceInstance, importing plans.ImportingSrc) (terraform.HookAction, error) { 326 h.println(fmt.Sprintf( 327 h.view.colorize.Color("[reset][bold]%s: Import complete [id=%s]"), 328 addr, importing.ID, 329 )) 330 331 return terraform.HookActionContinue, nil 332 } 333 334 // Wrap calls to the view so that concurrent calls do not interleave println. 335 func (h *UiHook) println(s string) { 336 h.viewLock.Lock() 337 defer h.viewLock.Unlock() 338 h.view.streams.Println(s) 339 } 340 341 // scanLines is basically copied from the Go standard library except 342 // we've modified it to also fine `\r`. 343 func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { 344 if atEOF && len(data) == 0 { 345 return 0, nil, nil 346 } 347 if i := bytes.IndexByte(data, '\n'); i >= 0 { 348 // We have a full newline-terminated line. 349 return i + 1, dropCR(data[0:i]), nil 350 } 351 if i := bytes.IndexByte(data, '\r'); i >= 0 { 352 // We have a full carriage-return-terminated line. 353 return i + 1, dropCR(data[0:i]), nil 354 } 355 // If we're at EOF, we have a final, non-terminated line. Return it. 356 if atEOF { 357 return len(data), dropCR(data), nil 358 } 359 // Request more data. 360 return 0, nil, nil 361 } 362 363 // dropCR drops a terminal \r from the data. 364 func dropCR(data []byte) []byte { 365 if len(data) > 0 && data[len(data)-1] == '\r' { 366 return data[0 : len(data)-1] 367 } 368 return data 369 } 370 371 func truncateId(id string, maxLen int) string { 372 // Note that the id may contain multibyte characters. 373 // We need to truncate it to maxLen characters, not maxLen bytes. 374 rid := []rune(id) 375 totalLength := len(rid) 376 if totalLength <= maxLen { 377 return id 378 } 379 if maxLen < 5 { 380 // We don't shorten to less than 5 chars 381 // as that would be pointless with ... (3 chars) 382 maxLen = 5 383 } 384 385 dots := []rune("...") 386 partLen := maxLen / 2 387 388 leftIdx := partLen - 1 389 leftPart := rid[0:leftIdx] 390 391 rightIdx := totalLength - partLen - 1 392 393 overlap := maxLen - (partLen*2 + len(dots)) 394 if overlap < 0 { 395 rightIdx -= overlap 396 } 397 398 rightPart := rid[rightIdx:] 399 400 return string(leftPart) + string(dots) + string(rightPart) 401 }