github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/views/hook_json.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package views 5 6 import ( 7 "bufio" 8 "strings" 9 "sync" 10 "time" 11 "unicode" 12 13 "github.com/zclconf/go-cty/cty" 14 15 "github.com/terramate-io/tf/addrs" 16 "github.com/terramate-io/tf/command/format" 17 "github.com/terramate-io/tf/command/views/json" 18 "github.com/terramate-io/tf/plans" 19 "github.com/terramate-io/tf/states" 20 "github.com/terramate-io/tf/terraform" 21 ) 22 23 // How long to wait between sending heartbeat/progress messages 24 const heartbeatInterval = 10 * time.Second 25 26 func newJSONHook(view *JSONView) *jsonHook { 27 return &jsonHook{ 28 view: view, 29 applying: make(map[string]applyProgress), 30 timeNow: time.Now, 31 timeAfter: time.After, 32 } 33 } 34 35 type jsonHook struct { 36 terraform.NilHook 37 38 view *JSONView 39 40 // Concurrent map of resource addresses to allow the sequence of pre-apply, 41 // progress, and post-apply messages to share data about the resource 42 applying map[string]applyProgress 43 applyingLock sync.Mutex 44 45 // Mockable functions for testing the progress timer goroutine 46 timeNow func() time.Time 47 timeAfter func(time.Duration) <-chan time.Time 48 } 49 50 var _ terraform.Hook = (*jsonHook)(nil) 51 52 type applyProgress struct { 53 addr addrs.AbsResourceInstance 54 action plans.Action 55 start time.Time 56 57 // done is used for post-apply to stop the progress goroutine 58 done chan struct{} 59 60 // heartbeatDone is used to allow tests to safely wait for the progress 61 // goroutine to finish 62 heartbeatDone chan struct{} 63 } 64 65 func (h *jsonHook) PreApply(addr addrs.AbsResourceInstance, gen states.Generation, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { 66 if action != plans.NoOp { 67 idKey, idValue := format.ObjectValueIDOrName(priorState) 68 h.view.Hook(json.NewApplyStart(addr, action, idKey, idValue)) 69 } 70 71 progress := applyProgress{ 72 addr: addr, 73 action: action, 74 start: h.timeNow().Round(time.Second), 75 done: make(chan struct{}), 76 heartbeatDone: make(chan struct{}), 77 } 78 h.applyingLock.Lock() 79 h.applying[addr.String()] = progress 80 h.applyingLock.Unlock() 81 82 if action != plans.NoOp { 83 go h.applyingHeartbeat(progress) 84 } 85 return terraform.HookActionContinue, nil 86 } 87 88 func (h *jsonHook) applyingHeartbeat(progress applyProgress) { 89 defer close(progress.heartbeatDone) 90 for { 91 select { 92 case <-progress.done: 93 return 94 case <-h.timeAfter(heartbeatInterval): 95 } 96 97 elapsed := h.timeNow().Round(time.Second).Sub(progress.start) 98 h.view.Hook(json.NewApplyProgress(progress.addr, progress.action, elapsed)) 99 } 100 } 101 102 func (h *jsonHook) PostApply(addr addrs.AbsResourceInstance, gen states.Generation, newState cty.Value, err error) (terraform.HookAction, error) { 103 key := addr.String() 104 h.applyingLock.Lock() 105 progress := h.applying[key] 106 if progress.done != nil { 107 close(progress.done) 108 } 109 delete(h.applying, key) 110 h.applyingLock.Unlock() 111 112 if progress.action == plans.NoOp { 113 return terraform.HookActionContinue, nil 114 } 115 116 elapsed := h.timeNow().Round(time.Second).Sub(progress.start) 117 118 if err != nil { 119 // Errors are collected and displayed post-apply, so no need to 120 // re-render them here. Instead just signal that this resource failed 121 // to apply. 122 h.view.Hook(json.NewApplyErrored(addr, progress.action, elapsed)) 123 } else { 124 idKey, idValue := format.ObjectValueID(newState) 125 h.view.Hook(json.NewApplyComplete(addr, progress.action, idKey, idValue, elapsed)) 126 } 127 return terraform.HookActionContinue, nil 128 } 129 130 func (h *jsonHook) PreProvisionInstanceStep(addr addrs.AbsResourceInstance, typeName string) (terraform.HookAction, error) { 131 h.view.Hook(json.NewProvisionStart(addr, typeName)) 132 return terraform.HookActionContinue, nil 133 } 134 135 func (h *jsonHook) PostProvisionInstanceStep(addr addrs.AbsResourceInstance, typeName string, err error) (terraform.HookAction, error) { 136 if err != nil { 137 // Errors are collected and displayed post-apply, so no need to 138 // re-render them here. Instead just signal that this provisioner step 139 // failed. 140 h.view.Hook(json.NewProvisionErrored(addr, typeName)) 141 } else { 142 h.view.Hook(json.NewProvisionComplete(addr, typeName)) 143 } 144 return terraform.HookActionContinue, nil 145 } 146 147 func (h *jsonHook) ProvisionOutput(addr addrs.AbsResourceInstance, typeName string, msg string) { 148 s := bufio.NewScanner(strings.NewReader(msg)) 149 s.Split(scanLines) 150 for s.Scan() { 151 line := strings.TrimRightFunc(s.Text(), unicode.IsSpace) 152 if line != "" { 153 h.view.Hook(json.NewProvisionProgress(addr, typeName, line)) 154 } 155 } 156 } 157 158 func (h *jsonHook) PreRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value) (terraform.HookAction, error) { 159 idKey, idValue := format.ObjectValueID(priorState) 160 h.view.Hook(json.NewRefreshStart(addr, idKey, idValue)) 161 return terraform.HookActionContinue, nil 162 } 163 164 func (h *jsonHook) PostRefresh(addr addrs.AbsResourceInstance, gen states.Generation, priorState cty.Value, newState cty.Value) (terraform.HookAction, error) { 165 idKey, idValue := format.ObjectValueID(newState) 166 h.view.Hook(json.NewRefreshComplete(addr, idKey, idValue)) 167 return terraform.HookActionContinue, nil 168 }