Merge pull request #786 from cloudskiff/output/plan

First iteration of the output plan
main
William BEUIL 2021-07-02 14:08:56 +02:00 committed by GitHub
commit 226e1e716a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 466 additions and 5 deletions

View File

@ -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{

View File

@ -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{

View File

@ -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:

View File

@ -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) {

128
pkg/cmd/scan/output/plan.go Normal file
View File

@ -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
}

View File

@ -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))
})
}
}

View File

@ -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"
}
}
}
]
}

View File

@ -0,0 +1,6 @@
{
"format_version": "0.1",
"planned_values": {
"root_module": {}
}
}

View File

@ -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) {