From aed04a14f2eb8ef151aac989f223bfe3dd1ef66c Mon Sep 17 00:00:00 2001 From: Adnan Gulegulzar <32373065+ADorigi@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:27:14 -0400 Subject: [PATCH] Add `grype db providers` command (#2174) * feat: add `grype db providers` command - currently reads content of `provider-metadata.json` file - added flag `-o`/`--output` flags which accept `json` and `table` - update method `getDBProviders()` and type `dbProviderMetadata` for db schema `v6` Signed-off-by: Adnan Gulegulzar * chore: update readme for `grype db providers` Signed-off-by: Adnan Gulegulzar * chore: update lint Signed-off-by: Adnan Gulegulzar * chore: add cli test for `grype db providers` Signed-off-by: Adnan Gulegulzar * fix: review changes - updated table as the default output format - updated tablewriter settings - added unit test for the components of db providers command - added dummy "provider-metadata.json" to aid unit tests - added table and json assertion to cli test Signed-off-by: Adnan Gulegulzar * chore: removes changes to `db diff`, `db serach` and `db list` commands Signed-off-by: Adnan Gulegulzar * chore: remove unused constants Signed-off-by: Adnan Gulegulzar * chore: move constants to scope where used Signed-off-by: Will Murphy --------- Signed-off-by: Adnan Gulegulzar Signed-off-by: Will Murphy Co-authored-by: Will Murphy --- README.md | 2 + cmd/grype/cli/commands/db.go | 1 + cmd/grype/cli/commands/db_providers.go | 155 ++++++++++++++++++ cmd/grype/cli/commands/db_providers_test.go | 151 +++++++++++++++++ .../test-fixtures/provider-metadata.json | 12 ++ test/cli/db_providers_test.go | 65 ++++++++ test/cli/trait_assertions_test.go | 17 ++ 7 files changed, 403 insertions(+) create mode 100644 cmd/grype/cli/commands/db_providers.go create mode 100644 cmd/grype/cli/commands/db_providers_test.go create mode 100644 cmd/grype/cli/commands/test-fixtures/provider-metadata.json create mode 100644 test/cli/db_providers_test.go diff --git a/README.md b/README.md index 9a37eb2cd3a..3752941a989 100644 --- a/README.md +++ b/README.md @@ -567,6 +567,8 @@ Grype provides database-specific CLI commands for users that want to control the `grype db import` — provide grype with a database archive to explicitly use (useful for offline DB updates) +`grype db providers` - provides a detailed list of database providers + Find complete information on Grype's database commands by running `grype db --help`. ## Shell completion diff --git a/cmd/grype/cli/commands/db.go b/cmd/grype/cli/commands/db.go index ce1ed09ba74..41a81b2cd5e 100644 --- a/cmd/grype/cli/commands/db.go +++ b/cmd/grype/cli/commands/db.go @@ -35,6 +35,7 @@ func DB(app clio.Application) *cobra.Command { DBStatus(app), DBUpdate(app), DBSearch(app), + DBProviders(app), ) return db diff --git a/cmd/grype/cli/commands/db_providers.go b/cmd/grype/cli/commands/db_providers.go new file mode 100644 index 00000000000..d25de084582 --- /dev/null +++ b/cmd/grype/cli/commands/db_providers.go @@ -0,0 +1,155 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/anchore/clio" + "github.com/anchore/grype/grype/db/legacy/distribution" + "github.com/anchore/grype/internal/bus" +) + +const metadataFileName = "provider-metadata.json" +const jsonOutputFormat = "json" +const tableOutputFormat = "table" + +type dbProviderMetadata struct { + Name string `json:"name"` + LastSuccessfulRun string `json:"lastSuccessfulRun"` +} + +type dbProviders struct { + Providers []dbProviderMetadata `json:"providers"` +} + +type dbProvidersOptions struct { + Output string `yaml:"output" json:"output"` +} + +var _ clio.FlagAdder = (*dbProvidersOptions)(nil) + +func (d *dbProvidersOptions) AddFlags(flags clio.FlagSet) { + flags.StringVarP(&d.Output, "output", "o", "format to display results (available=[table, json])") +} + +func DBProviders(app clio.Application) *cobra.Command { + opts := &dbProvidersOptions{ + Output: tableOutputFormat, + } + + return app.SetupCommand(&cobra.Command{ + Use: "providers", + Short: "list vulnerability database providers", + Args: cobra.ExactArgs(0), + RunE: func(_ *cobra.Command, _ []string) error { + return runDBProviders(opts, app) + }, + }, opts) +} + +func runDBProviders(opts *dbProvidersOptions, app clio.Application) error { + metadataFileLocation, err := getMetadataFileLocation(app) + if err != nil { + return nil + } + providers, err := getDBProviders(*metadataFileLocation) + if err != nil { + return err + } + + sb := &strings.Builder{} + + switch opts.Output { + case tableOutputFormat: + displayDBProvidersTable(providers.Providers, sb) + case jsonOutputFormat: + err = displayDBProvidersJSON(providers, sb) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported output format: %s", opts.Output) + } + bus.Report(sb.String()) + + return nil +} + +func getMetadataFileLocation(app clio.Application) (*string, error) { + dbCurator, err := distribution.NewCurator(dbOptionsDefault(app.ID()).DB.ToCuratorConfig()) + if err != nil { + return nil, err + } + + location := dbCurator.Status().Location + + return &location, nil +} + +func getDBProviders(metadataFileLocation string) (*dbProviders, error) { + metadataFile := path.Join(metadataFileLocation, metadataFileName) + + file, err := os.Open(metadataFile) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("file not found: %w", err) + } + return nil, fmt.Errorf("error opening file: %w", err) + } + defer file.Close() + + var providers dbProviders + fileBytes, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + err = json.Unmarshal(fileBytes, &providers) + if err != nil { + return nil, fmt.Errorf("cannot unmarshal providers: %w", err) + } + + return &providers, nil +} + +func displayDBProvidersTable(providers []dbProviderMetadata, output io.Writer) { + rows := [][]string{} + for _, provider := range providers { + rows = append(rows, []string{provider.Name, provider.LastSuccessfulRun}) + } + + table := tablewriter.NewWriter(output) + table.SetHeader([]string{"Name", "Last Successful Run"}) + + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetTablePadding(" ") + table.SetNoWhiteSpace(true) + + table.AppendBulk(rows) + table.Render() +} + +func displayDBProvidersJSON(providers *dbProviders, output io.Writer) error { + encoder := json.NewEncoder(output) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + err := encoder.Encode(providers) + if err != nil { + return fmt.Errorf("cannot display json: %w", err) + } + return nil +} diff --git a/cmd/grype/cli/commands/db_providers_test.go b/cmd/grype/cli/commands/db_providers_test.go new file mode 100644 index 00000000000..734ff127f93 --- /dev/null +++ b/cmd/grype/cli/commands/db_providers_test.go @@ -0,0 +1,151 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "reflect" + "testing" +) + +func TestGetDBProviders(t *testing.T) { + + tests := []struct { + name string + fileLocation string + expectedProviders dbProviders + expectedError error + }{ + { + name: "test provider metadata file", + fileLocation: "./test-fixtures", + expectedProviders: dbProviders{ + Providers: []dbProviderMetadata{ + { + Name: "provider1", + LastSuccessfulRun: "2024-10-16T01:33:16.844201Z", + }, + { + Name: "provider2", + LastSuccessfulRun: "2024-10-16T01:32:43.516596Z", + }, + }, + }, + expectedError: nil, + }, + { + name: "no metadata file found", + fileLocation: "./", + expectedProviders: dbProviders{}, + expectedError: os.ErrNotExist, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + providers, err := getDBProviders(test.fileLocation) + if err != nil { + if errors.Is(err, test.expectedError) { + return + } + t.Errorf("getDBProviders() expected list of providers, got error: %v", err) + return + } + if !reflect.DeepEqual(*providers, test.expectedProviders) { + t.Error("getDBProviders() providers comparison failed, got error") + } + }) + } + +} + +func TestDisplayDBProvidersTable(t *testing.T) { + tests := []struct { + name string + providers dbProviders + expectedOutput string + }{ + { + name: "display providers table", + providers: dbProviders{ + Providers: []dbProviderMetadata{ + { + Name: "provider1", + LastSuccessfulRun: "2024-10-16T01:33:16.844201Z", + }, + { + Name: "provider2", + LastSuccessfulRun: "2024-10-16T01:32:43.516596Z", + }, + }, + }, + expectedOutput: "NAME LAST SUCCESSFUL RUN \nprovider1 2024-10-16T01:33:16.844201Z \nprovider2 2024-10-16T01:32:43.516596Z \n", + }, + { + name: "empty list of providers", + providers: dbProviders{ + Providers: []dbProviderMetadata{}, + }, + expectedOutput: "NAME LAST SUCCESSFUL RUN \n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + var out bytes.Buffer + displayDBProvidersTable(test.providers.Providers, &out) + outputString := out.String() + if outputString != test.expectedOutput { + t.Errorf("displayDBProvidersTable() = %v, want %v", out.String(), test.expectedOutput) + } + }) + } +} + +func TestDisplayDBProvidersJSON(t *testing.T) { + tests := []struct { + name string + providers dbProviders + }{ + + { + name: "display providers table", + providers: dbProviders{ + Providers: []dbProviderMetadata{ + { + Name: "provider1", + LastSuccessfulRun: "2024-10-16T01:33:16.844201Z", + }, + { + Name: "provider2", + LastSuccessfulRun: "2024-10-16T01:32:43.516596Z", + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + var out bytes.Buffer + err := displayDBProvidersJSON(&test.providers, &out) + if err != nil { + t.Error(err) + } + var providers dbProviders + + err = json.Unmarshal(out.Bytes(), &providers) + if err != nil { + t.Error(err) + } + + if !reflect.DeepEqual(providers, test.providers) { + t.Error("DBProvidersJSON() providers comparison failed, got error") + } + + }) + } +} diff --git a/cmd/grype/cli/commands/test-fixtures/provider-metadata.json b/cmd/grype/cli/commands/test-fixtures/provider-metadata.json new file mode 100644 index 00000000000..015f6914e41 --- /dev/null +++ b/cmd/grype/cli/commands/test-fixtures/provider-metadata.json @@ -0,0 +1,12 @@ +{ + "providers": [ + { + "name": "provider1", + "lastSuccessfulRun": "2024-10-16T01:33:16.844201Z" + }, + { + "name": "provider2", + "lastSuccessfulRun": "2024-10-16T01:32:43.516596Z" + } + ] +} \ No newline at end of file diff --git a/test/cli/db_providers_test.go b/test/cli/db_providers_test.go new file mode 100644 index 00000000000..3437aa8860a --- /dev/null +++ b/test/cli/db_providers_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestDBProviders(t *testing.T) { + tests := []struct { + name string + args []string + env map[string]string + assertions []traitAssertion + }{ + { + name: "db providers command", + args: []string{"db", "providers"}, + assertions: []traitAssertion{ + assertInOutput("LAST SUCCESSFUL RUN"), + assertNoStderr, + assertTableReport, + }, + }, + { + name: "db providers command help", + args: []string{"db", "providers", "-h"}, + assertions: []traitAssertion{ + assertInOutput("list vulnerability database providers"), + assertNoStderr, + }, + }, + { + name: "db providers command with table output flag", + args: []string{"db", "providers", "-o", "table"}, + assertions: []traitAssertion{ + assertInOutput("LAST SUCCESSFUL RUN"), + assertNoStderr, + assertTableReport, + }, + }, + { + name: "db providers command with json output flag", + args: []string{"db", "providers", "-o", "json"}, + assertions: []traitAssertion{ + assertInOutput("providers"), + assertNoStderr, + assertJsonReport, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd, stdout, stderr := runGrype(t, test.env, test.args...) + for _, traitAssertionFn := range test.assertions { + traitAssertionFn(t, stdout, stderr, cmd.ProcessState.ExitCode()) + } + if t.Failed() { + t.Log("STDOUT:\n", stdout) + t.Log("STDERR:\n", stderr) + t.Log("COMMAND:", strings.Join(cmd.Args, " ")) + } + }) + } +} diff --git a/test/cli/trait_assertions_test.go b/test/cli/trait_assertions_test.go index 8aa14b09850..11e30f18ac6 100644 --- a/test/cli/trait_assertions_test.go +++ b/test/cli/trait_assertions_test.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "strings" "testing" @@ -70,3 +71,19 @@ func assertNotInOutput(notWanted string) traitAssertion { } } } + +func assertJsonReport(tb testing.TB, stdout, _ string, _ int) { + tb.Helper() + var data interface{} + + if err := json.Unmarshal([]byte(stdout), &data); err != nil { + tb.Errorf("expected to find a JSON report, but was unmarshalable: %+v", err) + } +} + +func assertTableReport(tb testing.TB, stdout, _ string, _ int) { + tb.Helper() + if !strings.Contains(stdout, "NAME") || !strings.Contains(stdout, "LAST SUCCESSFUL RUN") { + tb.Errorf("expected to find a table report, but did not") + } +}