feat: init and get command support json format output (#1453)

Signed-off-by: Syasusu <syasusu@163.com>
This commit is contained in:
Syasusu 2024-04-02 18:32:37 +02:00 committed by GitHub
parent 046beee664
commit bdab86962f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 325 additions and 72 deletions

View File

@ -35,6 +35,7 @@ ENHANCEMENTS:
* Dump state file when `tofu test` fails to clean up resources. ([#1243](https://github.com/opentofu/opentofu/pull/1243))
* Added aliases for `state list` (`state ls`), `state mv` (`state move`), and `state rm` (`state remove`) ([#1220](https://github.com/opentofu/opentofu/pull/1220))
* Added mechanism to introduce automatic retries for provider installations, specifically targeting transient errors ([#1233](https://github.com/opentofu/opentofu/issues/1233))
* Added `-json` flag to `tofu init` and `tofu get` to support output in json format. ([#1453](https://github.com/opentofu/opentofu/pull/1453))
BUG FIXES:
* Fix view hooks unit test flakiness by deterministically waiting for heartbeats to execute ([$1153](https://github.com/opentofu/opentofu/issues/1153))

2
go.mod
View File

@ -43,7 +43,7 @@ require (
github.com/hashicorp/go-azure-helpers v0.43.0
github.com/hashicorp/go-cleanhttp v0.5.2
github.com/hashicorp/go-getter v1.7.3
github.com/hashicorp/go-hclog v1.5.0
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-retryablehttp v0.7.4

4
go.sum
View File

@ -661,8 +661,8 @@ github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrj
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=

View File

@ -62,33 +62,67 @@ func TestInitProvidersInternal(t *testing.T) {
// This test should _not_ reach out anywhere because the "terraform"
// provider is internal to the core tofu binary.
fixturePath := filepath.Join("testdata", "tf-provider")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
t.Run("output in human readable format", func(t *testing.T) {
fixturePath := filepath.Join("testdata", "tf-provider")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
stdout, stderr, err := tf.Run("init")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
stdout, stderr, err := tf.Run("init")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
if !strings.Contains(stdout, "OpenTofu has been successfully initialized!") {
t.Errorf("success message is missing from output:\n%s", stdout)
}
if !strings.Contains(stdout, "OpenTofu has been successfully initialized!") {
t.Errorf("success message is missing from output:\n%s", stdout)
}
if strings.Contains(stdout, "Installing hashicorp/terraform") {
// Shouldn't have downloaded anything with this config, because the
// provider is built in.
t.Errorf("provider download message appeared in output:\n%s", stdout)
}
if strings.Contains(stdout, "Installing hashicorp/terraform") {
// Shouldn't have downloaded anything with this config, because the
// provider is built in.
t.Errorf("provider download message appeared in output:\n%s", stdout)
}
if strings.Contains(stdout, "Installing terraform.io/builtin/terraform") {
// Shouldn't have downloaded anything with this config, because the
// provider is built in.
t.Errorf("provider download message appeared in output:\n%s", stdout)
}
})
t.Run("output in machine readable format", func(t *testing.T) {
fixturePath := filepath.Join("testdata", "tf-provider")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
stdout, stderr, err := tf.Run("init", "-json")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
// we can not check timestamp, so the sub string is not a valid json object
if !strings.Contains(stdout, `{"@level":"info","@message":"OpenTofu has been successfully initialized!","@module":"tofu.ui"`) {
t.Errorf("success message is missing from output:\n%s", stdout)
}
if strings.Contains(stdout, "Installing hashicorp/terraform") {
// Shouldn't have downloaded anything with this config, because the
// provider is built in.
t.Errorf("provider download message appeared in output:\n%s", stdout)
}
if strings.Contains(stdout, "Installing terraform.io/builtin/terraform") {
// Shouldn't have downloaded anything with this config, because the
// provider is built in.
t.Errorf("provider download message appeared in output:\n%s", stdout)
}
})
if strings.Contains(stdout, "Installing terraform.io/builtin/terraform") {
// Shouldn't have downloaded anything with this config, because the
// provider is built in.
t.Errorf("provider download message appeared in output:\n%s", stdout)
}
}
func TestInitProvidersVendored(t *testing.T) {
@ -144,42 +178,85 @@ func TestInitProvidersLocalOnly(t *testing.T) {
// to the host "example.com", which is the placeholder domain we use in
// the test fixture.)
fixturePath := filepath.Join("testdata", "local-only-provider")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
// If you run this test on a workstation with a plugin-cache directory
// configured, it will leave a bad directory behind and tofu init will
// not work until you remove it.
//
// To avoid this, we will "zero out" any existing cli config file.
tf.AddEnv("TF_CLI_CONFIG_FILE=")
t.Run("output in human readable format", func(t *testing.T) {
fixturePath := filepath.Join("testdata", "local-only-provider")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
// If you run this test on a workstation with a plugin-cache directory
// configured, it will leave a bad directory behind and tofu init will
// not work until you remove it.
//
// To avoid this, we will "zero out" any existing cli config file.
tf.AddEnv("TF_CLI_CONFIG_FILE=")
// Our fixture dir has a generic os_arch dir, which we need to customize
// to the actual OS/arch where this test is running in order to get the
// desired result.
fixtMachineDir := tf.Path("terraform.d/plugins/example.com/awesomecorp/happycloud/1.2.0/os_arch")
wantMachineDir := tf.Path("terraform.d/plugins/example.com/awesomecorp/happycloud/1.2.0/", fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH))
err := os.Rename(fixtMachineDir, wantMachineDir)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// Our fixture dir has a generic os_arch dir, which we need to customize
// to the actual OS/arch where this test is running in order to get the
// desired result.
fixtMachineDir := tf.Path("terraform.d/plugins/example.com/awesomecorp/happycloud/1.2.0/os_arch")
wantMachineDir := tf.Path("terraform.d/plugins/example.com/awesomecorp/happycloud/1.2.0/", fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH))
err := os.Rename(fixtMachineDir, wantMachineDir)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
stdout, stderr, err := tf.Run("init")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
stdout, stderr, err := tf.Run("init")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
if !strings.Contains(stdout, "OpenTofu has been successfully initialized!") {
t.Errorf("success message is missing from output:\n%s", stdout)
}
if !strings.Contains(stdout, "OpenTofu has been successfully initialized!") {
t.Errorf("success message is missing from output:\n%s", stdout)
}
if !strings.Contains(stdout, "- Installing example.com/awesomecorp/happycloud v1.2.0") {
t.Errorf("provider download message is missing from output:\n%s", stdout)
t.Logf("(this can happen if you have a conflicting copy of the plugin in one of the global plugin search dirs)")
}
})
t.Run("output in machine readable format", func(t *testing.T) {
fixturePath := filepath.Join("testdata", "local-only-provider")
tf := e2e.NewBinary(t, tofuBin, fixturePath)
// If you run this test on a workstation with a plugin-cache directory
// configured, it will leave a bad directory behind and tofu init will
// not work until you remove it.
//
// To avoid this, we will "zero out" any existing cli config file.
tf.AddEnv("TF_CLI_CONFIG_FILE=")
// Our fixture dir has a generic os_arch dir, which we need to customize
// to the actual OS/arch where this test is running in order to get the
// desired result.
fixtMachineDir := tf.Path("terraform.d/plugins/example.com/awesomecorp/happycloud/1.2.0/os_arch")
wantMachineDir := tf.Path("terraform.d/plugins/example.com/awesomecorp/happycloud/1.2.0/", fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH))
err := os.Rename(fixtMachineDir, wantMachineDir)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
stdout, stderr, err := tf.Run("init", "-json")
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if stderr != "" {
t.Errorf("unexpected stderr output:\n%s", stderr)
}
// we can not check timestamp, so the sub string is not a valid json object
if !strings.Contains(stdout, `{"@level":"info","@message":"OpenTofu has been successfully initialized!","@module":"tofu.ui"`) {
t.Errorf("success message is missing from output:\n%s", stdout)
}
if !strings.Contains(stdout, `{"@level":"info","@message":"- Installing example.com/awesomecorp/happycloud v1.2.0...","@module":"tofu.ui"`) {
t.Errorf("provider download message is missing from output:\n%s", stdout)
t.Logf("(this can happen if you have a conflicting copy of the plugin in one of the global plugin search dirs)")
}
})
if !strings.Contains(stdout, "- Installing example.com/awesomecorp/happycloud v1.2.0") {
t.Errorf("provider download message is missing from output:\n%s", stdout)
t.Logf("(this can happen if you have a conflicting copy of the plugin in one of the global plugin search dirs)")
}
}
func TestInitProvidersCustomMethod(t *testing.T) {
@ -347,9 +424,21 @@ func TestInitProviderNotFound(t *testing.T) {
}
})
t.Run("registry provider not found output in json format", func(t *testing.T) {
stdout, _, err := tf.Run("init", "-no-color", "-json")
if err == nil {
t.Fatal("expected error, got success")
}
oneLineStdout := strings.ReplaceAll(stdout, "\n", " ")
if !strings.Contains(oneLineStdout, `"diagnostic":{"severity":"error","summary":"Failed to query available provider packages","detail":"Could not retrieve the list of available versions for provider hashicorp/nonexist: provider registry registry.opentofu.org does not have a provider named registry.opentofu.org/hashicorp/nonexist\n\nAll modules should specify their required_providers so that external consumers will get the correct providers when using a module. To see which modules are currently depending on hashicorp/nonexist, run the following command:\n tofu providers\n\nIf you believe this provider is missing from the registry, please submit a issue on the OpenTofu Registry https://github.com/opentofu/registry/issues/"},"type":"diagnostic"}`) {
t.Errorf("expected error message is missing from output:\n%s", stdout)
}
})
t.Run("local provider not found", func(t *testing.T) {
// The -plugin-dir directory must exist for the provider installer to search it.
pluginDir := tf.Path("empty")
pluginDir := tf.Path("empty-for-json")
if err := os.Mkdir(pluginDir, os.ModePerm); err != nil {
t.Fatal(err)
}
@ -364,6 +453,23 @@ func TestInitProviderNotFound(t *testing.T) {
}
})
t.Run("local provider not found output in json format", func(t *testing.T) {
// The -plugin-dir directory must exist for the provider installer to search it.
pluginDir := tf.Path("empty")
if err := os.Mkdir(pluginDir, os.ModePerm); err != nil {
t.Fatal(err)
}
stdout, _, err := tf.Run("init", "-no-color", "-plugin-dir="+pluginDir, "-json")
if err == nil {
t.Fatal("expected error, got success")
}
if !strings.Contains(stdout, `"diagnostic":{"severity":"error","summary":"Failed to query available provider packages","detail":"Could not retrieve the list of available versions for provider hashicorp/nonexist: provider registry.opentofu.org/hashicorp/nonexist was not found in any of the search locations\n\n - `+pluginDir+`"},"type":"diagnostic"}`) {
t.Errorf("expected error message is missing from output:\n%s", stdout)
}
})
t.Run("special characters enabled", func(t *testing.T) {
_, stderr, err := tf.Run("init")
if err == nil {

View File

@ -10,6 +10,7 @@ import (
"fmt"
"strings"
"github.com/opentofu/opentofu/internal/command/views"
"github.com/opentofu/opentofu/internal/tfdiags"
)
@ -27,11 +28,22 @@ func (c *GetCommand) Run(args []string) int {
cmdFlags := c.Meta.defaultFlagSet("get")
cmdFlags.BoolVar(&update, "update", false, "update")
cmdFlags.StringVar(&testsDirectory, "test-directory", "tests", "test-directory")
cmdFlags.BoolVar(&c.outputInJSON, "json", false, "json")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
return 1
}
if c.outputInJSON {
c.Meta.color = false
c.Meta.Color = false
c.oldUi = c.Ui
c.Ui = &WrappedUi{
cliUi: c.oldUi,
jsonView: views.NewJSONView(c.View),
outputInJSON: true,
}
}
// Initialization can be aborted by interruption signals
ctx, done := c.InterruptibleContext(c.CommandContext())
@ -81,6 +93,10 @@ Options:
test command will search for test files in the current directory and
in the one specified by the flag.
-json Produce output in a machine-readable JSON format,
suitable for use in text editor integrations and other
automated systems. Always disables color.
`
return strings.TrimSpace(helpText)
}

View File

@ -26,6 +26,7 @@ import (
backendInit "github.com/opentofu/opentofu/internal/backend/init"
"github.com/opentofu/opentofu/internal/cloud"
"github.com/opentofu/opentofu/internal/command/arguments"
"github.com/opentofu/opentofu/internal/command/views"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/configs/configschema"
"github.com/opentofu/opentofu/internal/encryption"
@ -67,11 +68,23 @@ func (c *InitCommand) Run(args []string) int {
cmdFlags.StringVar(&flagLockfile, "lockfile", "", "Set a dependency lockfile mode")
cmdFlags.BoolVar(&c.Meta.ignoreRemoteVersion, "ignore-remote-version", false, "continue even if remote and local OpenTofu versions are incompatible")
cmdFlags.StringVar(&testsDirectory, "test-directory", "tests", "test-directory")
cmdFlags.BoolVar(&c.outputInJSON, "json", false, "json")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
return 1
}
if c.outputInJSON {
c.Meta.color = false
c.Meta.Color = false
c.oldUi = c.Ui
c.Ui = &WrappedUi{
cliUi: c.oldUi,
jsonView: views.NewJSONView(c.View),
outputInJSON: true,
}
}
backendFlagSet := arguments.FlagIsSet(cmdFlags, "backend")
cloudFlagSet := arguments.FlagIsSet(cmdFlags, "cloud")
@ -1233,6 +1246,10 @@ Options:
test command will search for test files in the current directory and
in the one specified by the flag.
-json Produce output in a machine-readable JSON format,
suitable for use in text editor integrations and other
automated systems. Always disables color.
`
return strings.TrimSpace(helpText)
}

View File

@ -19,7 +19,7 @@ import (
"strings"
"time"
plugin "github.com/hashicorp/go-plugin"
"github.com/hashicorp/go-plugin"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
@ -266,6 +266,8 @@ type Meta struct {
// Used with commands which write state to allow users to write remote
// state even if the remote and local OpenTofu versions don't match.
ignoreRemoteVersion bool
outputInJSON bool
}
type testingOverrides struct {
@ -693,6 +695,12 @@ func (m *Meta) showDiagnostics(vals ...interface{}) {
return
}
if m.outputInJSON {
jsonView := views.NewJSONView(m.View)
jsonView.Diagnostics(diags)
return
}
outputWidth := m.ErrorColumns()
diags = diags.ConsolidateWarnings(1)

View File

@ -0,0 +1,60 @@
package command
import (
"github.com/mitchellh/cli"
"github.com/opentofu/opentofu/internal/command/views"
)
// WrappedUi is a shim which adds json compatibility to those commands which
// have not yet been refactored to support output by views.View.
//
// For those not support json output command, all output is printed by cli.Ui.
// So we create WrappedUi, contains the old cli.Ui and views.JSONView,
// implement cli.Ui interface, so that we can make all command support json
// output in a short time.
type WrappedUi struct {
cliUi cli.Ui
jsonView *views.JSONView
outputInJSON bool
}
func (m *WrappedUi) Ask(s string) (string, error) {
return m.cliUi.Ask(s)
}
func (m *WrappedUi) AskSecret(s string) (string, error) {
return m.cliUi.AskSecret(s)
}
func (m *WrappedUi) Output(s string) {
if m.outputInJSON {
m.jsonView.Output(s)
return
}
m.cliUi.Output(s)
}
func (m *WrappedUi) Info(s string) {
if m.outputInJSON {
m.jsonView.Info(s)
return
}
m.cliUi.Info(s)
}
func (m *WrappedUi) Error(s string) {
if m.outputInJSON {
m.jsonView.Error(s)
return
}
m.cliUi.Error(s)
}
func (m *WrappedUi) Warn(s string) {
if m.outputInJSON {
m.jsonView.Warn(s)
return
}
m.cliUi.Warn(s)
}

View File

@ -23,9 +23,10 @@ const JSON_UI_VERSION = "1.2"
func NewJSONView(view *View) *JSONView {
log := hclog.New(&hclog.LoggerOptions{
Name: "tofu.ui",
Output: view.streams.Stdout.File,
JSONFormat: true,
Name: "tofu.ui",
Output: view.streams.Stdout.File,
JSONFormat: true,
JSONEscapeDisabled: true,
})
jv := &JSONView{
log: log,
@ -128,3 +129,23 @@ func (v *JSONView) Outputs(outputs json.Outputs) {
"outputs", outputs,
)
}
// Output is designed for supporting command.WrappedUi
func (v *JSONView) Output(message string) {
v.log.Info(message, "type", "output")
}
// Info is designed for supporting command.WrappedUi
func (v *JSONView) Info(message string) {
v.log.Info(message)
}
// Warn is designed for supporting command.WrappedUi
func (v *JSONView) Warn(message string) {
v.log.Warn(message)
}
// Error is designed for supporting command.WrappedUi
func (v *JSONView) Error(message string) {
v.log.Error(message)
}

View File

@ -44,20 +44,44 @@ func TestNewJSONView(t *testing.T) {
}
func TestJSONView_Log(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
jv := NewJSONView(NewView(streams))
jv.Log("hello, world")
want := []map[string]interface{}{
testCases := []struct {
caseName string
input string
want []map[string]interface{}
}{
{
"@level": "info",
"@message": "hello, world",
"@module": "tofu.ui",
"type": "log",
"log with regular character",
"hello, world",
[]map[string]interface{}{
{
"@level": "info",
"@message": "hello, world",
"@module": "tofu.ui",
"type": "log",
},
},
},
{
"log with special character",
"hello, special char, <>&",
[]map[string]interface{}{
{
"@level": "info",
"@message": "hello, special char, <>&",
"@module": "tofu.ui",
"type": "log",
},
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
for _, tc := range testCases {
t.Run(tc.caseName, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
jv := NewJSONView(NewView(streams))
jv.Log(tc.input)
testJSONViewOutputEquals(t, done(t).Stdout(), tc.want)
})
}
}
// This test covers only the basics of JSON diagnostic rendering, as more