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

views: add cloud view #35929

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 6 additions & 0 deletions internal/backend/backendrun/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/mitchellh/colorstring"

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

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

// 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: 10 additions & 6 deletions internal/cloud/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,8 @@ type Cloud struct {
// client is the HCP Terraform or Terraform Enterprise API client.
client *tfe.Client

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

// Hostname of HCP Terraform or Terraform Enterprise
Hostname string
Expand Down Expand Up @@ -606,10 +605,15 @@ 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 {
if output := b.viewHooks.RetryLogHook(attemptNum, resp, true); len(output) > 0 {
b.CLI.Output(b.Colorize().Color(output))
}
b.View.RetryLog(attemptNum, resp)
}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/cloud/backend_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 @@ -25,6 +26,8 @@ func (b *Cloud) CLIInit(opts *backendrun.CLIOpts) error {
Streams: opts.Streams,
Colorize: opts.CLIColor,
}
view := views.NewView(opts.Streams)
b.View = views.NewCloud(opts.ViewType, view)

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

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

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

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

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

// 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.
// 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)
Output(messageCode CloudMessageCode, params ...any)
PrepareMessage(messageCode CloudMessageCode, params ...any) string
}

// 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
}

// 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 {
var _ Cloud = (*CloudHuman)(nil)

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

func (v *CloudHuman) RetryLog(attemptNum int, resp *http.Response) {
// 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 ""
v.lastRetry = time.Now()
return
}

var msg string
if attemptNum == 1 {
msg = initialRetryError
msg = v.PrepareMessage(InitialRetryErrorMessage)
} else {
msg = fmt.Sprintf(repeatedRetryError, time.Since(hooks.lastRetry).Round(time.Second))
msg = v.PrepareMessage(RepeatedRetryErrorMessage, time.Since(v.lastRetry).Round(time.Second))
}

v.view.streams.Println(msg)
}

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 colorize {
return strings.TrimSpace(fmt.Sprintf("[reset][yellow]%s[reset]", msg))
if message.HumanValue == "" {
// no need to apply colorization if the message is empty
return message.HumanValue
}
return strings.TrimSpace(msg)

return v.view.colorize.Color(strings.TrimSpace(fmt.Sprintf(message.HumanValue, params...)))
}

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

lastRetry time.Time
}

// The newline in this error is to make it look good in the CLI!
const initialRetryError = `
var _ Cloud = (*CloudJSON)(nil)

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

func (v *CloudJSON) RetryLog(attemptNum int, resp *http.Response) {
// 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) {
v.lastRetry = time.Now()
return
}

var msg string
if attemptNum == 1 {
msg = v.PrepareMessage(InitialRetryErrorMessage)
} else {
msg = v.PrepareMessage(RepeatedRetryErrorMessage, time.Since(v.lastRetry).Round(time.Second))
}

v.view.view.streams.Println(msg)
}

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),
}

cloud_output, _ := json.Marshal(json_data)
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)
}

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: repeatdRetryError,
JSONValue: repeatdRetryErrorJSON,
},
}

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 = `
There was an error connecting to HCP Terraform. Please do not exit
Terraform to prevent data loss! Trying to restore the connection...
`

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