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  }