Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert #35929 (add new cloud view) #36005

Merged
merged 1 commit into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading