commit
226e1e716a
|
@ -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{
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"format_version": "0.1",
|
||||
"planned_values": {
|
||||
"root_module": {}
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue