mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
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:
parent
afcbff193b
commit
de574ae6d4
8
go.mod
8
go.mod
@ -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
12
go.sum
@ -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=
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 = `
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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()
|
||||
|
5
internal/cloud/testdata/apply-json-with-error/main.tf
vendored
Normal file
5
internal/cloud/testdata/apply-json-with-error/main.tf
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
resource "null_resource" "foo" {
|
||||
triggers = {
|
||||
random = "${guid()}"
|
||||
}
|
||||
}
|
1
internal/cloud/testdata/apply-json-with-error/plan-redacted.json
vendored
Normal file
1
internal/cloud/testdata/apply-json-with-error/plan-redacted.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{}
|
2
internal/cloud/testdata/apply-json-with-error/plan.log
vendored
Normal file
2
internal/cloud/testdata/apply-json-with-error/plan.log
vendored
Normal 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"}
|
5
internal/cloud/testdata/apply-json-with-outputs/apply.log
vendored
Normal file
5
internal/cloud/testdata/apply-json-with-outputs/apply.log
vendored
Normal 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"}
|
22
internal/cloud/testdata/apply-json-with-outputs/main.tf
vendored
Normal file
22
internal/cloud/testdata/apply-json-with-outputs/main.tf
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
1
internal/cloud/testdata/apply-json-with-outputs/plan-redacted.json
vendored
Normal file
1
internal/cloud/testdata/apply-json-with-outputs/plan-redacted.json
vendored
Normal 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"}
|
6
internal/cloud/testdata/apply-json-with-outputs/plan.log
vendored
Normal file
6
internal/cloud/testdata/apply-json-with-outputs/plan.log
vendored
Normal 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"}
|
||||
|
||||
|
5
internal/cloud/testdata/apply-json/apply.log
vendored
Normal file
5
internal/cloud/testdata/apply-json/apply.log
vendored
Normal 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"}
|
1
internal/cloud/testdata/apply-json/main.tf
vendored
Normal file
1
internal/cloud/testdata/apply-json/main.tf
vendored
Normal file
@ -0,0 +1 @@
|
||||
resource "null_resource" "foo" {}
|
116
internal/cloud/testdata/apply-json/plan-redacted.json
vendored
Normal file
116
internal/cloud/testdata/apply-json/plan-redacted.json
vendored
Normal 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"
|
||||
}
|
4
internal/cloud/testdata/apply-json/plan.log
vendored
Normal file
4
internal/cloud/testdata/apply-json/plan.log
vendored
Normal 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"}
|
||||
|
1
internal/cloud/testdata/plan-json-basic/main.tf
vendored
Normal file
1
internal/cloud/testdata/plan-json-basic/main.tf
vendored
Normal file
@ -0,0 +1 @@
|
||||
resource "null_resource" "foo" {}
|
116
internal/cloud/testdata/plan-json-basic/plan-redacted.json
vendored
Normal file
116
internal/cloud/testdata/plan-json-basic/plan-redacted.json
vendored
Normal 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"
|
||||
}
|
3
internal/cloud/testdata/plan-json-basic/plan.log
vendored
Normal file
3
internal/cloud/testdata/plan-json-basic/plan.log
vendored
Normal 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"}
|
5
internal/cloud/testdata/plan-json-error/main.tf
vendored
Normal file
5
internal/cloud/testdata/plan-json-error/main.tf
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
resource "null_resource" "foo" {
|
||||
triggers {
|
||||
random = "${guid()}"
|
||||
}
|
||||
}
|
1
internal/cloud/testdata/plan-json-error/plan-redacted.json
vendored
Normal file
1
internal/cloud/testdata/plan-json-error/plan-redacted.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{}
|
2
internal/cloud/testdata/plan-json-error/plan.log
vendored
Normal file
2
internal/cloud/testdata/plan-json-error/plan.log
vendored
Normal 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"}
|
82
internal/cloud/testdata/plan-json-full/main.tf
vendored
Normal file
82
internal/cloud/testdata/plan-json-full/main.tf
vendored
Normal 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"
|
||||
}
|
||||
}
|
1
internal/cloud/testdata/plan-json-full/plan-redacted.json
vendored
Normal file
1
internal/cloud/testdata/plan-json-full/plan-redacted.json
vendored
Normal file
File diff suppressed because one or more lines are too long
6
internal/cloud/testdata/plan-json-full/plan.log
vendored
Normal file
6
internal/cloud/testdata/plan-json-full/plan.log
vendored
Normal 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"}
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user