Initial implementation of structured logging in cloud backend (#32504)

* Implementation of structured logging.

These are the changes that enable the cloud backend to consume
structured logs and make use of the new plan renderer. This will enable
CLI-driven runs to view the structured output in the Terraform Cloud UI.

* Cloud structured logging unit tests

* Remove deferred logs logic, fix minor issues

Color formatting fixes, log type stop lists, default behavior for logs
that are unknown

* Use service disco path in redacted plan url
This commit is contained in:
Sebastian Rivera 2023-02-09 07:35:48 -05:00 committed by GitHub
parent afcbff193b
commit de574ae6d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1200 additions and 50 deletions

8
go.mod
View File

@ -38,12 +38,13 @@ require (
github.com/hashicorp/go-hclog v0.15.0
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/hashicorp/go-tfe v1.14.0
github.com/hashicorp/go-retryablehttp v0.7.2
github.com/hashicorp/go-tfe v1.18.0
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f
github.com/hashicorp/hcl/v2 v2.16.0
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d
github.com/hashicorp/terraform-config-inspect v0.0.0-20210209133302-4fd17a0faac2
github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c
github.com/hashicorp/terraform-svchost v0.1.0
@ -147,7 +148,6 @@ require (
github.com/hashicorp/go-safetemp v1.0.0 // indirect
github.com/hashicorp/go-slug v0.10.1 // indirect
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect
github.com/hashicorp/serf v0.9.5 // indirect
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect
github.com/huandu/xstrings v1.3.3 // indirect
@ -175,7 +175,7 @@ require (
github.com/vmihailenco/tagparser v0.1.1 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e // indirect
golang.org/x/time v0.1.0 // indirect
golang.org/x/time v0.3.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect

12
go.sum
View File

@ -365,8 +365,8 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
github.com/hashicorp/go-plugin v1.4.3 h1:DXmvivbWD5qdiBts9TpBC7BYL1Aia5sxbRgQB+v6UZM=
github.com/hashicorp/go-plugin v1.4.3/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ=
github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo=
@ -376,8 +376,8 @@ github.com/hashicorp/go-slug v0.10.1/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu4
github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-tfe v1.14.0 h1:FZKKkwlyTxw8/OE3e7NiFQLcgGXTHra9ogGhMTotxh8=
github.com/hashicorp/go-tfe v1.14.0/go.mod h1:77snluBqtTTvMrY0w/mxQA5jlHQ8NT44AqQ8UdrPf0o=
github.com/hashicorp/go-tfe v1.18.0 h1:AjyZe2KSAyGHD1kbGYlY64PVYQPnJJON24qr97IjIqA=
github.com/hashicorp/go-tfe v1.18.0/go.mod h1:T76X7dHKNEPEugPCZI3gDdaDdxUU4P4sqMZO60W57cQ=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
@ -864,8 +864,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA=
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=

View File

@ -23,6 +23,7 @@ import (
"github.com/zclconf/go-cty/cty/gocty"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/statemgr"
@ -75,6 +76,9 @@ type Cloud struct {
// services is used for service discovery
services *disco.Disco
// renderer is used for rendering JSON plan output and streamed logs.
renderer *jsonformat.Renderer
// local allows local operations, where Terraform Cloud serves as a state storage backend.
local backend.Enhanced

View File

@ -3,11 +3,13 @@ package cloud
import (
"bufio"
"context"
"encoding/json"
"io"
"log"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
@ -151,41 +153,70 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
return r, err
}
logs, err := b.client.Applies.Logs(stopCtx, r.Apply.ID)
err = b.renderApplyLogs(stopCtx, r)
if err != nil {
return r, generalError("Failed to retrieve logs", err)
return r, err
}
return r, nil
}
func (b *Cloud) renderApplyLogs(ctx context.Context, run *tfe.Run) error {
logs, err := b.client.Applies.Logs(ctx, run.Apply.ID)
if err != nil {
return err
}
reader := bufio.NewReaderSize(logs, 64*1024)
if b.CLI != nil {
reader := bufio.NewReaderSize(logs, 64*1024)
skip := 0
for next := true; next; {
var l, line []byte
var err error
for isPrefix := true; isPrefix; {
l, isPrefix, err = reader.ReadLine()
if err != nil {
if err != io.EOF {
return r, generalError("Failed to read logs", err)
return generalError("Failed to read logs", err)
}
next = false
}
line = append(line, l...)
}
// Skip the first 3 lines to prevent duplicate output.
// Apply logs show the same Terraform info logs as shown in the plan logs
// (which contain version and os/arch information), we therefore skip to prevent duplicate output.
if skip < 3 {
skip++
continue
}
if next || len(line) > 0 {
b.CLI.Output(b.Colorize().Color(string(line)))
log := &jsonformat.JSONLog{}
if err := json.Unmarshal(line, log); err != nil {
// If we can not parse the line as JSON, we will simply
// print the line. This maintains backwards compatibility for
// users who do not wish to enable structured output in their
// workspace.
b.CLI.Output(string(line))
continue
}
if b.renderer != nil {
// Otherwise, we will print the log
err := b.renderer.RenderLog(log)
if err != nil {
return err
}
}
}
}
}
return r, nil
return nil
}
const applyDefaultHeader = `

View File

@ -19,6 +19,7 @@ import (
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/initwd"
@ -114,6 +115,153 @@ func TestCloud_applyBasic(t *testing.T) {
}
}
func TestCloud_applyJSONBasic(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationApply(t, "./testdata/apply-json")
defer configCleanup()
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result != backend.OperationSuccess {
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
}
if run.PlanEmpty {
t.Fatalf("expected a non-empty plan")
}
if len(input.answers) > 0 {
t.Fatalf("expected no unused answers, got: %v", input.answers)
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", gotOut)
}
if !strings.Contains(gotOut, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summary in output: %s", gotOut)
}
stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyJSONWithOutputs(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationApply(t, "./testdata/apply-json-with-outputs")
defer configCleanup()
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result != backend.OperationSuccess {
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
}
if run.PlanEmpty {
t.Fatalf("expected a non-empty plan")
}
if len(input.answers) > 0 {
t.Fatalf("expected no unused answers, got: %v", input.answers)
}
outp := close(t)
gotOut := outp.Stdout()
expectedSimpleOutput := `simple = [
"some",
"list",
]`
expectedSensitiveOutput := `secret = (sensitive value)`
expectedComplexOutput := `complex = {
keyA = {
someList = [
1,
2,
3,
]
}
keyB = {
someBool = true
someStr = "hello"
}
}`
if !strings.Contains(gotOut, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", gotOut)
}
if !strings.Contains(gotOut, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summary in output: %s", gotOut)
}
if !strings.Contains(gotOut, "Outputs:") {
t.Fatalf("expected output header: %s", gotOut)
}
if !strings.Contains(gotOut, expectedSimpleOutput) {
t.Fatalf("expected output: %s, got: %s", expectedSimpleOutput, gotOut)
}
if !strings.Contains(gotOut, expectedSensitiveOutput) {
t.Fatalf("expected output: %s, got: %s", expectedSensitiveOutput, gotOut)
}
if !strings.Contains(gotOut, expectedComplexOutput) {
t.Fatalf("expected output: %s, got: %s", expectedComplexOutput, gotOut)
}
stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyCanceled(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -1443,6 +1591,46 @@ func TestCloud_applyWithRemoteError(t *testing.T) {
}
}
func TestCloud_applyJSONWithRemoteError(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationApply(t, "./testdata/apply-json-with-error")
defer configCleanup()
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if run.Result.ExitStatus() != 1 {
t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus())
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "Unsupported block type") {
t.Fatalf("unexpected plan error in output: %s", gotOut)
}
}
func TestCloud_applyVersionCheck(t *testing.T) {
testCases := map[string]struct {
localVersion string

View File

@ -2,6 +2,7 @@ package cloud
import (
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
)
// CLIInit implements backend.CLI
@ -17,6 +18,10 @@ func (b *Cloud) CLIInit(opts *backend.CLIOpts) error {
b.ContextOpts = opts.ContextOpts
b.runningInAutomation = opts.RunningInAutomation
b.input = opts.Input
b.renderer = &jsonformat.Renderer{
Streams: opts.Streams,
Colorize: opts.CLIColor,
}
return nil
}

View File

@ -3,15 +3,23 @@ package cloud
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/hashicorp/go-retryablehttp"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/jsonapi"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/terraform"
)
@ -538,3 +546,90 @@ func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *te
return <-result
}
// This method will fetch the redacted plan output and marshal the response into
// a struct the jsonformat.Renderer expects.
//
// Note: Apologies for the lengthy definition, this is a result of not being able to mock receiver methods
var readRedactedPlan func(context.Context, url.URL, string, string) (*jsonformat.Plan, error) = func(ctx context.Context, baseURL url.URL, token string, planID string) (*jsonformat.Plan, error) {
client := retryablehttp.NewClient()
client.RetryMax = 10
client.RetryWaitMin = 100 * time.Millisecond
client.RetryWaitMax = 400 * time.Millisecond
client.Logger = logging.HCLogger()
u, err := baseURL.Parse(fmt.Sprintf(
"plans/%s/json-output-redacted", url.QueryEscape(planID)))
if err != nil {
return nil, err
}
req, err := retryablehttp.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
p := &jsonformat.Plan{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err = checkResponseCode(resp); err != nil {
return nil, err
}
if err := json.NewDecoder(resp.Body).Decode(p); err != nil {
return nil, err
}
return p, nil
}
func checkResponseCode(r *http.Response) error {
if r.StatusCode >= 200 && r.StatusCode <= 299 {
return nil
}
var errs []string
var err error
switch r.StatusCode {
case 401:
return tfe.ErrUnauthorized
case 404:
return tfe.ErrResourceNotFound
}
errs, err = decodeErrorPayload(r)
if err != nil {
return err
}
return errors.New(strings.Join(errs, "\n"))
}
func decodeErrorPayload(r *http.Response) ([]string, error) {
// Decode the error payload.
var errs []string
errPayload := &jsonapi.ErrorsPayload{}
err := json.NewDecoder(r.Body).Decode(errPayload)
if err != nil || len(errPayload.Errors) == 0 {
return errs, errors.New(r.Status)
}
// Parse and format the errors.
for _, e := range errPayload.Errors {
if e.Detail == "" {
errs = append(errs, e.Title)
} else {
errs = append(errs, fmt.Sprintf("%s\n\n%s", e.Title, e.Detail))
}
}
return errs, nil
}

View File

@ -3,6 +3,7 @@ package cloud
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@ -16,6 +17,7 @@ import (
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/tfdiags"
)
@ -309,31 +311,9 @@ in order to capture the filesystem context the remote workspace expects:
return r, err
}
logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID)
err = b.renderPlanLogs(stopCtx, op, r)
if err != nil {
return r, generalError("Failed to retrieve logs", err)
}
reader := bufio.NewReaderSize(logs, 64*1024)
if b.CLI != nil {
for next := true; next; {
var l, line []byte
for isPrefix := true; isPrefix; {
l, isPrefix, err = reader.ReadLine()
if err != nil {
if err != io.EOF {
return r, generalError("Failed to read logs", err)
}
next = false
}
line = append(line, l...)
}
if next || len(line) > 0 {
b.CLI.Output(b.Colorize().Color(string(line)))
}
}
return r, err
}
// Retrieve the run to get its current status.
@ -373,6 +353,102 @@ in order to capture the filesystem context the remote workspace expects:
return r, nil
}
// renderPlanLogs reads the streamed plan JSON logs and calls the JSON Plan renderer (jsonformat.RenderPlan) to
// render the plan output. The plan output is fetched from the redacted output endpoint.
func (b *Cloud) renderPlanLogs(ctx context.Context, op *backend.Operation, run *tfe.Run) error {
logs, err := b.client.Plans.Logs(ctx, run.Plan.ID)
if err != nil {
return err
}
if b.CLI != nil {
reader := bufio.NewReaderSize(logs, 64*1024)
for next := true; next; {
var l, line []byte
var err error
for isPrefix := true; isPrefix; {
l, isPrefix, err = reader.ReadLine()
if err != nil {
if err != io.EOF {
return generalError("Failed to read logs", err)
}
next = false
}
line = append(line, l...)
}
if next || len(line) > 0 {
log := &jsonformat.JSONLog{}
if err := json.Unmarshal(line, log); err != nil {
// If we can not parse the line as JSON, we will simply
// print the line. This maintains backwards compatibility for
// users who do not wish to enable structured output in their
// workspace.
b.CLI.Output(string(line))
continue
}
// We will ignore plan output, change summary or outputs logs
// during the plan phase.
if log.Type == jsonformat.LogOutputs ||
log.Type == jsonformat.LogChangeSummary ||
log.Type == jsonformat.LogPlannedChange {
continue
}
if b.renderer != nil {
// Otherwise, we will print the log
err := b.renderer.RenderLog(log)
if err != nil {
return err
}
}
}
}
}
// Get the run's current status and include the workspace. We will check if
// the run has errored and if structured output is enabled.
run, err = b.client.Runs.ReadWithOptions(ctx, run.ID, &tfe.RunReadOptions{
Include: []tfe.RunIncludeOpt{tfe.RunWorkspace},
})
if err != nil {
return err
}
// If the run was errored, canceled, or discarded we will not resume the rest
// of this logic and attempt to render the plan.
if run.Status == tfe.RunErrored || run.Status == tfe.RunCanceled ||
run.Status == tfe.RunDiscarded {
// We won't return an error here since we need to resume the logic that
// follows after rendering the logs (run tasks, cost estimation, etc.)
return nil
}
// Only call the renderer if the remote workspace has structured run output
// enabled. The plan output will have already been rendered when the logs
// were read if this wasn't the case.
if run.Workspace.StructuredRunOutputEnabled && b.renderer != nil {
token, err := b.token()
if err != nil {
return err
}
// Fetch the redacted plan.
redacted, err := readRedactedPlan(ctx, b.client.BaseURL(), token, run.Plan.ID)
if err != nil {
return err
}
// Render plan output.
b.renderer.RenderHumanPlan(*redacted, op.PlanMode)
}
return nil
}
const planDefaultHeader = `
[reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the plan running remotely.[reset]

View File

@ -15,6 +15,7 @@ import (
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/initwd"
@ -96,6 +97,52 @@ func TestCloud_planBasic(t *testing.T) {
}
}
func TestCloud_planJSONBasic(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationPlan(t, "./testdata/plan-json-basic")
defer configCleanup()
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result != backend.OperationSuccess {
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
}
if run.PlanEmpty {
t.Fatal("expected a non-empty plan")
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", gotOut)
}
stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after the operation finished
if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after successful plan: %s", err.Error())
}
}
func TestCloud_planCanceled(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
@ -158,6 +205,56 @@ func TestCloud_planLongLine(t *testing.T) {
}
}
func TestCloud_planJSONFull(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationPlan(t, "./testdata/plan-json-full")
defer configCleanup()
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result != backend.OperationSuccess {
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
}
if run.PlanEmpty {
t.Fatal("expected a non-empty plan")
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "tfcoremock_simple_resource.example: Refreshing state... [id=my-simple-resource]") {
t.Fatalf("expected plan log: %s", gotOut)
}
if !strings.Contains(gotOut, "2 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", gotOut)
}
stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after the operation finished
if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after successful plan: %s", err.Error())
}
}
func TestCloud_planWithoutPermissions(t *testing.T) {
b, bCleanup := testBackendWithTags(t)
defer bCleanup()
@ -1092,6 +1189,47 @@ func TestCloud_planWithRemoteError(t *testing.T) {
}
}
func TestCloud_planJSONWithRemoteError(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
// Initialize the plan renderer
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, configCleanup, done := testOperationPlan(t, "./testdata/plan-json-error")
defer configCleanup()
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
if run.Result == backend.OperationSuccess {
t.Fatal("expected plan operation to fail")
}
if run.Result.ExitStatus() != 1 {
t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus())
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "Unsupported block type") {
t.Fatalf("unexpected plan error in output: %s", gotOut)
}
}
func TestCloud_planOtherError(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

View File

@ -0,0 +1,5 @@
resource "null_resource" "foo" {
triggers = {
random = "${guid()}"
}
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,2 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-20T12:12:25.477403-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"error","@message":"Error: Unsupported block type","@module":"terraform.ui","@timestamp":"2023-01-20T12:12:25.615995-05:00","diagnostic":{"severity":"error","summary":"Unsupported block type","detail":"Blocks of type \"triggers\" are not expected here. Did you mean to define argument \"triggers\"? If so, use the equals sign to assign it a value.","range":{"filename":"main.tf","start":{"line":2,"column":3,"byte":35},"end":{"line":2,"column":11,"byte":43}},"snippet":{"context":"resource \"null_resource\" \"foo\"","code":" triggers {","start_line":2,"highlight_start_offset":2,"highlight_end_offset":10,"values":[]}},"type":"diagnostic"}

View File

@ -0,0 +1,5 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:14.916732Z","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Creating...","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:16.390332Z","hook":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
{"@level":"info","@message":"null_resource.foo: Creation complete after 0s [id=7091618264040236234]","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:16.391654Z","hook":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create","id_key":"id","id_value":"7091618264040236234","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:16.992073Z","changes":{"add":1,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"}
{"@level":"info","@message":"Outputs: 3","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:16.992183Z","outputs":{"complex":{"sensitive":false,"type":["object",{"keyA":["object",{"someList":["tuple",["number","number","number"]]}],"keyB":["object",{"someBool":"bool","someStr":"string"}]}],"value":{"keyA":{"someList":[1,2,3]},"keyB":{"someBool":true,"someStr":"hello"}}},"secret":{"sensitive":true,"type":"string","value":"my-secret"},"simple":{"sensitive":false,"type":["tuple",["string","string"]],"value":["some","list"]}},"type":"outputs"}

View File

@ -0,0 +1,22 @@
resource "null_resource" "foo" {}
output "simple" {
value = ["some", "list"]
}
output "secret" {
value = "my-secret"
sensitive = true
}
output "complex" {
value = {
keyA = {
someList = [1, 2, 3]
}
keyB = {
someBool = true
someStr = "hello"
}
}
}

View File

@ -0,0 +1 @@
{"plan_format_version":"1.1","resource_drift":[],"resource_changes":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","change":{"actions":["create"],"before":null,"after":{"triggers":null},"after_unknown":{"id":true},"before_sensitive":false,"after_sensitive":{}}}],"relevant_attributes":[],"output_changes":{"complex":{"actions":["create"],"before":null,"after":{"keyA":{"someList":[1,2,3]},"keyB":{"someBool":true,"someStr":"hello"}},"after_unknown":false,"before_sensitive":false,"after_sensitive":false},"secret":{"actions":["create"],"before":null,"after":"8517896e47af3c9ca19a694ea0d6cc30b0dccf08598f33d93e583721fd5f3032","after_unknown":false,"before_sensitive":true,"after_sensitive":true},"simple":{"actions":["create"],"before":null,"after":["some","list"],"after_unknown":false,"before_sensitive":false,"after_sensitive":false}},"provider_schemas":{"registry.terraform.io/hashicorp/null":{"provider":{"version":0,"block":{"description_kind":"plain"}},"resource_schemas":{"null_resource":{"version":0,"block":{"attributes":{"id":{"type":"string","description":"This is set to a random value at create time.","description_kind":"plain","computed":true},"triggers":{"type":["map","string"],"description":"A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.","description_kind":"plain","optional":true}},"description":"The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.","description_kind":"plain"}}},"data_source_schemas":{"null_data_source":{"version":0,"block":{"attributes":{"has_computed_default":{"type":"string","description":"If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.","description_kind":"plain","optional":true,"computed":true},"id":{"type":"string","description":"This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.","description_kind":"plain","deprecated":true,"computed":true},"inputs":{"type":["map","string"],"description":"A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.","description_kind":"plain","optional":true},"outputs":{"type":["map","string"],"description":"After the data source is \"read\", a copy of the `inputs` map.","description_kind":"plain","computed":true},"random":{"type":"string","description":"A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.","description_kind":"plain","computed":true}},"description":"The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n","description_kind":"plain","deprecated":true}}}}},"provider_format_version":"1.0"}

