Skip to content

Commit

Permalink
Merge pull request #36005 from bschaatsbergen/r/cloud-view
Browse files Browse the repository at this point in the history
  • Loading branch information
bschaatsbergen authored Nov 14, 2024
2 parents dc948f6 + a5d8673 commit 989f31e
Show file tree
Hide file tree
Showing 8 changed files with 44 additions and 617 deletions.
8 changes: 0 additions & 8 deletions internal/backend/backendrun/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"github.com/mitchellh/colorstring"

"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/internal/terraform"
)
Expand Down Expand Up @@ -62,12 +60,6 @@ type CLIOpts struct {
// for tailoring the output to fit the attached terminal, for example.
Streams *terminal.Streams

// FIXME: Temporarily exposing ViewType and View to the backend.
// This is a workaround until the backend is refactored to support
// native View handling.
ViewType arguments.ViewType
View *views.View

// StatePath is the local path where state is read from.
//
// StateOutPath is the local path where the state will be written.
Expand Down
16 changes: 6 additions & 10 deletions internal/cloud/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ type Cloud struct {
// client is the HCP Terraform or Terraform Enterprise API client.
client *tfe.Client

// View handles rendering output in human-readable or machine-readable format from cloud-specific operations.
View views.Cloud
// viewHooks implements functions integrating the tfe.Client with the CLI
// output.
viewHooks views.CloudHooks

// Hostname of HCP Terraform or Terraform Enterprise
Hostname string
Expand Down Expand Up @@ -606,15 +607,10 @@ func cliConfigToken(hostname svchost.Hostname, services *disco.Disco) (string, e
// retryLogHook is invoked each time a request is retried allowing the
// backend to log any connection issues to prevent data loss.
func (b *Cloud) retryLogHook(attemptNum int, resp *http.Response) {
// FIXME: This guard statement prevents a potential nil error
// due to the way the backend is initialized and the context from which
// this function is called.
//
// In a future refactor, we should ensure that views are natively supported
// in backends and allow for calling a View directly within the
// backend.Configure method.
if b.CLI != nil {
b.View.RetryLog(attemptNum, resp)
if output := b.viewHooks.RetryLogHook(attemptNum, resp, true); len(output) > 0 {
b.CLI.Output(b.Colorize().Color(output))
}
}
}

Expand Down
2 changes: 0 additions & 2 deletions internal/cloud/backend_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ package cloud
import (
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/command/views"
)

// CLIInit implements backendrun.CLI
Expand All @@ -26,7 +25,6 @@ func (b *Cloud) CLIInit(opts *backendrun.CLIOpts) error {
Streams: opts.Streams,
Colorize: opts.CLIColor,
}
b.View = views.NewCloud(opts.ViewType, opts.View)

return nil
}
6 changes: 0 additions & 6 deletions internal/command/meta_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,6 @@ func (m *Meta) Backend(opts *BackendOpts) (backendrun.OperationsBackend, tfdiags
}
cliOpts.Validation = true

// FIXME: Temporarily exposing ViewType and View to the backend.
// This is a workaround until the backend is refactored to support
// native View handling.
cliOpts.ViewType = opts.ViewType
cliOpts.View = m.View

// If the backend supports CLI initialization, do it.
if cli, ok := b.(backendrun.CLI); ok {
if err := cli.CLIInit(cliOpts); err != nil {
Expand Down
206 changes: 30 additions & 176 deletions internal/command/views/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,199 +4,53 @@
package views

import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/tfdiags"
)

// The Cloud view is used for operations that are specific to cloud operations.
type Cloud interface {
RetryLog(attemptNum int, resp *http.Response)
Diagnostics(diags tfdiags.Diagnostics)
}

// NewCloud returns Cloud implementation for the given ViewType.
func NewCloud(vt arguments.ViewType, view *View) Cloud {
switch vt {
case arguments.ViewJSON:
return &CloudJSON{
view: NewJSONView(view),
}
case arguments.ViewHuman:
return &CloudHuman{
view: view,
}
default:
panic(fmt.Sprintf("unknown view type %v", vt))
}
}

// The CloudHuman implementation renders human-readable text logs, suitable for
// a scrolling terminal.
type CloudHuman struct {
view *View

lastRetry time.Time
}

var _ Cloud = (*CloudHuman)(nil)

func (v *CloudHuman) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}

func (v *CloudHuman) RetryLog(attemptNum int, resp *http.Response) {
msg, elapsed := retryLogMessage(attemptNum, resp, &v.lastRetry)
// retryLogMessage returns an empty string for the first attempt or for rate-limited responses (HTTP 429)
if msg != "" {
if elapsed != nil {
v.output(msg, elapsed) // subsequent retry message
} else {
v.output(msg) // initial retry message
v.view.streams.Println() // ensures a newline between messages
}
}
}

func (v *CloudHuman) output(messageCode CloudMessageCode, params ...any) {
v.view.streams.Println(v.prepareMessage(messageCode, params...))
}

func (v *CloudHuman) prepareMessage(messageCode CloudMessageCode, params ...any) string {
message, ok := CloudMessageRegistry[messageCode]
if !ok {
// display the message code as fallback if not found in the message registry
return string(messageCode)
}

if message.HumanValue == "" {
// no need to apply colorization if the message is empty
return message.HumanValue
}

output := strings.TrimSpace(fmt.Sprintf(message.HumanValue, params...))
if v.view.colorize != nil {
return v.view.colorize.Color(output)
}

return output
}

// The CloudJSON implementation renders streaming JSON logs, suitable for
// integrating with other software.
type CloudJSON struct {
view *JSONView

// CloudHooks provides functions that help with integrating directly into
// the go-tfe tfe.Client struct.
type CloudHooks struct {
// lastRetry is set to the last time a request was retried.
lastRetry time.Time
}

var _ Cloud = (*CloudJSON)(nil)

func (v *CloudJSON) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}

func (v *CloudJSON) RetryLog(attemptNum int, resp *http.Response) {
msg, elapsed := retryLogMessage(attemptNum, resp, &v.lastRetry)
// retryLogMessage returns an empty string for the first attempt or for rate-limited responses (HTTP 429)
if msg != "" {
if elapsed != nil {
v.output(msg, elapsed) // subsequent retry message
} else {
v.output(msg) // initial retry message
}
}
}

func (v *CloudJSON) output(messageCode CloudMessageCode, params ...any) {
// don't add empty messages to json output
preppedMessage := v.prepareMessage(messageCode, params...)
if preppedMessage == "" {
return
}

current_timestamp := time.Now().UTC().Format(time.RFC3339)
json_data := map[string]string{
"@level": "info",
"@message": preppedMessage,
"@module": "terraform.ui",
"@timestamp": current_timestamp,
"type": "cloud_output",
"message_code": string(messageCode),
// RetryLogHook returns a string providing an update about a request from the
// client being retried.
//
// If colorize is true, then the value returned by this function should be
// processed by a colorizer.
func (hooks *CloudHooks) RetryLogHook(attemptNum int, resp *http.Response, colorize bool) string {
// Ignore the first retry to make sure any delayed output will
// be written to the console before we start logging retries.
//
// The retry logic in the TFE client will retry both rate limited
// requests and server errors, but in the cloud backend we only
// care about server errors so we ignore rate limit (429) errors.
if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) {
hooks.lastRetry = time.Now()
return ""
}

cloud_output, err := json.Marshal(json_data)
if err != nil {
// Handle marshalling error with empty output
cloud_output = []byte{}
var msg string
if attemptNum == 1 {
msg = initialRetryError
} else {
msg = fmt.Sprintf(repeatedRetryError, time.Since(hooks.lastRetry).Round(time.Second))
}
v.view.view.streams.Println(string(cloud_output))
}

func (v *CloudJSON) prepareMessage(messageCode CloudMessageCode, params ...any) string {
message, ok := CloudMessageRegistry[messageCode]
if !ok {
// display the message code as fallback if not found in the message registry
return string(messageCode)
if colorize {
return strings.TrimSpace(fmt.Sprintf("[reset][yellow]%s[reset]", msg))
}

return strings.TrimSpace(fmt.Sprintf(message.JSONValue, params...))
}

// CloudMessage represents a message string in both json and human decorated text format.
type CloudMessage struct {
HumanValue string
JSONValue string
}

var CloudMessageRegistry map[CloudMessageCode]CloudMessage = map[CloudMessageCode]CloudMessage{
"initial_retry_error_message": {
HumanValue: initialRetryError,
JSONValue: initialRetryErrorJSON,
},
"repeated_retry_error_message": {
HumanValue: repeatedRetryError,
JSONValue: repeatedRetryErrorJSON,
},
return strings.TrimSpace(msg)
}

type CloudMessageCode string

const (
InitialRetryErrorMessage CloudMessageCode = "initial_retry_error_message"
RepeatedRetryErrorMessage CloudMessageCode = "repeated_retry_error_message"
)

const initialRetryError = `[reset][yellow]
There was an error connecting to HCP Terraform. Please do not exit
Terraform to prevent data loss! Trying to restore the connection...[reset]
`
const initialRetryErrorJSON = `
// The newline in this error is to make it look good in the CLI!
const initialRetryError = `
There was an error connecting to HCP Terraform. Please do not exit
Terraform to prevent data loss! Trying to restore the connection...
`

const repeatedRetryError = `[reset][yellow]Still trying to restore the connection... (%s elapsed)[reset]`
const repeatedRetryErrorJSON = `Still trying to restore the connection... (%s elapsed)`

func retryLogMessage(attemptNum int, resp *http.Response, lastRetry *time.Time) (CloudMessageCode, *time.Duration) {
// Skips logging for the first attempt or for rate-limited requests (HTTP 429)
if attemptNum == 0 || (resp != nil && resp.StatusCode == 429) {
*lastRetry = time.Now() // Update the retry timestamp for subsequent attempts
return "", nil
}

// Logs initial retry message on the first retry attempt
if attemptNum == 1 {
return InitialRetryErrorMessage, nil
}

// Logs repeated retry message on subsequent attempts with elapsed time
elapsed := time.Since(*lastRetry).Round(time.Second)
return RepeatedRetryErrorMessage, &elapsed
}
const repeatedRetryError = "Still trying to restore the connection... (%s elapsed)"
Loading

0 comments on commit 989f31e

Please sign in to comment.