From 4734fbe5a8eabfd8d1d70fb9207732cf3e988214 Mon Sep 17 00:00:00 2001 From: Elie Date: Fri, 4 Mar 2022 10:20:24 +0100 Subject: [PATCH] feat: add `driftctl fmt` command --- pkg/cmd/driftctl.go | 1 + pkg/cmd/flags.go | 181 ++++++++++++++ pkg/cmd/flags_test.go | 235 ++++++++++++++++++ pkg/cmd/fmt.go | 68 +++++ pkg/cmd/fmt_test.go | 131 ++++++++++ pkg/cmd/scan.go | 169 ------------- pkg/cmd/scan_test.go | 233 +---------------- pkg/cmd/testdata/fmt/expected_console.txt | 30 +++ pkg/cmd/testdata/fmt/input_stdin_invalid.json | 1 + pkg/cmd/testdata/fmt/input_stdin_valid.json | 112 +++++++++ pkg/driftctl.go | 4 + 11 files changed, 764 insertions(+), 401 deletions(-) create mode 100644 pkg/cmd/flags.go create mode 100644 pkg/cmd/flags_test.go create mode 100644 pkg/cmd/fmt.go create mode 100644 pkg/cmd/fmt_test.go create mode 100644 pkg/cmd/testdata/fmt/expected_console.txt create mode 100644 pkg/cmd/testdata/fmt/input_stdin_invalid.json create mode 100644 pkg/cmd/testdata/fmt/input_stdin_valid.json diff --git a/pkg/cmd/driftctl.go b/pkg/cmd/driftctl.go index 416f37e9..81bc5eab 100644 --- a/pkg/cmd/driftctl.go +++ b/pkg/cmd/driftctl.go @@ -69,6 +69,7 @@ func NewDriftctlCmd(build build.BuildInterface) *DriftctlCmd { cmd.PersistentFlags().BoolP("send-crash-report", "", false, "Enable error reporting. Crash data will be sent to us via Sentry.\nWARNING: may leak sensitive data (please read the documentation for more details)\nThis flag should be used only if an error occurs during execution") cmd.AddCommand(NewScanCmd(&pkg.ScanOptions{})) + cmd.AddCommand(NewFmtCmd(&pkg.FmtOptions{})) cmd.AddCommand(NewGenDriftIgnoreCmd()) return cmd diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go new file mode 100644 index 00000000..19198ce3 --- /dev/null +++ b/pkg/cmd/flags.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" + cmderrors "github.com/snyk/driftctl/pkg/cmd/errors" + "github.com/snyk/driftctl/pkg/cmd/scan/output" + "github.com/snyk/driftctl/pkg/iac/config" + "github.com/snyk/driftctl/pkg/iac/supplier" + "github.com/snyk/driftctl/pkg/iac/terraform/state/backend" +) + +func parseFromFlag(from []string) ([]config.SupplierConfig, error) { + + configs := make([]config.SupplierConfig, 0, len(from)) + + for _, flag := range from { + schemePath := strings.Split(flag, "://") + if len(schemePath) != 2 || schemePath[1] == "" || schemePath[0] == "" { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nAccepted schemes are: %s", + strings.Join(supplier.GetSupportedSchemes(), ","), + ), + ), + "Unable to parse from flag '%s'", + flag, + ) + } + + scheme := schemePath[0] + path := schemePath[1] + supplierBackend := strings.Split(scheme, "+") + if len(supplierBackend) > 2 { + return nil, errors.Wrapf( + cmderrors.NewUsageError(fmt.Sprintf( + "\nAccepted schemes are: %s", + strings.Join(supplier.GetSupportedSchemes(), ","), + ), + ), + "Unable to parse from scheme '%s'", + scheme, + ) + } + + supplierKey := supplierBackend[0] + if !supplier.IsSupplierSupported(supplierKey) { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nAccepted values are: %s", + strings.Join(supplier.GetSupportedSuppliers(), ","), + ), + ), + "Unsupported IaC source '%s'", + supplierKey, + ) + } + + backendString := "" + if len(supplierBackend) == 2 { + backendString = supplierBackend[1] + if !backend.IsSupported(backendString) { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nAccepted values are: %s", + strings.Join(backend.GetSupportedBackends(), ","), + ), + ), + "Unsupported IaC backend '%s'", + backendString, + ) + } + } + + configs = append(configs, config.SupplierConfig{ + Key: supplierKey, + Backend: backendString, + Path: path, + }) + } + + return configs, nil +} + +func parseOutputFlags(out []string) ([]output.OutputConfig, error) { + result := make([]output.OutputConfig, 0, len(out)) + for _, v := range out { + o, err := parseOutputFlag(v) + if err != nil { + return result, err + } + result = append(result, *o) + } + return result, nil +} + +func parseOutputFlag(out string) (*output.OutputConfig, error) { + schemeOpts := strings.Split(out, "://") + if len(schemeOpts) < 2 || schemeOpts[0] == "" { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nAccepted formats are: %s", + strings.Join(output.SupportedOutputsExample(), ","), + ), + ), + "Unable to parse output flag '%s'", + out, + ) + } + + o := &output.OutputConfig{ + Key: schemeOpts[0], + } + if !output.IsSupported(o.Key) { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nValid formats are: %s", + strings.Join(output.SupportedOutputsExample(), ","), + ), + ), + "Unsupported output '%s'", + o.Key, + ) + } + + opts := schemeOpts[1:] + + switch o.Key { + case output.JSONOutputType: + if len(opts) != 1 || opts[0] == "" { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nMust be of kind: %s", + output.Example(output.JSONOutputType), + ), + ), + "Invalid json output '%s'", + out, + ) + } + o.Path = opts[0] + case output.HTMLOutputType: + if len(opts) != 1 || opts[0] == "" { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nMust be of kind: %s", + output.Example(output.HTMLOutputType), + ), + ), + "Invalid html output '%s'", + out, + ) + } + o.Path = opts[0] + case output.PlanOutputType: + if len(opts) != 1 || opts[0] == "" { + return nil, errors.Wrapf( + cmderrors.NewUsageError( + fmt.Sprintf( + "\nMust be of kind: %s", + output.Example(output.PlanOutputType), + ), + ), + "Invalid plan output '%s'", + out, + ) + } + o.Path = opts[0] + } + + return o, nil +} diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go new file mode 100644 index 00000000..c70d1469 --- /dev/null +++ b/pkg/cmd/flags_test.go @@ -0,0 +1,235 @@ +package cmd + +import ( + "fmt" + "reflect" + "testing" + + "github.com/snyk/driftctl/pkg/cmd/scan/output" + "github.com/snyk/driftctl/pkg/iac/config" +) + +func Test_parseFromFlag(t *testing.T) { + type args struct { + from []string + } + tests := []struct { + name string + args args + want []config.SupplierConfig + wantErr bool + }{ + { + name: "test complete from parsing", + args: args{ + from: []string{"tfstate+s3://bucket/path/to/state.tfstate"}, + }, + want: []config.SupplierConfig{ + { + Key: "tfstate", + Backend: "s3", + Path: "bucket/path/to/state.tfstate", + }, + }, + wantErr: false, + }, + { + name: "test complete from parsing with multiples flags", + args: args{ + from: []string{"tfstate+s3://bucket/path/to/state.tfstate", "tfstate:///tmp/my-state.tfstate"}, + }, + want: []config.SupplierConfig{ + { + Key: "tfstate", + Backend: "s3", + Path: "bucket/path/to/state.tfstate", + }, + { + Key: "tfstate", + Backend: "", + Path: "/tmp/my-state.tfstate", + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseFromFlag(tt.args.from) + if (err != nil) != tt.wantErr { + t.Errorf("parseFromFlag() error = %v, err %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseFromFlag() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_parseOutputFlag(t *testing.T) { + type args struct { + out []string + } + tests := []struct { + name string + args args + want []output.OutputConfig + err error + }{ + { + name: "test empty output", + args: args{ + out: []string{""}, + }, + want: []output.OutputConfig{}, + err: fmt.Errorf("Unable to parse output flag '': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), + }, + { + name: "test empty array", + args: args{ + out: []string{}, + }, + want: []output.OutputConfig{}, + err: nil, + }, + { + name: "test invalid", + args: args{ + out: []string{"sdgjsdgjsdg"}, + }, + want: []output.OutputConfig{}, + err: fmt.Errorf("Unable to parse output flag 'sdgjsdgjsdg': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), + }, + { + name: "test invalid", + args: args{ + out: []string{"://"}, + }, + want: []output.OutputConfig{}, + err: fmt.Errorf("Unable to parse output flag '://': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), + }, + { + name: "test unsupported", + args: args{ + out: []string{"foobar://"}, + }, + want: []output.OutputConfig{}, + err: fmt.Errorf("Unsupported output 'foobar': \nValid formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), + }, + { + name: "test empty json", + args: args{ + out: []string{"json://"}, + }, + want: []output.OutputConfig{}, + err: fmt.Errorf("Invalid json output 'json://': \nMust be of kind: json://PATH/TO/FILE.json"), + }, + { + name: "test valid console", + args: args{ + out: []string{"console://"}, + }, + want: []output.OutputConfig{ + { + Key: "console", + }, + }, + err: nil, + }, + { + name: "test valid json", + args: args{ + out: []string{"json:///tmp/foobar.json"}, + }, + want: []output.OutputConfig{ + { + Key: "json", + Path: "/tmp/foobar.json", + }, + }, + err: nil, + }, + { + name: "test empty jsonplan", + args: args{ + out: []string{"plan://"}, + }, + want: []output.OutputConfig{}, + err: fmt.Errorf("Invalid plan output 'plan://': \nMust be of kind: plan://PATH/TO/FILE.json"), + }, + { + name: "test valid jsonplan", + args: args{ + out: []string{"plan:///tmp/foobar.json"}, + }, + want: []output.OutputConfig{ + { + Key: "plan", + Path: "/tmp/foobar.json", + }, + }, + err: nil, + }, + { + name: "test multiple output values", + args: args{ + out: []string{"console:///dev/stdout", "json://result.json"}, + }, + want: []output.OutputConfig{ + { + Key: "console", + }, + { + Key: "json", + Path: "result.json", + }, + }, + err: nil, + }, + { + name: "test multiple output values with invalid value", + args: args{ + out: []string{"console:///dev/stdout", "invalid://result.json"}, + }, + want: []output.OutputConfig{ + { + Key: "console", + }, + }, + err: fmt.Errorf("Unsupported output 'invalid': \nValid formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), + }, + { + name: "test multiple valid output values", + args: args{ + out: []string{"json://result1.json", "json://result2.json", "json://result3.json"}, + }, + want: []output.OutputConfig{ + { + Key: "json", + Path: "result1.json", + }, + { + Key: "json", + Path: "result2.json", + }, + { + Key: "json", + Path: "result3.json", + }, + }, + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseOutputFlags(tt.args.out) + if err != nil && err.Error() != tt.err.Error() { + t.Fatalf("got error = '%v', expected '%v'", err, tt.err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("parseOutputFlag() got = '%v', want '%v'", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/fmt.go b/pkg/cmd/fmt.go new file mode 100644 index 00000000..f14a0e27 --- /dev/null +++ b/pkg/cmd/fmt.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "io" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/snyk/driftctl/pkg/analyser" + "github.com/spf13/cobra" + + "github.com/snyk/driftctl/pkg" + "github.com/snyk/driftctl/pkg/cmd/scan/output" +) + +func NewFmtCmd(opts *pkg.FmtOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "fmt", + Long: "Take an analysis results in JSON on stdin and return it in another format", + Hidden: true, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + outputFlag, _ := cmd.Flags().GetStringSlice("output") + if len(outputFlag) > 1 { + return errors.New("Only one output format can be set") + } + out, err := parseOutputFlags(outputFlag) + if err != nil { + return err + } + opts.Output = out[0] + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runFmt(opts, os.Stdin) + }, + } + + fl := cmd.Flags() + fl.StringSliceP( + "output", + "o", + []string{output.Example(output.ConsoleOutputType)}, + "Output format, by default it will write to the console\n"+ + "Accepted formats are: "+strings.Join(output.SupportedOutputsExample(), ",")+"\n", + ) + + return cmd +} + +func runFmt(opts *pkg.FmtOptions, reader io.Reader) error { + + var analysisText []byte + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + analysisText = append(analysisText, scanner.Bytes()...) + } + + analysis := analyser.NewAnalysis(analyser.AnalyzerOptions{}) + err := json.Unmarshal(analysisText, analysis) + if err != nil { + return err + } + + return output.GetOutput(opts.Output).Write(analysis) +} diff --git a/pkg/cmd/fmt_test.go b/pkg/cmd/fmt_test.go new file mode 100644 index 00000000..d4c90451 --- /dev/null +++ b/pkg/cmd/fmt_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/snyk/driftctl/pkg" + "github.com/snyk/driftctl/pkg/cmd/scan/output" + "github.com/snyk/driftctl/test" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_runFmt_InvalidInput(t *testing.T) { + opts := &pkg.FmtOptions{ + Output: output.OutputConfig{ + Key: output.ConsoleOutputType, + }, + } + + input, err := os.Open("testdata/fmt/input_stdin_invalid.json") + if err != nil { + t.Fatal(err) + } + defer input.Close() + + err = runFmt(opts, input) + require.NotNil(t, err) + assert.Equal(t, "invalid character 'i' looking for beginning of value", err.Error()) +} + +func Test_runFmt(t *testing.T) { + opts := &pkg.FmtOptions{ + Output: output.OutputConfig{ + Key: output.ConsoleOutputType, + }, + } + + input, err := os.Open("testdata/fmt/input_stdin_valid.json") + if err != nil { + t.Fatal(err) + } + defer input.Close() + + stdout := os.Stdout // keep backup of the real stdout + stderr := os.Stderr // keep backup of the real stderr + r, w, _ := os.Pipe() + os.Stdout = w + os.Stderr = w + + err = runFmt(opts, input) + + outC := make(chan []byte) + // copy the output in a separate goroutine so printing can't block indefinitely + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + outC <- buf.Bytes() + }() + + // back to normal state + assert.Nil(t, w.Close()) + os.Stdout = stdout // restoring the real stdout + os.Stderr = stderr + output := <-outC + + if err != nil { + t.Fatal(err) + } + + expectedBytes, err := os.ReadFile("testdata/fmt/expected_console.txt") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, string(expectedBytes), string(output)) +} + +func TestFmtCmd_Valid(t *testing.T) { + rootCmd := &cobra.Command{Use: "root"} + scanCmd := NewFmtCmd(&pkg.FmtOptions{}) + scanCmd.RunE = func(_ *cobra.Command, args []string) error { return nil } + rootCmd.AddCommand(scanCmd) + + cases := []struct { + args []string + }{ + {args: []string{"fmt"}}, + {args: []string{"fmt", "-o", "json://test.json"}}, + } + + for _, tt := range cases { + t.Run("", func(t *testing.T) { + output, err := test.Execute(rootCmd, tt.args...) + if output != "" { + t.Errorf("Unexpected output: %v", output) + } + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + } +} + +func TestFmtCmd_Invalid(t *testing.T) { + cases := []struct { + args []string + expected string + }{ + {args: []string{"fmt", "test"}, expected: `unknown command "test" for "root fmt"`}, + {args: []string{"fmt", "-o", "json://test.json", "-o", "html://test.html"}, expected: "Only one output format can be set"}, + {args: []string{"fmt", "-o", "foobar://barfoo"}, expected: "Unsupported output 'foobar': \nValid formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"}, + } + + for _, tt := range cases { + t.Run("", func(t *testing.T) { + rootCmd := &cobra.Command{Use: "root"} + rootCmd.AddCommand(NewFmtCmd(&pkg.FmtOptions{})) + _, err := test.Execute(rootCmd, tt.args...) + if err == nil { + t.Errorf("Invalid arg should generate error") + } + if err.Error() != tt.expected { + t.Errorf("Expected '%v', got '%v'", tt.expected, err) + } + }) + } +} diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index d86f8e6b..a60ed1d4 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -26,7 +26,6 @@ import ( cmderrors "github.com/snyk/driftctl/pkg/cmd/errors" "github.com/snyk/driftctl/pkg/cmd/scan/output" "github.com/snyk/driftctl/pkg/filter" - "github.com/snyk/driftctl/pkg/iac/config" "github.com/snyk/driftctl/pkg/iac/supplier" "github.com/snyk/driftctl/pkg/iac/terraform/state/backend" globaloutput "github.com/snyk/driftctl/pkg/output" @@ -354,174 +353,6 @@ func scanRun(opts *pkg.ScanOptions) error { return nil } -func parseFromFlag(from []string) ([]config.SupplierConfig, error) { - - configs := make([]config.SupplierConfig, 0, len(from)) - - for _, flag := range from { - schemePath := strings.Split(flag, "://") - if len(schemePath) != 2 || schemePath[1] == "" || schemePath[0] == "" { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nAccepted schemes are: %s", - strings.Join(supplier.GetSupportedSchemes(), ","), - ), - ), - "Unable to parse from flag '%s'", - flag, - ) - } - - scheme := schemePath[0] - path := schemePath[1] - supplierBackend := strings.Split(scheme, "+") - if len(supplierBackend) > 2 { - return nil, errors.Wrapf( - cmderrors.NewUsageError(fmt.Sprintf( - "\nAccepted schemes are: %s", - strings.Join(supplier.GetSupportedSchemes(), ","), - ), - ), - "Unable to parse from scheme '%s'", - scheme, - ) - } - - supplierKey := supplierBackend[0] - if !supplier.IsSupplierSupported(supplierKey) { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nAccepted values are: %s", - strings.Join(supplier.GetSupportedSuppliers(), ","), - ), - ), - "Unsupported IaC source '%s'", - supplierKey, - ) - } - - backendString := "" - if len(supplierBackend) == 2 { - backendString = supplierBackend[1] - if !backend.IsSupported(backendString) { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nAccepted values are: %s", - strings.Join(backend.GetSupportedBackends(), ","), - ), - ), - "Unsupported IaC backend '%s'", - backendString, - ) - } - } - - configs = append(configs, config.SupplierConfig{ - Key: supplierKey, - Backend: backendString, - Path: path, - }) - } - - return configs, nil -} - -func parseOutputFlags(out []string) ([]output.OutputConfig, error) { - result := make([]output.OutputConfig, 0, len(out)) - for _, v := range out { - o, err := parseOutputFlag(v) - if err != nil { - return result, err - } - result = append(result, *o) - } - return result, nil -} - -func parseOutputFlag(out string) (*output.OutputConfig, error) { - schemeOpts := strings.Split(out, "://") - if len(schemeOpts) < 2 || schemeOpts[0] == "" { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nAccepted formats are: %s", - strings.Join(output.SupportedOutputsExample(), ","), - ), - ), - "Unable to parse output flag '%s'", - out, - ) - } - - o := &output.OutputConfig{ - Key: schemeOpts[0], - } - if !output.IsSupported(o.Key) { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nValid formats are: %s", - strings.Join(output.SupportedOutputsExample(), ","), - ), - ), - "Unsupported output '%s'", - o.Key, - ) - } - - opts := schemeOpts[1:] - - switch o.Key { - case output.JSONOutputType: - if len(opts) != 1 || opts[0] == "" { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nMust be of kind: %s", - output.Example(output.JSONOutputType), - ), - ), - "Invalid json output '%s'", - out, - ) - } - o.Path = opts[0] - case output.HTMLOutputType: - if len(opts) != 1 || opts[0] == "" { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nMust be of kind: %s", - output.Example(output.HTMLOutputType), - ), - ), - "Invalid html output '%s'", - out, - ) - } - o.Path = opts[0] - case output.PlanOutputType: - if len(opts) != 1 || opts[0] == "" { - return nil, errors.Wrapf( - cmderrors.NewUsageError( - fmt.Sprintf( - "\nMust be of kind: %s", - output.Example(output.PlanOutputType), - ), - ), - "Invalid plan output '%s'", - out, - ) - } - o.Path = opts[0] - } - - return o, nil -} - func validateTfProviderVersionString(version string) error { if version == "" { return nil diff --git a/pkg/cmd/scan_test.go b/pkg/cmd/scan_test.go index 1191479e..91a093bf 100644 --- a/pkg/cmd/scan_test.go +++ b/pkg/cmd/scan_test.go @@ -1,18 +1,12 @@ package cmd import ( - "fmt" - "reflect" "testing" "github.com/snyk/driftctl/pkg" - "github.com/snyk/driftctl/pkg/cmd/scan/output" - "github.com/stretchr/testify/assert" - - "github.com/snyk/driftctl/pkg/iac/config" "github.com/snyk/driftctl/test" - "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" ) // TODO: Test successful scan @@ -111,231 +105,6 @@ func TestScanCmd_Invalid(t *testing.T) { } } -func Test_parseFromFlag(t *testing.T) { - type args struct { - from []string - } - tests := []struct { - name string - args args - want []config.SupplierConfig - wantErr bool - }{ - { - name: "test complete from parsing", - args: args{ - from: []string{"tfstate+s3://bucket/path/to/state.tfstate"}, - }, - want: []config.SupplierConfig{ - { - Key: "tfstate", - Backend: "s3", - Path: "bucket/path/to/state.tfstate", - }, - }, - wantErr: false, - }, - { - name: "test complete from parsing with multiples flags", - args: args{ - from: []string{"tfstate+s3://bucket/path/to/state.tfstate", "tfstate:///tmp/my-state.tfstate"}, - }, - want: []config.SupplierConfig{ - { - Key: "tfstate", - Backend: "s3", - Path: "bucket/path/to/state.tfstate", - }, - { - Key: "tfstate", - Backend: "", - Path: "/tmp/my-state.tfstate", - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseFromFlag(tt.args.from) - if (err != nil) != tt.wantErr { - t.Errorf("parseFromFlag() error = %v, err %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseFromFlag() got = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_parseOutputFlag(t *testing.T) { - type args struct { - out []string - } - tests := []struct { - name string - args args - want []output.OutputConfig - err error - }{ - { - name: "test empty output", - args: args{ - out: []string{""}, - }, - want: []output.OutputConfig{}, - err: fmt.Errorf("Unable to parse output flag '': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), - }, - { - name: "test empty array", - args: args{ - out: []string{}, - }, - want: []output.OutputConfig{}, - err: nil, - }, - { - name: "test invalid", - args: args{ - out: []string{"sdgjsdgjsdg"}, - }, - want: []output.OutputConfig{}, - err: fmt.Errorf("Unable to parse output flag 'sdgjsdgjsdg': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), - }, - { - name: "test invalid", - args: args{ - out: []string{"://"}, - }, - want: []output.OutputConfig{}, - err: fmt.Errorf("Unable to parse output flag '://': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), - }, - { - name: "test unsupported", - args: args{ - out: []string{"foobar://"}, - }, - want: []output.OutputConfig{}, - err: fmt.Errorf("Unsupported output 'foobar': \nValid formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), - }, - { - name: "test empty json", - args: args{ - out: []string{"json://"}, - }, - want: []output.OutputConfig{}, - err: fmt.Errorf("Invalid json output 'json://': \nMust be of kind: json://PATH/TO/FILE.json"), - }, - { - name: "test valid console", - args: args{ - out: []string{"console://"}, - }, - want: []output.OutputConfig{ - { - Key: "console", - }, - }, - err: nil, - }, - { - name: "test valid json", - args: args{ - out: []string{"json:///tmp/foobar.json"}, - }, - want: []output.OutputConfig{ - { - Key: "json", - Path: "/tmp/foobar.json", - }, - }, - err: nil, - }, - { - name: "test empty jsonplan", - args: args{ - out: []string{"plan://"}, - }, - want: []output.OutputConfig{}, - err: fmt.Errorf("Invalid plan output 'plan://': \nMust be of kind: plan://PATH/TO/FILE.json"), - }, - { - name: "test valid jsonplan", - args: args{ - out: []string{"plan:///tmp/foobar.json"}, - }, - want: []output.OutputConfig{ - { - Key: "plan", - Path: "/tmp/foobar.json", - }, - }, - err: nil, - }, - { - name: "test multiple output values", - args: args{ - out: []string{"console:///dev/stdout", "json://result.json"}, - }, - want: []output.OutputConfig{ - { - Key: "console", - }, - { - Key: "json", - Path: "result.json", - }, - }, - err: nil, - }, - { - name: "test multiple output values with invalid value", - args: args{ - out: []string{"console:///dev/stdout", "invalid://result.json"}, - }, - want: []output.OutputConfig{ - { - Key: "console", - }, - }, - err: fmt.Errorf("Unsupported output 'invalid': \nValid formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), - }, - { - name: "test multiple valid output values", - args: args{ - out: []string{"json://result1.json", "json://result2.json", "json://result3.json"}, - }, - want: []output.OutputConfig{ - { - Key: "json", - Path: "result1.json", - }, - { - Key: "json", - Path: "result2.json", - }, - { - Key: "json", - Path: "result3.json", - }, - }, - err: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseOutputFlags(tt.args.out) - if err != nil && err.Error() != tt.err.Error() { - t.Fatalf("got error = '%v', expected '%v'", err, tt.err) - } - if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("parseOutputFlag() got = '%v', want '%v'", got, tt.want) - } - }) - } -} - func Test_Options(t *testing.T) { cases := []struct { name string diff --git a/pkg/cmd/testdata/fmt/expected_console.txt b/pkg/cmd/testdata/fmt/expected_console.txt new file mode 100644 index 00000000..a6270474 --- /dev/null +++ b/pkg/cmd/testdata/fmt/expected_console.txt @@ -0,0 +1,30 @@ +Found missing resources: + - testuser1 (aws_iam_user) + - testrole1 (aws_iam_role) +Found resources not covered by IaC: + aws_iam_access_key: + - AKIAXYUOJZ3H5YCXF34G + - AKIAXYUOJZ3HV2LTLXD2 + - AKIAXYUOJZ3HUSPPQQ4L + aws_iam_role: + - OrganizationAccountAccessRole + - driftctl_assume\_role + aws_iam_role_policy: + - OrganizationAccountAccessRole:AdministratorAccess + - driftctl_assume_role:driftctl_policy.10 + aws_iam_user: + - driftctl + - sundowndev + - test_user + aws_iam_user_policy: + - driftctl:driftctlrole +Found changed resources: + - test-20210416154114486700000001 (aws_s3_bucket): + ~ BucketPrefix: "test-" => + + Tags.tag2: => "value" + ~ Tags.test: "test" => "test1" +Found 14 resource(s) + - 7% coverage + - 1 resource(s) managed by Terraform + - 11 resource(s) not managed by Terraform + - 2 resource(s) found in a Terraform state but missing on the cloud provider diff --git a/pkg/cmd/testdata/fmt/input_stdin_invalid.json b/pkg/cmd/testdata/fmt/input_stdin_invalid.json new file mode 100644 index 00000000..e466dcbd --- /dev/null +++ b/pkg/cmd/testdata/fmt/input_stdin_invalid.json @@ -0,0 +1 @@ +invalid \ No newline at end of file diff --git a/pkg/cmd/testdata/fmt/input_stdin_valid.json b/pkg/cmd/testdata/fmt/input_stdin_valid.json new file mode 100644 index 00000000..6b44fa55 --- /dev/null +++ b/pkg/cmd/testdata/fmt/input_stdin_valid.json @@ -0,0 +1,112 @@ +{ + "summary": { + "total_resources": 12, + "total_changed": 1, + "total_unmanaged": 11, + "total_missing": 0, + "total_managed": 1 + }, + "managed": [ + { + "id": "test-20210416154114486700000001", + "type": "aws_s3_bucket" + } + ], + "unmanaged": [ + { + "id": "driftctl", + "type": "aws_iam_user" + }, + { + "id": "sundowndev", + "type": "aws_iam_user" + }, + { + "id": "test_user", + "type": "aws_iam_user" + }, + { + "id": "OrganizationAccountAccessRole:AdministratorAccess", + "type": "aws_iam_role_policy" + }, + { + "id": "driftctl_assume_role:driftctl_policy.10", + "type": "aws_iam_role_policy" + }, + { + "id": "OrganizationAccountAccessRole", + "type": "aws_iam_role" + }, + { + "id": "driftctl_assume\\_role", + "type": "aws_iam_role" + }, + { + "id": "driftctl:driftctlrole", + "type": "aws_iam_user_policy" + }, + { + "id": "AKIAXYUOJZ3H5YCXF34G", + "type": "aws_iam_access_key" + }, + { + "id": "AKIAXYUOJZ3HV2LTLXD2", + "type": "aws_iam_access_key" + }, + { + "id": "AKIAXYUOJZ3HUSPPQQ4L", + "type": "aws_iam_access_key" + } + ], + "missing": [ + { + "id": "testuser1", + "type": "aws_iam_user" + }, + { + "id": "testrole1", + "type": "aws_iam_role" + } + ], + "differences": [ + { + "res": { + "id": "test-20210416154114486700000001", + "type": "aws_s3_bucket" + }, + "changelog": [ + { + "type": "update", + "path": [ + "BucketPrefix" + ], + "from": "test-", + "to": null, + "computed": false + }, + { + "type": "create", + "path": [ + "Tags", + "tag2" + ], + "from": null, + "to": "value", + "computed": false + }, + { + "type": "update", + "path": [ + "Tags", + "test" + ], + "from": "test", + "to": "test1", + "computed": false + } + ] + } + ], + "coverage": 8, + "alerts": null +} \ No newline at end of file diff --git a/pkg/driftctl.go b/pkg/driftctl.go index ab1efda2..61d4a3f9 100644 --- a/pkg/driftctl.go +++ b/pkg/driftctl.go @@ -19,6 +19,10 @@ import ( "github.com/snyk/driftctl/pkg/resource" ) +type FmtOptions struct { + Output output.OutputConfig +} + type ScanOptions struct { Coverage bool Detect bool