View File

@ -0,0 +1,6 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:02.177699Z","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:03.842915Z","change":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:03.842951Z","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}
{"@level":"info","@message":"Outputs: 3","@module":"terraform.ui","@timestamp":"2023-01-20T21:13:03.842965Z","outputs":{"complex":{"sensitive":false,"action":"create"},"secret":{"sensitive":true,"action":"create"},"simple":{"sensitive":false,"action":"create"}},"type":"outputs"}

View File

@ -0,0 +1,5 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.623068-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Creating...","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.874882-05:00","hook":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
{"@level":"info","@message":"null_resource.foo: Creation complete after 0s [id=3573948886993018026]","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.878389-05:00","hook":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create","id_key":"id","id_value":"3573948886993018026","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.887223-05:00","changes":{"add":1,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"}
{"@level":"info","@message":"Outputs: 0","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.887259-05:00","outputs":{},"type":"outputs"}

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -0,0 +1,116 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"triggers": null
},
"after_unknown": {
"id": true
},
"before_sensitive": false,
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -0,0 +1,4 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.623068-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.822722-05:00","change":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-20T15:50:04.822787-05:00","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -0,0 +1,116 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"triggers": null
},
"after_unknown": {
"id": true
},
"before_sensitive": false,
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -0,0 +1,3 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.409143-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605841-05:00","change":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605906-05:00","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -0,0 +1,5 @@
resource "null_resource" "foo" {
triggers {
random = "${guid()}"
}
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,2 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-20T12:12:25.477403-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"error","@message":"Error: Unsupported block type","@module":"terraform.ui","@timestamp":"2023-01-20T12:12:25.615995-05:00","diagnostic":{"severity":"error","summary":"Unsupported block type","detail":"Blocks of type \"triggers\" are not expected here. Did you mean to define argument \"triggers\"? If so, use the equals sign to assign it a value.","range":{"filename":"main.tf","start":{"line":2,"column":3,"byte":35},"end":{"line":2,"column":11,"byte":43}},"snippet":{"context":"resource \"null_resource\" \"foo\"","code":" triggers {","start_line":2,"highlight_start_offset":2,"highlight_end_offset":10,"values":[]}},"type":"diagnostic"}

View File

@ -0,0 +1,82 @@
provider "tfcoremock" {}
# In order to generate the JSON logs contained in plan.log
# First ONLY apply tfcoremock_simple_resource.example (set the bool attribute
# to true). Make sure the complex_resource is commented out.
# Once applied, change the bool attribute to false and uncomment the complex
# resource.
resource "tfcoremock_simple_resource" "example" {
id = "my-simple-resource"
bool = false
number = 0
string = "Hello, world!"
float = 0
integer = 0
}
resource "tfcoremock_complex_resource" "example" {
id = "my-complex-resource"
bool = true
number = 0
string = "Hello, world!"
float = 0
integer = 0
list = [
{
string = "list.one"
},
{
string = "list.two"
}
]
set = [
{
string = "set.one"
},
{
string = "set.two"
}
]
map = {
"one" : {
string = "map.one"
},
"two" : {
string = "map.two"
}
}
object = {
string = "nested object"
object = {
string = "nested nested object"
}
}
list_block {
string = "list_block.one"
}
list_block {
string = "list_block.two"
}
list_block {
string = "list_block.three"
}
set_block {
string = "set_block.one"
}
set_block {
string = "set_block.two"
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,6 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-19T13:28:29.004160-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"tfcoremock_simple_resource.example: Refreshing state... [id=my-simple-resource]","@module":"terraform.ui","@timestamp":"2023-01-19T13:28:29.232274-05:00","hook":{"resource":{"addr":"tfcoremock_simple_resource.example","module":"","resource":"tfcoremock_simple_resource.example","implied_provider":"tfcoremock","resource_type":"tfcoremock_simple_resource","resource_name":"example","resource_key":null},"id_key":"id","id_value":"my-simple-resource"},"type":"refresh_start"}
{"@level":"info","@message":"tfcoremock_simple_resource.example: Refresh complete [id=my-simple-resource]","@module":"terraform.ui","@timestamp":"2023-01-19T13:28:29.232882-05:00","hook":{"resource":{"addr":"tfcoremock_simple_resource.example","module":"","resource":"tfcoremock_simple_resource.example","implied_provider":"tfcoremock","resource_type":"tfcoremock_simple_resource","resource_name":"example","resource_key":null},"id_key":"id","id_value":"my-simple-resource"},"type":"refresh_complete"}
{"@level":"info","@message":"tfcoremock_simple_resource.example: Plan to update","@module":"terraform.ui","@timestamp":"2023-01-19T13:28:29.289259-05:00","change":{"resource":{"addr":"tfcoremock_simple_resource.example","module":"","resource":"tfcoremock_simple_resource.example","implied_provider":"tfcoremock","resource_type":"tfcoremock_simple_resource","resource_name":"example","resource_key":null},"action":"update"},"type":"planned_change"}
{"@level":"info","@message":"tfcoremock_complex_resource.example: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-19T13:28:29.289320-05:00","change":{"resource":{"addr":"tfcoremock_complex_resource.example","module":"","resource":"tfcoremock_complex_resource.example","implied_provider":"tfcoremock","resource_type":"tfcoremock_complex_resource","resource_name":"example","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 1 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-19T13:28:29.289330-05:00","changes":{"add":1,"change":1,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"path"
"testing"
"time"
@ -16,9 +17,11 @@ import (
"github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/httpclient"
@ -222,10 +225,20 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) {
b.local = testLocalBackend(t, b)
b.input = true
baseURL, err := url.Parse("https://app.terraform.io")
if err != nil {
t.Fatalf("testBackend: failed to parse base URL for client")
}
baseURL.Path = "/api/v2/"
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) (*jsonformat.Plan, error) {
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
}
ctx := context.Background()
// Create the organization.
_, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
_, err = b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
Name: tfe.String(b.organization),
})
if err != nil {
@ -278,6 +291,16 @@ func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
b.client.Variables = mc.Variables
b.client.Workspaces = mc.Workspaces
baseURL, err := url.Parse("https://app.terraform.io")
if err != nil {
t.Fatalf("testBackend: failed to parse base URL for client")
}
baseURL.Path = "/api/v2/"
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) (*jsonformat.Plan, error) {
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
}
// Set local to a local test backend.
b.local = testLocalBackend(t, b)
@ -408,6 +431,30 @@ var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Requ
},
}
func mockColorize() *colorstring.Colorize {
colors := make(map[string]string)
for k, v := range colorstring.DefaultColors {
colors[k] = v
}
colors["purple"] = "38;5;57"
return &colorstring.Colorize{
Colors: colors,
Disable: false,
Reset: true,
}
}
func mockSROWorkspace(t *testing.T, b *Cloud, workspaceName string) {
_, err := b.client.Workspaces.Update(context.Background(), "hashicorp", workspaceName, tfe.WorkspaceUpdateOptions{
StructuredRunOutputEnabled: tfe.Bool(true),
TerraformVersion: tfe.String("1.4.0"),
})
if err != nil {
t.Fatalf("Error enabling SRO on workspace %s: %v", workspaceName, err)
}
}
// testDisco returns a *disco.Disco mapping app.terraform.io and
// localhost to a local test server.
func testDisco(s *httptest.Server) *disco.Disco {

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@ -18,6 +19,7 @@ import (
tfe "github.com/hashicorp/go-tfe"
"github.com/mitchellh/copystructure"
"github.com/hashicorp/terraform/internal/command/jsonformat"
tfversion "github.com/hashicorp/terraform/version"
)
@ -29,6 +31,7 @@ type MockClient struct {
Plans *MockPlans
PolicySetOutcomes *MockPolicySetOutcomes
TaskStages *MockTaskStages
RedactedPlans *MockRedactedPlans
PolicyChecks *MockPolicyChecks
Runs *MockRuns
StateVersions *MockStateVersions
@ -52,6 +55,7 @@ func NewMockClient() *MockClient {
c.StateVersionOutputs = newMockStateVersionOutputs(c)
c.Variables = newMockVariables(c)
c.Workspaces = newMockWorkspaces(c)
c.RedactedPlans = newMockRedactedPlans(c)
return c
}
@ -230,6 +234,11 @@ func (m *MockConfigurationVersions) Upload(ctx context.Context, url, path string
}
m.uploadPaths[cv.ID] = path
cv.Status = tfe.ConfigurationUploaded
return m.UploadTarGzip(ctx, url, nil)
}
func (m *MockConfigurationVersions) UploadTarGzip(ctx context.Context, url string, archive io.Reader) error {
return nil
}
@ -385,6 +394,10 @@ func (m *MockOrganizations) Create(ctx context.Context, options tfe.Organization
}
func (m *MockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) {
return m.ReadWithOptions(ctx, name, tfe.OrganizationReadOptions{})
}
func (m *MockOrganizations) ReadWithOptions(ctx context.Context, name string, options tfe.OrganizationReadOptions) (*tfe.Organization, error) {
org, ok := m.organizations[name]
if !ok {
return nil, tfe.ErrResourceNotFound
@ -448,6 +461,58 @@ func (m *MockOrganizations) ReadRunQueue(ctx context.Context, name string, optio
return rq, nil
}
type MockRedactedPlans struct {
client *MockClient
redactedPlans map[string]*jsonformat.Plan
}
func newMockRedactedPlans(client *MockClient) *MockRedactedPlans {
return &MockRedactedPlans{
client: client,
redactedPlans: make(map[string]*jsonformat.Plan),
}
}
func (m *MockRedactedPlans) create(cvID, workspaceID, planID string) error {
w, ok := m.client.Workspaces.workspaceIDs[workspaceID]
if !ok {
return tfe.ErrResourceNotFound
}
planPath := filepath.Join(
m.client.ConfigurationVersions.uploadPaths[cvID],
w.WorkingDirectory,
"plan-redacted.json",
)
redactedPlanFile, err := os.Open(planPath)
if err != nil {
return err
}
raw, err := ioutil.ReadAll(redactedPlanFile)
if err != nil {
return err
}
redactedPlan := &jsonformat.Plan{}
err = json.Unmarshal(raw, redactedPlan)
if err != nil {
return err
}
m.redactedPlans[planID] = redactedPlan
return nil
}
func (m *MockRedactedPlans) Read(ctx context.Context, hostname, token, planID string) (*jsonformat.Plan, error) {
if p, ok := m.redactedPlans[planID]; ok {
return p, nil
}
return nil, tfe.ErrResourceNotFound
}
type MockPlans struct {
client *MockClient
logs map[string]string
@ -981,6 +1046,19 @@ func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t
w.CurrentRun = r
}
r.Workspace = &tfe.Workspace{
ID: w.ID,
StructuredRunOutputEnabled: w.StructuredRunOutputEnabled,
TerraformVersion: w.TerraformVersion,
}
if w.StructuredRunOutputEnabled {
err := m.client.RedactedPlans.create(options.ConfigurationVersion.ID, options.Workspace.ID, p.ID)
if err != nil {
return nil, err
}
}
if m.ModifyNewRun != nil {
// caller-provided callback may modify the run in-place to mimic
// side-effects that a real server might take in some situations.
@ -1022,14 +1100,17 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run
logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL])
if r.Status == tfe.RunPlanning && r.Plan.Status == tfe.PlanFinished {
if r.IsDestroy || bytes.Contains(logs, []byte("1 to add, 0 to change, 0 to destroy")) {
if r.IsDestroy ||
bytes.Contains(logs, []byte("1 to add, 0 to change, 0 to destroy")) ||
bytes.Contains(logs, []byte("1 to add, 1 to change, 0 to destroy")) {
r.Actions.IsCancelable = false
r.Actions.IsConfirmable = true
r.HasChanges = true
r.Permissions.CanApply = true
}
if bytes.Contains(logs, []byte("null_resource.foo: 1 error")) {
if bytes.Contains(logs, []byte("null_resource.foo: 1 error")) ||
bytes.Contains(logs, []byte("Error: Unsupported block type")) {
r.Actions.IsCancelable = false
r.HasChanges = false
r.Status = tfe.RunErrored
@ -1397,10 +1478,11 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option
options.ExecutionMode = tfe.String("remote")
}
w := &tfe.Workspace{
ID: GenerateID("ws-"),
Name: *options.Name,
ExecutionMode: *options.ExecutionMode,
Operations: *options.Operations,
ID: GenerateID("ws-"),
Name: *options.Name,
ExecutionMode: *options.ExecutionMode,
Operations: *options.Operations,
StructuredRunOutputEnabled: false,
Permissions: &tfe.WorkspacePermissions{
CanQueueApply: true,
CanQueueRun: true,
@ -1413,11 +1495,13 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option
if options.VCSRepo != nil {
w.VCSRepo = &tfe.VCSRepo{}
}
if options.TerraformVersion != nil {
w.TerraformVersion = *options.TerraformVersion
} else {
w.TerraformVersion = tfversion.String()
}
var tags []*tfe.Tag
for _, tag := range options.Tags {
tags = append(tags, tag)
@ -1518,6 +1602,11 @@ func updateMockWorkspaceAttributes(w *tfe.Workspace, options tfe.WorkspaceUpdate
if options.WorkingDirectory != nil {
w.WorkingDirectory = *options.WorkingDirectory
}
if options.StructuredRunOutputEnabled != nil {
w.StructuredRunOutputEnabled = *options.StructuredRunOutputEnabled
}
return nil
}

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/command/jsonstate"
viewsjson "github.com/hashicorp/terraform/internal/command/views/json"
"github.com/hashicorp/terraform/internal/plans"
)
@ -142,6 +143,26 @@ func FromJsonOutput(output jsonstate.Output) Change {
}
}
// FromJsonViewsOutput unmarshals the raw values in the viewsjson.Output structs into
// generic interface{} types that can be reasoned about.
func FromJsonViewsOutput(output viewsjson.Output) Change {
return Change{
// We model resource formatting as NoOps.
Before: unmarshalGeneric(output.Value),
After: unmarshalGeneric(output.Value),
// We have some sensitive values, but we don't have any unknown values.
Unknown: false,
BeforeSensitive: output.Sensitive,
AfterSensitive: output.Sensitive,
// We don't display replacement data for resources, and all attributes
// are relevant.
ReplacePaths: attribute_path.Empty(false),
RelevantAttributes: attribute_path.AlwaysMatcher(),
}
}
func (change Change) asDiff(renderer computed.DiffRenderer) computed.Diff {
return computed.NewDiff(renderer, change.calculateChange(), change.ReplacePaths.Matches())
}

View File

@ -1,15 +1,41 @@
package jsonformat
import (
"fmt"
"github.com/mitchellh/colorstring"
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/command/jsonstate"
viewsjson "github.com/hashicorp/terraform/internal/command/views/json"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/terminal"
ctyjson "github.com/zclconf/go-cty/cty/json"
)
type JSONLogType string
type JSONLog struct {
Message string `json:"@message"`
Type JSONLogType `json:"type"`
Diagnostic *viewsjson.Diagnostic `json:"diagnostic"`
Outputs viewsjson.Outputs `json:"outputs"`
}
const (
LogVersion JSONLogType = "version"
LogDiagnostic JSONLogType = "diagnostic"
LogPlannedChange JSONLogType = "planned_change"
LogRefreshStart JSONLogType = "refresh_start"
LogRefreshComplete JSONLogType = "refresh_complete"
LogApplyStart JSONLogType = "apply_start"
LogApplyComplete JSONLogType = "apply_complete"
LogChangeSummary JSONLogType = "change_summary"
LogOutputs JSONLogType = "outputs"
)
type Renderer struct {
@ -58,6 +84,51 @@ func (renderer Renderer) RenderHumanState(state State) {
state.renderHumanStateOutputs(renderer, opts)
}
func (renderer Renderer) RenderLog(message map[string]interface{}) {
panic("not implemented")
func (r Renderer) RenderLog(log *JSONLog) error {
switch log.Type {
case LogRefreshComplete, LogVersion, LogPlannedChange:
// We won't display these types of logs
return nil
case LogApplyStart, LogApplyComplete, LogRefreshStart:
msg := fmt.Sprintf(r.Colorize.Color("[bold]%s[reset]"), log.Message)
r.Streams.Println(msg)
case LogDiagnostic:
diag := format.DiagnosticFromJSON(log.Diagnostic, r.Colorize, 78)
r.Streams.Print(diag)
case LogOutputs:
if len(log.Outputs) > 0 {
r.Streams.Println(r.Colorize.Color("[bold][green]Outputs:[reset]"))
for name, output := range log.Outputs {
change := differ.FromJsonViewsOutput(output)
ctype, err := ctyjson.UnmarshalType(output.Type)
if err != nil {
return err
}
outputDiff := change.ComputeDiffForType(ctype)
outputStr := outputDiff.RenderHuman(0, computed.RenderHumanOpts{
Colorize: r.Colorize,
ShowUnchangedChildren: true,
})
msg := fmt.Sprintf("%s = %s", name, outputStr)
r.Streams.Println(msg)
}
}
case LogChangeSummary:
// We will only render the apply change summary since the renderer
// generates a plan change summary for us
msg := fmt.Sprintf(r.Colorize.Color("[bold][green]%s[reset]"), log.Message)
r.Streams.Println("\n" + msg + "\n")
default:
// If the log type is not a known log type, we will just print the log message
r.Streams.Println(log.Message)
}
return nil
}