blob: aff1a6316ca82e521cc8ce9a1546ce80bba22216 [file] [log] [blame]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package views
import (
"bufio"
"bytes"
"fmt"
"strings"
"sync"
"time"
"unicode"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// How long to wait between sending heartbeat/progress messages
const defaultPeriodicUiTimer = 10 * time.Second
const maxIdLen = 80
func NewUiHook(view *View) *UiHook {
return &UiHook{
view: view,
periodicUiTimer: defaultPeriodicUiTimer,
resources: make(map[string]uiResourceState),
log: logging.HCLogger(),
}
}
type UiHook struct {
terraform.NilHook
view *View
viewLock sync.Mutex
periodicUiTimer time.Duration
resources map[string]uiResourceState
resourcesLock sync.Mutex
log hclog.Logger
}
var _ terraform.Hook = (*UiHook)(nil)
// uiResourceState tracks the state of a single resource
type uiResourceState struct {
// Address represents resource address
Address string
// IDKey represents name of the identifyable attribute (e.g. "id" or "name")
IDKey string
// IDValue represents the ID
IDValue string
Op uiResourceOp
Start time.Time
DoneCh chan struct{} // To be used for cancellation
done chan struct{} // used to coordinate tests
}
// uiResourceOp is an enum for operations on a resource
type uiResourceOp byte
const (
uiResourceUnknown uiResourceOp = iota
uiResourceCreate
uiResourceModify
uiResourceDestroy
uiResourceRead
uiResourceNoOp
uiResourceOpen
uiResourceRenew
uiResourceClose
)
func (h *UiHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) {
dispAddr := id.Addr.String()
if dk != addrs.NotDeposed {
dispAddr = fmt.Sprintf("%s (deposed object %s)", dispAddr, dk)
}
var operation string
var op uiResourceOp
idKey, idValue := format.ObjectValueIDOrName(priorState)
switch action {
case plans.Delete:
operation = "Destroying..."
op = uiResourceDestroy
case plans.Create:
operation = "Creating..."
op = uiResourceCreate
case plans.Update:
operation = "Modifying..."
op = uiResourceModify
case plans.Read:
operation = "Reading..."
op = uiResourceRead
case plans.NoOp:
op = uiResourceNoOp
default:
// We don't expect any other actions in here, so anything else is a
// bug in the caller but we'll ignore it in order to be robust.
h.println(fmt.Sprintf("(Unknown action %s for %s)", action, dispAddr))
return terraform.HookActionContinue, nil
}
var stateIdSuffix string
if idKey != "" && idValue != "" {
stateIdSuffix = fmt.Sprintf(" [%s=%s]", idKey, idValue)
} else {
// Make sure they are both empty so we can deal with this more
// easily in the other hook methods.
idKey = ""
idValue = ""
}
if operation != "" {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s%s[reset]"),
dispAddr,
operation,
stateIdSuffix,
))
}
key := id.Addr.String()
uiState := uiResourceState{
Address: key,
IDKey: idKey,
IDValue: idValue,
Op: op,
Start: time.Now().Round(time.Second),
DoneCh: make(chan struct{}),
done: make(chan struct{}),
}
h.resourcesLock.Lock()
h.resources[key] = uiState
h.resourcesLock.Unlock()
// Start goroutine that shows progress
if op != uiResourceNoOp {
go h.stillRunning(uiState)
}
return terraform.HookActionContinue, nil
}
func (h *UiHook) stillRunning(state uiResourceState) {
defer close(state.done)
for {
select {
case <-state.DoneCh:
return
case <-time.After(h.periodicUiTimer):
// Timer up, show status
}
var msg string
switch state.Op {
case uiResourceModify:
msg = "Still modifying..."
case uiResourceDestroy:
msg = "Still destroying..."
case uiResourceCreate:
msg = "Still creating..."
case uiResourceRead:
msg = "Still reading..."
case uiResourceOpen:
msg = "Still opening..."
case uiResourceRenew:
msg = "Still renewing..."
case uiResourceClose:
msg = "Still closing..."
case uiResourceUnknown:
return
}
idSuffix := ""
if state.IDKey != "" {
idSuffix = fmt.Sprintf("%s=%s, ", state.IDKey, truncateId(state.IDValue, maxIdLen))
}
elapsed := time.Now().Round(time.Second).Sub(state.Start)
minutes := int(elapsed.Seconds()) / 60
seconds := int(elapsed.Seconds()) % 60
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s [%s%02dm%02ds elapsed][reset]"),
state.Address,
msg,
idSuffix,
minutes,
seconds,
))
}
}
func (h *UiHook) PostApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey, newState cty.Value, applyerr error) (terraform.HookAction, error) {
addr := id.Addr.String()
h.resourcesLock.Lock()
state := h.resources[addr]
if state.DoneCh != nil {
close(state.DoneCh)
}
delete(h.resources, addr)
h.resourcesLock.Unlock()
var stateIdSuffix string
if k, v := format.ObjectValueID(newState); k != "" && v != "" {
stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v)
}
var msg string
switch state.Op {
case uiResourceModify:
msg = "Modifications complete"
case uiResourceDestroy:
msg = "Destruction complete"
case uiResourceCreate:
msg = "Creation complete"
case uiResourceRead:
msg = "Read complete"
case uiResourceNoOp:
// We don't make any announcements about no-op changes
return terraform.HookActionContinue, nil
case uiResourceUnknown:
return terraform.HookActionContinue, nil
}
if applyerr != nil {
// Errors are collected and printed in ApplyCommand, no need to duplicate
return terraform.HookActionContinue, nil
}
addrStr := id.Addr.String()
if dk != addrs.NotDeposed {
addrStr = fmt.Sprintf("%s (deposed object %s)", addrStr, dk)
}
colorized := fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s after %s%s"),
addrStr, msg, time.Now().Round(time.Second).Sub(state.Start), stateIdSuffix)
h.println(colorized)
return terraform.HookActionContinue, nil
}
func (h *UiHook) PreProvisionInstanceStep(id terraform.HookResourceIdentity, typeName string) (terraform.HookAction, error) {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Provisioning with '%s'...[reset]"),
id.Addr, typeName,
))
return terraform.HookActionContinue, nil
}
func (h *UiHook) ProvisionOutput(id terraform.HookResourceIdentity, typeName string, msg string) {
var buf bytes.Buffer
prefix := fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s (%s):[reset] "),
id.Addr, typeName,
)
s := bufio.NewScanner(strings.NewReader(msg))
s.Split(scanLines)
for s.Scan() {
line := strings.TrimRightFunc(s.Text(), unicode.IsSpace)
if line != "" {
buf.WriteString(fmt.Sprintf("%s%s\n", prefix, line))
}
}
h.println(strings.TrimSpace(buf.String()))
}
func (h *UiHook) PreRefresh(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState cty.Value) (terraform.HookAction, error) {
var stateIdSuffix string
if k, v := format.ObjectValueID(priorState); k != "" && v != "" {
stateIdSuffix = fmt.Sprintf(" [%s=%s]", k, v)
}
addrStr := id.Addr.String()
if dk != addrs.NotDeposed {
addrStr = fmt.Sprintf("%s (deposed object %s)", addrStr, dk)
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Refreshing state...%s"),
addrStr, stateIdSuffix))
return terraform.HookActionContinue, nil
}
func (h *UiHook) PreImportState(id terraform.HookResourceIdentity, importID string) (terraform.HookAction, error) {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Importing from ID %q..."),
id.Addr, importID,
))
return terraform.HookActionContinue, nil
}
func (h *UiHook) PostImportState(id terraform.HookResourceIdentity, imported []providers.ImportedResource) (terraform.HookAction, error) {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold][green]%s: Import prepared!"),
id.Addr,
))
for _, s := range imported {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][green] Prepared %s for import"),
s.TypeName,
))
}
return terraform.HookActionContinue, nil
}
func (h *UiHook) PrePlanImport(id terraform.HookResourceIdentity, importTarget cty.Value) (terraform.HookAction, error) {
if importTarget.Type().IsObjectType() {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Preparing import... [identity=%s]"),
id.Addr, tfdiags.ObjectToString(importTarget),
))
} else {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Preparing import... [id=%s]"),
id.Addr, importTarget.AsString(),
))
}
return terraform.HookActionContinue, nil
}
func (h *UiHook) PreApplyImport(id terraform.HookResourceIdentity, importing plans.ImportingSrc) (terraform.HookAction, error) {
if importing.Identity != nil {
ty, err := importing.Identity.ImpliedType()
if err != nil {
h.log.Debug("UiHook: PreApplyImport failed to get identity ImpliedType", err)
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Importing... [identity=(type error)]"),
id.Addr,
))
return terraform.HookActionContinue, nil
}
val, err := importing.Identity.Decode(ty)
if err != nil {
h.log.Debug("UiHook: PreApplyImport failed to decode identity", err)
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Importing... [identity=(decode error)]"),
id.Addr,
))
return terraform.HookActionContinue, nil
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Importing... [identity=%s]"),
id.Addr, tfdiags.ObjectToString(val),
))
} else {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Importing... [id=%s]"),
id.Addr, importing.ID,
))
}
return terraform.HookActionContinue, nil
}
func (h *UiHook) PostApplyImport(id terraform.HookResourceIdentity, importing plans.ImportingSrc) (terraform.HookAction, error) {
if importing.Identity != nil {
ty, err := importing.Identity.ImpliedType()
if err != nil {
h.log.Debug("UiHook: PostApplyImport failed to get identity ImpliedType", err)
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Import complete [identity=(type error)]"),
id.Addr,
))
return terraform.HookActionContinue, nil
}
val, err := importing.Identity.Decode(ty)
if err != nil {
h.log.Debug("UiHook: PostApplyImport failed to decode identity", err)
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Import complete [identity=(decode error)]"),
id.Addr,
))
return terraform.HookActionContinue, nil
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Import complete [identity=%s]"),
id.Addr, tfdiags.ObjectToString(val),
))
} else {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: Import complete [id=%s]"),
id.Addr, importing.ID,
))
}
return terraform.HookActionContinue, nil
}
func (h *UiHook) PreEphemeralOp(rId terraform.HookResourceIdentity, action plans.Action) (terraform.HookAction, error) {
key := rId.Addr.String()
var operation string
var op uiResourceOp
switch action {
case plans.Read:
// FIXME: this uses the same semantics as data sources, where "read"
// means deferred until apply, but because data sources don't implement
// hooks, and the meaning of Read is overloaded, we can't rely on any
// existing hooks
operation = "Configuration unknown, deferring..."
case plans.Open:
operation = "Opening..."
op = uiResourceOpen
case plans.Renew:
operation = "Renewing..."
op = uiResourceRenew
case plans.Close:
operation = "Closing..."
op = uiResourceClose
default:
// We don't expect any other actions in here, so anything else is a
// bug in the caller but we'll ignore it in order to be robust.
h.println(fmt.Sprintf("(Unknown action %s for %s)", action, key))
return terraform.HookActionContinue, nil
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s"),
rId.Addr, operation,
))
if action == plans.Read {
return terraform.HookActionContinue, nil
}
uiState := uiResourceState{
Address: key,
Op: op,
Start: time.Now().Round(time.Second),
DoneCh: make(chan struct{}),
done: make(chan struct{}),
}
h.resourcesLock.Lock()
h.resources[key] = uiState
h.resourcesLock.Unlock()
go h.stillRunning(uiState)
return terraform.HookActionContinue, nil
}
func (h *UiHook) PostEphemeralOp(rId terraform.HookResourceIdentity, action plans.Action, opErr error) (terraform.HookAction, error) {
addr := rId.Addr.String()
h.resourcesLock.Lock()
state := h.resources[addr]
if state.DoneCh != nil {
close(state.DoneCh)
}
delete(h.resources, addr)
h.resourcesLock.Unlock()
elapsedTime := time.Now().Round(time.Second).Sub(state.Start)
var msg string
switch state.Op {
case uiResourceOpen:
msg = "Opening complete"
case uiResourceRenew:
msg = "Renewal complete"
case uiResourceClose:
msg = "Closing complete"
case uiResourceUnknown:
return terraform.HookActionContinue, nil
}
if opErr != nil {
// Errors are collected and printed in ApplyCommand, no need to duplicate
return terraform.HookActionContinue, nil
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s after %s"),
rId.Addr, msg, elapsedTime,
))
return terraform.HookActionContinue, nil
}
// Wrap calls to the view so that concurrent calls do not interleave println.
func (h *UiHook) println(s string) {
h.viewLock.Lock()
defer h.viewLock.Unlock()
h.view.streams.Println(s)
}
// scanLines is basically copied from the Go standard library except
// we've modified it to also fine `\r`.
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\n'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, dropCR(data[0:i]), nil
}
if i := bytes.IndexByte(data, '\r'); i >= 0 {
// We have a full carriage-return-terminated line.
return i + 1, dropCR(data[0:i]), nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), dropCR(data), nil
}
// Request more data.
return 0, nil, nil
}
// dropCR drops a terminal \r from the data.
func dropCR(data []byte) []byte {
if len(data) > 0 && data[len(data)-1] == '\r' {
return data[0 : len(data)-1]
}
return data
}
func truncateId(id string, maxLen int) string {
// Note that the id may contain multibyte characters.
// We need to truncate it to maxLen characters, not maxLen bytes.
rid := []rune(id)
totalLength := len(rid)
if totalLength <= maxLen {
return id
}
if maxLen < 5 {
// We don't shorten to less than 5 chars
// as that would be pointless with ... (3 chars)
maxLen = 5
}
dots := []rune("...")
partLen := maxLen / 2
leftIdx := partLen - 1
leftPart := rid[0:leftIdx]
rightIdx := totalLength - partLen - 1
overlap := maxLen - (partLen*2 + len(dots))
if overlap < 0 {
rightIdx -= overlap
}
rightPart := rid[rightIdx:]
return string(leftPart) + string(dots) + string(rightPart)
}