diff --git a/pkg/cmd/driftctl_test.go b/pkg/cmd/driftctl_test.go index 97ee31d6..7ad0f262 100644 --- a/pkg/cmd/driftctl_test.go +++ b/pkg/cmd/driftctl_test.go @@ -111,7 +111,7 @@ func TestDriftctlCmd_Scan(t *testing.T) { env: map[string]string{ "DCTL_OUTPUT": "test", }, - err: fmt.Errorf("Unable to parse output flag 'test': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json"), + err: fmt.Errorf("Unable to parse output flag 'test': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json,plan://PATH/TO/FILE.json"), }, { env: map[string]string{ diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index 43f36db7..3cf3833d 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -387,6 +387,20 @@ func parseOutputFlag(out string) (*output.OutputConfig, error) { ) } options["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, + ) + } + options["path"] = opts[0] } return &output.OutputConfig{ diff --git a/pkg/cmd/scan/output/output.go b/pkg/cmd/scan/output/output.go index dd44b30f..dce9df31 100644 --- a/pkg/cmd/scan/output/output.go +++ b/pkg/cmd/scan/output/output.go @@ -15,12 +15,14 @@ var supportedOutputTypes = []string{ ConsoleOutputType, JSONOutputType, HTMLOutputType, + PlanOutputType, } var supportedOutputExample = map[string]string{ ConsoleOutputType: ConsoleOutputExample, JSONOutputType: JSONOutputExample, HTMLOutputType: HTMLOutputExample, + PlanOutputType: PlanOutputExample, } func SupportedOutputs() []string { @@ -57,6 +59,8 @@ func GetOutput(config OutputConfig, quiet bool) Output { return NewJSON(config.Options["path"]) case HTMLOutputType: return NewHTML(config.Options["path"]) + case PlanOutputType: + return NewPlan(config.Options["path"]) case ConsoleOutputType: fallthrough default: @@ -75,6 +79,11 @@ func GetPrinter(config OutputConfig, quiet bool) output.Printer { return &output.VoidPrinter{} } fallthrough + case PlanOutputType: + if isStdOut(config.Options["path"]) { + return &output.VoidPrinter{} + } + fallthrough case ConsoleOutputType: fallthrough default: diff --git a/pkg/cmd/scan/output/output_test.go b/pkg/cmd/scan/output/output_test.go index 5ac690a2..78b5b70e 100644 --- a/pkg/cmd/scan/output/output_test.go +++ b/pkg/cmd/scan/output/output_test.go @@ -324,6 +324,43 @@ func fakeAnalysisWithGithubEnumerationError() *analyser.Analysis { return &a } +func fakeAnalysisForJSONPlan() *analyser.Analysis { + a := analyser.Analysis{} + a.AddUnmanaged( + &resource.AbstractResource{ + Id: "unmanaged-id-1", + Type: "aws_unmanaged_resource", + Attrs: &resource.Attributes{ + "name": "First unmanaged resource", + }, + }, + &resource.AbstractResource{ + Id: "unmanaged-id-2", + Type: "aws_unmanaged_resource", + Attrs: &resource.Attributes{ + "name": "Second unmanaged resource", + }, + }, + ) + a.AddManaged( + &resource.AbstractResource{ + Id: "managed-id-1", + Type: "aws_managed_resource", + Attrs: &resource.Attributes{ + "name": "First managed resource", + }, + }, + &resource.AbstractResource{ + Id: "managed-id-2", + Type: "aws_managed_resource", + Attrs: &resource.Attributes{ + "name": "Second managed resource", + }, + }, + ) + return &a +} + func TestGetPrinter(t *testing.T) { tests := []struct { name string @@ -370,6 +407,24 @@ func TestGetPrinter(t *testing.T) { key: ConsoleOutputType, want: &output.VoidPrinter{}, }, + { + name: "jsonplan file output", + path: "/path/to/file", + key: PlanOutputType, + want: output.NewConsolePrinter(), + }, + { + name: "jsonplan stdout output", + path: "stdout", + key: PlanOutputType, + want: &output.VoidPrinter{}, + }, + { + name: "jsonplan /dev/stdout output", + path: "/dev/stdout", + key: PlanOutputType, + want: &output.VoidPrinter{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/scan/output/plan.go b/pkg/cmd/scan/output/plan.go new file mode 100644 index 00000000..5dd7c5a2 --- /dev/null +++ b/pkg/cmd/scan/output/plan.go @@ -0,0 +1,128 @@ +package output + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/cloudskiff/driftctl/pkg/analyser" + "github.com/cloudskiff/driftctl/pkg/resource" +) + +const FormatVersion = "0.1" +const PlanOutputType = "plan" +const PlanOutputExample = "plan://PATH/TO/FILE.json" + +type plan struct { + FormatVersion string `json:"format_version,omitempty"` + PlannedValues plannedValues `json:"planned_values,omitempty"` + ResourceChanges []rscChange `json:"resource_changes,omitempty"` +} + +type plannedValues struct { + RootModule module `json:"root_module,omitempty"` +} + +type rscChange struct { + Address string `json:"address,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Change change `json:"change,omitempty"` +} + +type change struct { + Actions []string `json:"actions,omitempty"` + Before map[string]interface{} `json:"before,omitempty"` + After map[string]interface{} `json:"after,omitempty"` +} + +type module struct { + Resources []rsc `json:"resources,omitempty"` +} + +type rsc struct { + Address string `json:"address,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + AttributeValues map[string]interface{} `json:"values,omitempty"` +} + +type Plan struct { + path string +} + +func NewPlan(path string) *Plan { + return &Plan{path} +} + +func (c *Plan) Write(analysis *analyser.Analysis) error { + file := os.Stdout + if !isStdOut(c.path) { + f, err := os.OpenFile(c.path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + file = f + } + output := plan{FormatVersion: FormatVersion} + output.PlannedValues.RootModule = addPlannedValues(analysis) + output.ResourceChanges = addResourceChanges(analysis) + jsonPlan, err := json.MarshalIndent(output, "", "\t") + if err != nil { + return err + } + if _, err := file.Write(jsonPlan); err != nil { + return err + } + return nil +} + +func addPlannedValues(analysis *analyser.Analysis) module { + managedRsc := listRsc(analysis.Managed()) + unmanagedRsc := listRsc(analysis.Unmanaged()) + return module{ + Resources: append(managedRsc, unmanagedRsc...), + } +} + +func listRsc(resources []resource.Resource) []rsc { + var ret []rsc + for _, res := range resources { + r := rsc{ + Address: fmt.Sprintf("%s.%s", res.TerraformType(), res.TerraformId()), + Type: res.TerraformType(), + Name: res.TerraformId(), + AttributeValues: *res.Attributes(), + } + ret = append(ret, r) + } + return ret +} + +func addResourceChanges(analysis *analyser.Analysis) []rscChange { + managedRsc := listRscChange(analysis.Managed(), "no-op") + unmanagedRsc := listRscChange(analysis.Unmanaged(), "create") + return append(managedRsc, unmanagedRsc...) +} + +func listRscChange(resources []resource.Resource, action string) []rscChange { + var ret []rscChange + for _, res := range resources { + r := rscChange{ + Address: fmt.Sprintf("%s.%s", res.TerraformType(), res.TerraformId()), + Type: res.TerraformType(), + Name: res.TerraformId(), + Change: change{ + Actions: []string{action}, + After: *res.Attributes(), + }, + } + if action == "no-op" { + r.Change.Before = *res.Attributes() + } + ret = append(ret, r) + + } + return ret +} diff --git a/pkg/cmd/scan/output/plan_test.go b/pkg/cmd/scan/output/plan_test.go new file mode 100644 index 00000000..d36cce56 --- /dev/null +++ b/pkg/cmd/scan/output/plan_test.go @@ -0,0 +1,127 @@ +package output + +import ( + "bytes" + "io" + "io/ioutil" + "os" + "path" + "testing" + + "github.com/cloudskiff/driftctl/pkg/analyser" + "github.com/cloudskiff/driftctl/test/goldenfile" + "github.com/stretchr/testify/assert" +) + +func TestPlan_Write(t *testing.T) { + tests := []struct { + name string + goldenfile string + analysis *analyser.Analysis + wantErr bool + }{ + { + name: "test jsonplan output", + goldenfile: "output_plan.json", + analysis: fakeAnalysisForJSONPlan(), + wantErr: false, + }, + { + name: "test jsonplan output when no infra", + goldenfile: "output_plan_empty.json", + analysis: &analyser.Analysis{}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + tempFile, err := ioutil.TempFile(tempDir, "result") + if err != nil { + t.Fatal(err) + } + c := NewPlan(tempFile.Name()) + if err := c.Write(tt.analysis); (err != nil) != tt.wantErr { + t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) + } + result, err := ioutil.ReadFile(tempFile.Name()) + if err != nil { + t.Fatal(err) + } + expectedFilePath := path.Join("./testdata/", tt.goldenfile) + if *goldenfile.Update == tt.goldenfile { + if err := ioutil.WriteFile(expectedFilePath, result, 0600); err != nil { + t.Fatal(err) + } + } + expected, err := ioutil.ReadFile(expectedFilePath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, string(expected), string(result)) + }) + } +} + +func TestPlan_Write_stdout(t *testing.T) { + tests := []struct { + name string + path string + goldenfile string + analysis *analyser.Analysis + wantErr bool + }{ + { + name: "test jsonplan output on stdout", + goldenfile: "output_plan.json", + path: "stdout", + analysis: fakeAnalysisForJSONPlan(), + wantErr: false, + }, + + { + name: "test jsonplan output on /dev/stdout", + goldenfile: "output_plan.json", + path: "/dev/stdout", + analysis: fakeAnalysisForJSONPlan(), + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout := os.Stdout // keep backup of the real stdout + r, w, _ := os.Pipe() + os.Stdout = w + + c := NewPlan(tt.path) + if err := c.Write(tt.analysis); (err != nil) != tt.wantErr { + t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) + } + + 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 + w.Close() + os.Stdout = stdout // restoring the real stdout + result := <-outC + + expectedFilePath := path.Join("./testdata/", tt.goldenfile) + if *goldenfile.Update == tt.goldenfile { + if err := ioutil.WriteFile(expectedFilePath, result, 0600); err != nil { + t.Fatal(err) + } + } + expected, err := ioutil.ReadFile(expectedFilePath) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, string(expected), string(result)) + }) + } +} diff --git a/pkg/cmd/scan/output/testdata/output_plan.json b/pkg/cmd/scan/output/testdata/output_plan.json new file mode 100644 index 00000000..3e83a31a --- /dev/null +++ b/pkg/cmd/scan/output/testdata/output_plan.json @@ -0,0 +1,101 @@ +{ + "format_version": "0.1", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "aws_managed_resource.managed-id-1", + "type": "aws_managed_resource", + "name": "managed-id-1", + "values": { + "name": "First managed resource" + } + }, + { + "address": "aws_managed_resource.managed-id-2", + "type": "aws_managed_resource", + "name": "managed-id-2", + "values": { + "name": "Second managed resource" + } + }, + { + "address": "aws_unmanaged_resource.unmanaged-id-1", + "type": "aws_unmanaged_resource", + "name": "unmanaged-id-1", + "values": { + "name": "First unmanaged resource" + } + }, + { + "address": "aws_unmanaged_resource.unmanaged-id-2", + "type": "aws_unmanaged_resource", + "name": "unmanaged-id-2", + "values": { + "name": "Second unmanaged resource" + } + } + ] + } + }, + "resource_changes": [ + { + "address": "aws_managed_resource.managed-id-1", + "type": "aws_managed_resource", + "name": "managed-id-1", + "change": { + "actions": [ + "no-op" + ], + "before": { + "name": "First managed resource" + }, + "after": { + "name": "First managed resource" + } + } + }, + { + "address": "aws_managed_resource.managed-id-2", + "type": "aws_managed_resource", + "name": "managed-id-2", + "change": { + "actions": [ + "no-op" + ], + "before": { + "name": "Second managed resource" + }, + "after": { + "name": "Second managed resource" + } + } + }, + { + "address": "aws_unmanaged_resource.unmanaged-id-1", + "type": "aws_unmanaged_resource", + "name": "unmanaged-id-1", + "change": { + "actions": [ + "create" + ], + "after": { + "name": "First unmanaged resource" + } + } + }, + { + "address": "aws_unmanaged_resource.unmanaged-id-2", + "type": "aws_unmanaged_resource", + "name": "unmanaged-id-2", + "change": { + "actions": [ + "create" + ], + "after": { + "name": "Second unmanaged resource" + } + } + } + ] +} \ No newline at end of file diff --git a/pkg/cmd/scan/output/testdata/output_plan_empty.json b/pkg/cmd/scan/output/testdata/output_plan_empty.json new file mode 100644 index 00000000..e9699389 --- /dev/null +++ b/pkg/cmd/scan/output/testdata/output_plan_empty.json @@ -0,0 +1,6 @@ +{ + "format_version": "0.1", + "planned_values": { + "root_module": {} + } +} \ No newline at end of file diff --git a/pkg/cmd/scan_test.go b/pkg/cmd/scan_test.go index 591f2a85..ff402455 100644 --- a/pkg/cmd/scan_test.go +++ b/pkg/cmd/scan_test.go @@ -177,7 +177,7 @@ func Test_parseOutputFlag(t *testing.T) { out: "", }, want: nil, - err: fmt.Errorf("Unable to parse output flag '': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json"), + 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 invalid", @@ -185,7 +185,7 @@ func Test_parseOutputFlag(t *testing.T) { out: "sdgjsdgjsdg", }, want: nil, - err: fmt.Errorf("Unable to parse output flag 'sdgjsdgjsdg': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json"), + 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", @@ -193,7 +193,7 @@ func Test_parseOutputFlag(t *testing.T) { out: "://", }, want: nil, - err: fmt.Errorf("Unable to parse output flag '://': \nAccepted formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json"), + 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", @@ -201,7 +201,7 @@ func Test_parseOutputFlag(t *testing.T) { out: "foobar://", }, want: nil, - err: fmt.Errorf("Unsupported output 'foobar': \nValid formats are: console://,html://PATH/TO/FILE.html,json://PATH/TO/FILE.json"), + 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", @@ -235,6 +235,27 @@ func Test_parseOutputFlag(t *testing.T) { }, err: nil, }, + { + name: "test empty jsonplan", + args: args{ + out: "plan://", + }, + want: nil, + err: fmt.Errorf("Invalid plan output 'plan://': \nMust be of kind: plan://PATH/TO/FILE.json"), + }, + { + name: "test valid jsonplan", + args: args{ + out: "plan:///tmp/foobar.json", + }, + want: &output.OutputConfig{ + Key: "plan", + Options: map[string]string{ + "path": "/tmp/foobar.json", + }, + }, + err: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {