diff --git a/pkg/analyser/analysis.go b/pkg/analyser/analysis.go index 914004fd..56dc08dc 100644 --- a/pkg/analyser/analysis.go +++ b/pkg/analyser/analysis.go @@ -42,6 +42,7 @@ type Analysis struct { summary Summary alerts alerter.Alerts Duration time.Duration + Date time.Time } type serializableDifference struct { diff --git a/pkg/cmd/driftctl_test.go b/pkg/cmd/driftctl_test.go index 9ceb5202..97ee31d6 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://,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"), }, { env: map[string]string{ diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index 7f53bf00..8e49137f 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -367,6 +367,20 @@ func parseOutputFlag(out string) (*output.OutputConfig, error) { ) } options["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, + ) + } + options["path"] = opts[0] } return &output.OutputConfig{ diff --git a/pkg/cmd/scan/output/assets/index.tmpl b/pkg/cmd/scan/output/assets/index.tmpl new file mode 100644 index 00000000..ee1dec66 --- /dev/null +++ b/pkg/cmd/scan/output/assets/index.tmpl @@ -0,0 +1,375 @@ + + + + driftctl Scan Report + + + + + + +
+
+ driftctl logo +
+

Scan Report

+

{{ .ScanDate }}

+

Scan Duration: {{.ScanDuration}}

+
+
+
+
+ Total Resources: + {{.Summary.TotalResources}} +
+
+ Coverage: + {{.Coverage}}% +
+
+ Managed: + {{rate .Summary.TotalManaged}}% + {{.Summary.TotalManaged}}/{{.Summary.TotalResources}} +
+
+ Unmanaged: + {{rate .Summary.TotalUnmanaged}}% + {{.Summary.TotalUnmanaged}}/{{.Summary.TotalResources}} +
+
+ Missing: + {{rate .Summary.TotalDeleted}}% + {{.Summary.TotalDeleted}}/{{.Summary.TotalResources}} +
+
+
+ {{ if (lt .Coverage 100) }} +
+ + + + + +
+ +
+
+ {{if (gt (len .Unmanaged) 0)}} + + {{end}} + {{if (gt (len .Differences) 0)}} + + {{end}} + {{if (gt (len .Deleted) 0)}} + + {{end}} + {{if (gt (len .Alerts) 0)}} + + {{end}} +
+
+ {{ if (gt (len .Unmanaged) 0) }} +
+ + + + + + + + + {{range $res := .Unmanaged}} + + + + + {{end}} + +
Resource IDResource Type
{{$res.TerraformId}}{{$res.TerraformType}}
+ +
+ {{end}} + {{ if (gt (len .Differences) 0) }} + + {{end}} + {{ if (gt (len .Deleted) 0) }} + + {{end}} + {{ if (gt (len .Alerts) 0) }} + + {{end}} +
+
+ {{else}} +

Congrats! Your infrastructure is in sync

+ {{end}} +
+
+ + + + diff --git a/pkg/cmd/scan/output/assets/style.css b/pkg/cmd/scan/output/assets/style.css new file mode 100644 index 00000000..4cce0ef2 --- /dev/null +++ b/pkg/cmd/scan/output/assets/style.css @@ -0,0 +1,329 @@ +html, body, div, span, h1, h2, p, pre, a, code, img, ul, li, form, label, table, tbody, thead, tr, th, td, header, section, button { + border: 0; + font: inherit; + margin: 0; + padding: 0; + vertical-align: baseline; +} + +body { + background-color: #f7f7f9; + color: #1c1e21; + font-family: Helvetica, sans-serif; + padding-bottom: 50px; +} + +form { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + margin-bottom: 20px; +} + +h1 { + font-size: 24px; + font-weight: 700; + margin-bottom: 5px; +} + +h2 { + font-size: 20px; + font-weight: 700; + margin-bottom: 5px; +} + +header { + align-items: center; + display: flex; + flex-direction: row; + height: 130px; + justify-content: center; +} + +img { + margin-right: 20px; +} + +input::placeholder { + color: #ccc; + opacity: 1; +} + +main { + background-color: #fff; + border-top: 3px solid #71b2c3; + box-shadow: 0 0 5px #0000000a; + padding: 25px; +} + +section { + background: #fff; + border-radius: 3px; + box-shadow: 0 0 5px #0000000a; + color: #747578; + display: flex; + flex-direction: column; + font-size: 15px; + margin-bottom: 20px; + padding: 15px; +} + +select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: url(data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0Ljk1IDEwIj48ZGVmcz48c3R5bGU+LmNscy0xe2ZpbGw6I2ZmZjt9LmNscy0ye2ZpbGw6IzQ0NDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPmFycm93czwvdGl0bGU+PHJlY3QgY2xhc3M9ImNscy0xIiB3aWR0aD0iNC45NSIgaGVpZ2h0PSIxMCIvPjxwb2x5Z29uIGNsYXNzPSJjbHMtMiIgcG9pbnRzPSIxLjQxIDQuNjcgMi40OCAzLjE4IDMuNTQgNC42NyAxLjQxIDQuNjciLz48cG9seWdvbiBjbGFzcz0iY2xzLTIiIHBvaW50cz0iMy41NCA1LjMzIDIuNDggNi44MiAxLjQxIDUuMzMgMy41NCA1LjMzIi8+PC9zdmc+) no-repeat 97% 50%; +} + +table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; +} + +tbody, ul, .table-body { + border-left: 1px solid #ececec; + border-right: 1px solid #ececec; + border-top: 1px solid #ececec; + border-radius: 3px; + display: block; +} + +ul { + list-style: none; +} + +[role="tab"] { + background: transparent; + border-radius: 3px; + color: #747578; + cursor: pointer; + display: inline-block; + font-size: 16px; + margin: 4px; + padding: 10px 20px; +} + +[role="tab"]:hover { + background-color: #f9f9f9; +} + +[role="tab"][aria-selected="true"] { + background: #71b2c3; + color: #fff; +} + +[role="tablist"] { + display: flex; + flex-direction: column; +} + +[role="tabpanel"] { + -webkit-animation: fadein .8s; + animation: fadein .8s; + width: 100%; +} + +[role="tabpanel"].is-hidden { + opacity: 0; +} + +input[type="reset"] { + background-color: transparent; + border: none; + color: #5faabd; + cursor: pointer; + font-size: 14px; + height: 34px; + margin: 5px; + width: 100px; +} + +input[type="search"], select { + border: 1px solid #ececec; + border-radius: 3px; + color: #6e7071; + font-size: 14px; + height: 36px; + margin: 5px; + max-width: 300px; + padding: 8px; + width: 100%; +} + +.card { + align-items: center; + display: flex; + flex-direction: row; + justify-content: center; + margin: 5px 0; +} + +.code-box { + background: #eee; + border-radius: 3px; + color: #747578; + display: flex; + margin-top: 20px; +} + +.code-box-line { + line-height: 30px; + overflow-x: auto; + padding: 10px; + width: 100%; +} + +.code-box-line-create { + background-color: #22863a1a; + border-radius: 3px; + color: #22863a; + padding: 3px; +} + +.code-box-line-delete { + background-color: #bf404a17; + border-radius: 3px; + color: #bf404a; + padding: 3px; + text-decoration: line-through; +} + +.congrats { + color: #4d9221; + text-align: center; + margin: 50px 0; +} + +.container { + margin: auto; + max-width: 100%; + width: 1280px; +} + +.empty-panel { + color: #747578; + display: flex; + flex-direction: row; + font-size: 20px; + font-weight: 600; + justify-content: center; + padding: 25px; +} + +.fraction { + background: #e8e8e8; + border-radius: 3px; + color: #555; + font-size: 12px; + margin-left: 5px; + padding: 4px 5px; +} + +.panels { + padding: 10px; + width: 100%; +} + +.resource-item { + border-bottom: 1px solid #ececec; + color: #6e7071; + font-size: 14px; + padding: 15px; +} + +.resource-item:hover { + background-color: #f9f9f9; +} + +.row { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.strong { + color: #333; + font-weight: 700; + margin-left: 5px; +} + +.table-header { + color: #747578; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 10px; +} + +.tabs-wrapper { + align-items: center; + display: flex; + flex-direction: column; +} + +.visuallyhidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.is-hidden { + display: none; +} + +@-webkit-keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (min-width: 768px) { + form { + flex-direction: row; + } + + header { + padding: 0 50px; + justify-content: flex-start; + } + + section { + flex-direction: row; + justify-content: space-around; + } + + [role="tab"] { + font-size: 18px; + } + + [role="tablist"] { + flex-direction: row; + } + + .card { + margin: 0; + } + + .panels { + padding: 20px; + } +} diff --git a/pkg/cmd/scan/output/html.go b/pkg/cmd/scan/output/html.go new file mode 100644 index 00000000..e54e5b1d --- /dev/null +++ b/pkg/cmd/scan/output/html.go @@ -0,0 +1,176 @@ +package output + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "math" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws/awsutil" + "github.com/cloudskiff/driftctl/pkg/alerter" + "github.com/cloudskiff/driftctl/pkg/analyser" + "github.com/cloudskiff/driftctl/pkg/resource" + "github.com/r3labs/diff/v2" +) + +const HTMLOutputType = "html" +const HTMLOutputExample = "html://PATH/TO/FILE.html" + +// assets holds our static web content. +//go:embed assets/* +var assets embed.FS + +type HTML struct { + path string +} + +type HTMLTemplateParams struct { + ScanDate string + Coverage int + Summary analyser.Summary + Unmanaged []resource.Resource + Differences []analyser.Difference + Deleted []resource.Resource + Alerts alerter.Alerts + Stylesheet template.CSS + ScanDuration string +} + +func NewHTML(path string) *HTML { + return &HTML{path} +} + +func (c *HTML) 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 + } + + tmplFile, err := assets.ReadFile("assets/index.tmpl") + if err != nil { + return err + } + + styleFile, err := assets.ReadFile("assets/style.css") + if err != nil { + return err + } + + funcMap := template.FuncMap{ + "getResourceTypes": func() []string { + resources := make([]resource.Resource, 0) + resources = append(resources, analysis.Unmanaged()...) + resources = append(resources, analysis.Deleted()...) + + for _, d := range analysis.Differences() { + resources = append(resources, d.Res) + } + + return distinctResourceTypes(resources) + }, + "rate": func(count int) float64 { + if analysis.Summary().TotalResources == 0 { + return 0 + } + return math.Round(100 * float64(count) / float64(analysis.Summary().TotalResources)) + }, + "jsonDiff": func(ch analyser.Changelog) template.HTML { + var buf bytes.Buffer + + whiteSpace := " " + for _, change := range ch { + for i, v := range change.Path { + if _, err := strconv.Atoi(v); err == nil { + change.Path[i] = fmt.Sprintf("[%s]", v) + } + } + path := strings.Join(change.Path, ".") + + switch change.Type { + case diff.CREATE: + pref := fmt.Sprintf("%s %s:", "+", path) + _, _ = fmt.Fprintf(&buf, "%s%s %s", whiteSpace, pref, prettify(change.To)) + case diff.DELETE: + pref := fmt.Sprintf("%s %s:", "-", path) + _, _ = fmt.Fprintf(&buf, "%s%s %s", whiteSpace, pref, prettify(change.From)) + case diff.UPDATE: + prefix := fmt.Sprintf("%s %s:", "~", path) + if change.JsonString { + _, _ = fmt.Fprintf(&buf, "%s%s
%s%s
", whiteSpace, prefix, whiteSpace, jsonDiff(change.From, change.To, whiteSpace)) + continue + } + _, _ = fmt.Fprintf(&buf, "%s%s %s => %s", whiteSpace, prefix, htmlPrettify(change.From), htmlPrettify(change.To)) + } + + if change.Computed { + _, _ = fmt.Fprintf(&buf, " %s", "(computed)") + } + _, _ = fmt.Fprintf(&buf, "
") + } + + return template.HTML(buf.String()) + }, + } + + tmpl, err := template.New("main").Funcs(funcMap).Parse(string(tmplFile)) + if err != nil { + return err + } + + data := &HTMLTemplateParams{ + ScanDate: analysis.Date.Format("Jan 02, 2006"), + Summary: analysis.Summary(), + Coverage: analysis.Coverage(), + Unmanaged: analysis.Unmanaged(), + Differences: analysis.Differences(), + Deleted: analysis.Deleted(), + Alerts: analysis.Alerts(), + Stylesheet: template.CSS(styleFile), + ScanDuration: analysis.Duration.Round(time.Second).String(), + } + + err = tmpl.Execute(file, data) + if err != nil { + return err + } + + return nil +} + +func distinctResourceTypes(resources []resource.Resource) []string { + types := make([]string, 0) + + for _, res := range resources { + found := false + for _, v := range types { + if v == res.TerraformType() { + found = true + break + } + } + if !found { + types = append(types, res.TerraformType()) + } + } + + return types +} + +func htmlPrettify(resource interface{}) string { + res := reflect.ValueOf(resource) + if resource == nil || res.Kind() == reflect.Ptr && res.IsNil() { + return "null" + } + return awsutil.Prettify(resource) +} diff --git a/pkg/cmd/scan/output/html_test.go b/pkg/cmd/scan/output/html_test.go new file mode 100644 index 00000000..f42a227e --- /dev/null +++ b/pkg/cmd/scan/output/html_test.go @@ -0,0 +1,234 @@ +package output + +import ( + "io/ioutil" + "path" + "testing" + "time" + + "github.com/cloudskiff/driftctl/pkg/resource" + testresource "github.com/cloudskiff/driftctl/test/resource" + "github.com/r3labs/diff/v2" + "github.com/stretchr/testify/assert" + + "github.com/cloudskiff/driftctl/pkg/analyser" + "github.com/cloudskiff/driftctl/test/goldenfile" +) + +func TestHTML_Write(t *testing.T) { + tests := []struct { + name string + goldenfile string + analysis func() *analyser.Analysis + err error + }{ + { + name: "test html output when there's no resources", + goldenfile: "output_empty.html", + analysis: func() *analyser.Analysis { + a := &analyser.Analysis{} + a.Date = time.Date(2021, 06, 10, 0, 0, 0, 0, &time.Location{}) + return a + }, + err: nil, + }, + { + name: "test html output when infrastructure is in sync", + goldenfile: "output_sync.html", + analysis: func() *analyser.Analysis { + a := &analyser.Analysis{} + a.Date = time.Date(2021, 06, 10, 0, 0, 0, 0, &time.Location{}) + a.Duration = 72 * time.Second + a.AddManaged( + &testresource.FakeResource{ + Id: "deleted-id-3", + Type: "aws_deleted_resource", + }, + ) + return a + }, + err: nil, + }, + { + name: "test html output", + goldenfile: "output.html", + + analysis: func() *analyser.Analysis { + a := fakeAnalysisWithAlerts() + a.Date = time.Date(2021, 06, 10, 0, 0, 0, 0, &time.Location{}) + a.Duration = 91 * time.Second + a.AddDeleted( + &testresource.FakeResource{ + Id: "deleted-id-3", + Type: "aws_deleted_resource", + }, + &testresource.FakeResource{ + Id: "deleted-id-4", + Type: "aws_deleted_resource", + }, + &testresource.FakeResource{ + Id: "deleted-id-5", + Type: "aws_deleted_resource", + }, + &testresource.FakeResource{ + Id: "deleted-id-6", + Type: "aws_deleted_resource", + }, + ) + a.AddUnmanaged( + &testresource.FakeResource{ + Id: "unmanaged-id-3", + Type: "aws_unmanaged_resource", + }, + &testresource.FakeResource{ + Id: "unmanaged-id-4", + Type: "aws_unmanaged_resource", + }, + &testresource.FakeResource{ + Id: "unmanaged-id-5", + Type: "aws_unmanaged_resource", + }, + ) + a.AddDifference(analyser.Difference{Res: &testresource.FakeResource{ + Id: "diff-id-2", + Type: "aws_diff_resource", + }, Changelog: []analyser.Change{ + { + Change: diff.Change{ + Type: diff.DELETE, + Path: []string{"path", "to", "fields", "0"}, + From: "value", + To: nil, + }, + }, + { + Change: diff.Change{ + Type: diff.UPDATE, + Path: []string{"path", "to", "fields", "1"}, + From: 12, + To: "12", + }, + }, + { + Change: diff.Change{ + Type: diff.DELETE, + Path: []string{"group_ids"}, + From: []string{"a071314398026"}, + To: nil, + }, + }, + { + Change: diff.Change{ + Type: diff.UPDATE, + Path: []string{"Policies", "0"}, + From: testresource.FakeResource{}, + To: testresource.FakeResource{Id: "093cd6ba-cf6d-4800-b252-6a50ca8903cd", Type: "aws_iam_policy"}, + }, + }, + { + Change: diff.Change{ + Type: diff.CREATE, + Path: []string{"Tags", "0", "Name"}, + From: nil, + To: "test", + }, + }, + { + Change: diff.Change{ + Type: diff.UPDATE, + Path: []string{"InstanceInitiatedShutdownBehavior"}, + From: "", + To: nil, + }, + }, + }}) + + return a + }, + err: nil, + }, + } + 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 := NewHTML(tempFile.Name()) + + err = c.Write(tt.analysis()) + if tt.err != nil { + assert.EqualError(t, err, tt.err.Error()) + } else { + assert.NoError(t, err) + } + + got, 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, got, 0600); err != nil { + t.Fatal(err) + } + } + + expected, err := ioutil.ReadFile(expectedFilePath) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, string(expected), string(got)) + }) + } +} + +func TestHTML_DistinctResourceTypes(t *testing.T) { + tests := []struct { + name string + resources []resource.Resource + value []string + }{ + { + name: "test empty array", + resources: []resource.Resource{}, + value: []string{}, + }, + { + name: "test empty array", + resources: []resource.Resource{ + &testresource.FakeResource{ + Id: "deleted-id-1", + Type: "aws_deleted_resource", + }, + &testresource.FakeResource{ + Id: "unmanaged-id-1", + Type: "aws_unmanaged_resource", + }, + &testresource.FakeResource{ + Id: "unmanaged-id-2", + Type: "aws_unmanaged_resource", + }, + &testresource.FakeResource{ + Id: "diff-id-1", + Type: "aws_diff_resource", + }, + &testresource.FakeResource{ + Id: "deleted-id-2", + Type: "aws_deleted_resource", + }, + }, + value: []string{"aws_deleted_resource", "aws_unmanaged_resource", "aws_diff_resource"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := distinctResourceTypes(tt.resources) + assert.Equal(t, tt.value, got) + }) + } +} diff --git a/pkg/cmd/scan/output/output.go b/pkg/cmd/scan/output/output.go index 1d960f8f..dd44b30f 100644 --- a/pkg/cmd/scan/output/output.go +++ b/pkg/cmd/scan/output/output.go @@ -14,11 +14,13 @@ type Output interface { var supportedOutputTypes = []string{ ConsoleOutputType, JSONOutputType, + HTMLOutputType, } var supportedOutputExample = map[string]string{ ConsoleOutputType: ConsoleOutputExample, JSONOutputType: JSONOutputExample, + HTMLOutputType: HTMLOutputExample, } func SupportedOutputs() []string { @@ -53,6 +55,8 @@ func GetOutput(config OutputConfig, quiet bool) Output { switch config.Key { case JSONOutputType: return NewJSON(config.Options["path"]) + case HTMLOutputType: + return NewHTML(config.Options["path"]) case ConsoleOutputType: fallthrough default: diff --git a/pkg/cmd/scan/output/output_test.go b/pkg/cmd/scan/output/output_test.go index 74f2e35b..5ac690a2 100644 --- a/pkg/cmd/scan/output/output_test.go +++ b/pkg/cmd/scan/output/output_test.go @@ -79,6 +79,18 @@ func fakeAnalysis() *analyser.Analysis { return &a } +func fakeAnalysisWithAlerts() *analyser.Analysis { + a := fakeAnalysis() + a.SetAlerts(alerter.Alerts{ + "": []alerter.Alert{ + remote.NewEnumerationAccessDeniedAlert(aws.RemoteAWSTerraform, "aws_vpc", "aws_vpc"), + remote.NewEnumerationAccessDeniedAlert(aws.RemoteAWSTerraform, "aws_sqs", "aws_sqs"), + remote.NewEnumerationAccessDeniedAlert(aws.RemoteAWSTerraform, "aws_sns", "aws_sns"), + }, + }) + return a +} + func fakeAnalysisNoDrift() *analyser.Analysis { a := analyser.Analysis{} for i := 0; i < 5; i++ { diff --git a/pkg/cmd/scan/output/testdata/output.html b/pkg/cmd/scan/output/testdata/output.html new file mode 100644 index 00000000..c9c707f3 --- /dev/null +++ b/pkg/cmd/scan/output/testdata/output.html @@ -0,0 +1,775 @@ + + + + driftctl Scan Report + + + + + + +
+
+ driftctl logo +
+

Scan Report

+

Jun 10, 2021

+

Scan Duration: 1m31s

+
+
+
+
+ Total Resources: + 13 +
+
+ Coverage: + 15% +
+
+ Managed: + 15% + 2/13 +
+
+ Unmanaged: + 38% + 5/13 +
+
+ Missing: + 46% + 6/13 +
+
+
+ +
+ + + + + +
+ +
+
+ + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Resource IDResource Type
unmanaged-id-1aws_unmanaged_resource
unmanaged-id-2aws_unmanaged_resource
unmanaged-id-3aws_unmanaged_resource
unmanaged-id-4aws_unmanaged_resource
unmanaged-id-5aws_unmanaged_resource
+ +
+ + + + + + + + + + +
+
+ +
+
+ + + + diff --git a/pkg/cmd/scan/output/testdata/output_empty.html b/pkg/cmd/scan/output/testdata/output_empty.html new file mode 100644 index 00000000..e13b1cc9 --- /dev/null +++ b/pkg/cmd/scan/output/testdata/output_empty.html @@ -0,0 +1,592 @@ + + + + driftctl Scan Report + + + + + + +
+
+ driftctl logo +
+

Scan Report

+

Jun 10, 2021

+

Scan Duration: 0s

+
+
+
+
+ Total Resources: + 0 +
+
+ Coverage: + 0% +
+
+ Managed: + 0% + 0/0 +
+
+ Unmanaged: + 0% + 0/0 +
+
+ Missing: + 0% + 0/0 +
+
+
+ +
+ + + + + +
+ +
+
+ + + + +
+
+ + + + +
+
+ +
+
+ + + + diff --git a/pkg/cmd/scan/output/testdata/output_sync.html b/pkg/cmd/scan/output/testdata/output_sync.html new file mode 100644 index 00000000..19a12408 --- /dev/null +++ b/pkg/cmd/scan/output/testdata/output_sync.html @@ -0,0 +1,568 @@ + + + + driftctl Scan Report + + + + + + +
+
+ driftctl logo +
+

Scan Report

+

Jun 10, 2021

+

Scan Duration: 1m12s

+
+
+
+
+ Total Resources: + 1 +
+
+ Coverage: + 100% +
+
+ Managed: + 100% + 1/1 +
+
+ Unmanaged: + 0% + 0/1 +
+
+ Missing: + 0% + 0/1 +
+
+
+ +

Congrats! Your infrastructure is in sync

+ +
+
+ + + + diff --git a/pkg/cmd/scan_test.go b/pkg/cmd/scan_test.go index a961b4e5..591f2a85 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://,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"), }, { 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://,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"), }, { 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://,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"), }, { 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://,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"), }, { name: "test empty json", diff --git a/pkg/driftctl.go b/pkg/driftctl.go index 44a05284..7c688c20 100644 --- a/pkg/driftctl.go +++ b/pkg/driftctl.go @@ -128,12 +128,13 @@ func (d DriftCTL) Run() (*analyser.Analysis, error) { driftIgnore := filter.NewDriftIgnore(d.opts.DriftignorePath) analysis, err := d.analyzer.Analyze(remoteResources, resourcesFromState, driftIgnore) - analysis.Duration = time.Since(start) - if err != nil { return nil, err } + analysis.Duration = time.Since(start) + analysis.Date = time.Now() + return &analysis, nil }