diff --git a/Taskfile.yaml b/Taskfile.yaml index a31d1b0..7ee936c 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -125,7 +125,7 @@ tasks: sh: "go list ./... | grep -v {{ .OWNER }}/{{ .PROJECT }}/test | tr '\n' ' '" # unit test coverage threshold (in % coverage) - COVERAGE_THRESHOLD: 50 + COVERAGE_THRESHOLD: 45 cmds: - cmd: "mkdir -p {{ .TMP_DIR }}" silent: true diff --git a/cmd/binny/cli/command/__snapshots__/list_test.snap b/cmd/binny/cli/command/__snapshots__/list_test.snap new file mode 100755 index 0000000..5abf5db --- /dev/null +++ b/cmd/binny/cli/command/__snapshots__/list_test.snap @@ -0,0 +1,370 @@ + +[Test_renderListTable/empty - 1] +no tools configured or installed +--- + +[Test_renderListTable/has_update - 1] + TOOL DESIRED VERSION CONSTRAINT +────────────────────────────────────────────────────────────────────────────────────────────────────────── + syft latest (v1.0.0) <= v1.0.0 installed version (v0.105.1) does not match resolved version (v1.0.0) +--- + +[Test_renderListTable/invalid_hash - 1] + TOOL DESIRED VERSION CONSTRAINT +──────────────────────────────────────────────────── + syft v0.105.1 <= v1.0.0 hash is invalid +--- + +[Test_renderListTable/error - 1] + TOOL DESIRED VERSION CONSTRAINT +─────────────────────────────────────────────────────── + syft latest (v1.0.0) <= v1.0.0 something is wrong +--- + +[Test_renderListTable/unknown_wanted_version - 1] + TOOL DESIRED VERSION CONSTRAINT +─────────────────────────────────────────────────────────── + syft ? (v1.0.1) <= v1.0.1 tool is not configured +--- + +[Test_renderListTable/not_installed - 1] + TOOL DESIRED VERSION CONSTRAINT +────────────────────────────────────────────────── + syft latest (v1.0.0) <= v1.0.0 not installed +--- + +[Test_renderListTable/no_update - 1] + TOOL DESIRED VERSION CONSTRAINT +───────────────────────────────────── + syft latest (v1.0.0) <= v1.0.0 +--- + +[Test_renderListTable/sort_by_name - 1] + TOOL DESIRED VERSION CONSTRAINT +─────────────────────────────────────────────────────────────────────────────────────────────────────────── + grype v0.74.0 <= v1.0.0 installed version (v0.53.0) does not match resolved version (v0.74.0) + syft latest (v1.0.0) <= v1.0.0 installed version (v0.105.1) does not match resolved version (v1.0.0) +--- + +[Test_renderListUpdatesTable/empty - 1] +no tools to check +--- + +[Test_renderListUpdatesTable/has_update - 1] + TOOL UPDATE +───────────────────────── + syft v0.105.1 → v1.0.0 +--- + +[Test_renderListUpdatesTable/invalid_hash - 1] +all tools up to date +--- + +[Test_renderListUpdatesTable/error - 1] + TOOL UPDATE +────────────────────────── + syft something is wrong +--- + +[Test_renderListUpdatesTable/unknown_wanted_version - 1] +all tools up to date +--- + +[Test_renderListUpdatesTable/not_installed - 1] + TOOL UPDATE +───────────────────── + syft not installed +--- + +[Test_renderListUpdatesTable/no_update - 1] +all tools up to date +--- + +[Test_renderListUpdatesTable/sort_by_name - 1] + TOOL UPDATE +────────────────────────── + grype v0.53.0 → v0.74.0 + syft v0.105.1 → v1.0.0 +--- + +[Test_renderListJSON/updates/empty - 1] +{ + "tools": [] +} + +--- + +[Test_renderListJSON/updates/has_update - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "v0.105.1", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/updates/invalid_hash - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "v0.105.1", + "resolvedVersion": "v0.105.1", + "installedVersion": "v0.105.1", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": false + } + ] +} + +--- + +[Test_renderListJSON/updates/error - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "", + "installedVersion": "", + "constraint": "", + "isInstalled": true, + "hashIsValid": false, + "error": {} + } + ] +} + +--- + +[Test_renderListJSON/updates/unknown_wanted_version - 1] +{ + "tools": [] +} + +--- + +[Test_renderListJSON/updates/not_installed - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "", + "constraint": "<= v1.0.0", + "isInstalled": false, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/updates/no_update - 1] +{ + "tools": [] +} + +--- + +[Test_renderListJSON/updates/sort_by_name - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "v0.105.1", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + }, + { + "name": "grype", + "wantVersion": "v0.74.0", + "resolvedVersion": "v0.74.0", + "installedVersion": "v0.53.0", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/no_updates/empty - 1] +{ + "tools": [] +} + +--- + +[Test_renderListJSON/no_updates/has_update - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "v0.105.1", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/no_updates/invalid_hash - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "v0.105.1", + "resolvedVersion": "v0.105.1", + "installedVersion": "v0.105.1", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": false + } + ] +} + +--- + +[Test_renderListJSON/no_updates/error - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "v1.0.0", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": false, + "error": {} + } + ] +} + +--- + +[Test_renderListJSON/no_updates/unknown_wanted_version - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "?", + "resolvedVersion": "v1.0.1", + "installedVersion": "v1.0.0", + "constraint": "<= v1.0.1", + "isInstalled": true, + "hashIsValid": false + } + ] +} + +--- + +[Test_renderListJSON/no_updates/not_installed - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "", + "constraint": "<= v1.0.0", + "isInstalled": false, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/no_updates/no_update - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "v1.0.0", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/no_updates/sort_by_name - 1] +{ + "tools": [ + { + "name": "syft", + "wantVersion": "latest", + "resolvedVersion": "v1.0.0", + "installedVersion": "v0.105.1", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + }, + { + "name": "grype", + "wantVersion": "v0.74.0", + "resolvedVersion": "v0.74.0", + "installedVersion": "v0.53.0", + "constraint": "<= v1.0.0", + "isInstalled": true, + "hashIsValid": true + } + ] +} + +--- + +[Test_renderListJSON/jq/show_keys - 1] +[ + "tools" +] + +--- + +[Test_renderListJSON/jq/bad_jq_expression - 1] + +--- + +[Test_renderListJSON/jq/filter_by_name - 1] +{ + "constraint": "<= v1.0.0", + "hashIsValid": true, + "installedVersion": "v0.105.1", + "isInstalled": true, + "name": "syft", + "resolvedVersion": "v1.0.0", + "wantVersion": "latest" +} + +--- + +[Test_renderListJSON/jq/raw_scalar_values - 1] +latest +v0.74.0 + +--- diff --git a/cmd/binny/cli/command/list.go b/cmd/binny/cli/command/list.go index 2409d5b..aef791a 100644 --- a/cmd/binny/cli/command/list.go +++ b/cmd/binny/cli/command/list.go @@ -1,10 +1,14 @@ package command import ( + "bytes" + "encoding/json" "fmt" "sort" + "strings" "github.com/charmbracelet/lipgloss" + "github.com/itchyny/gojq" "github.com/jedib0t/go-pretty/table" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -17,15 +21,20 @@ import ( ) type ListConfig struct { - Config string `json:"config" yaml:"config" mapstructure:"config"` - option.Check `json:"" yaml:",inline" mapstructure:",squash"` - option.Core `json:"" yaml:",inline" mapstructure:",squash"` - option.List `json:"" yaml:",inline" mapstructure:",squash"` + Config string `json:"config" yaml:"config" mapstructure:"config"` + option.Check `json:"" yaml:",inline" mapstructure:",squash"` + option.Core `json:"" yaml:",inline" mapstructure:",squash"` + option.List `json:"" yaml:",inline" mapstructure:",squash"` + option.Format `json:"" yaml:",inline" mapstructure:",squash"` } func List(app clio.Application) *cobra.Command { cfg := &ListConfig{ Core: option.DefaultCore(), + Format: option.Format{ + Output: "table", + AllowableFormats: []string{"table", "json"}, + }, } return app.SetupCommand(&cobra.Command{ @@ -34,8 +43,12 @@ func List(app clio.Application) *cobra.Command { Aliases: []string{ "ls", }, - Args: cobra.NoArgs, + Args: cobra.ArbitraryArgs, PreRunE: func(cmd *cobra.Command, args []string) error { + if cfg.Format.JQCommand != "" && cfg.Format.Output != "json" { + return fmt.Errorf("--jq can only be used when --output format is 'json'") + } + cfg.IncludeFilter = args return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -46,12 +59,12 @@ func List(app clio.Application) *cobra.Command { type toolStatus struct { Name string `json:"name"` - WantedVersion string `json:"wantedVersion"` // this is the version the user asked for + WantVersion string `json:"wantVersion"` // this is the version the user asked for ResolvedVersion string `json:"resolvedVersion"` // if the user asks for a non-specific version (e.g. "latest") then this is what that would resolve to at this point in time + InstalledVersion string `json:"installedVersion"` // the actual version that is installed, which could vary from the user wanted or resolved values Constraint string `json:"constraint"` // the version constraint the user asked for and used during version resolution IsInstalled bool `json:"isInstalled"` // is the tool installed at the desired version (says nothing about it being valid, only present) HashIsValid bool `json:"hashIsValid"` // is the installed tool have the correct xxh64 hash? - InstalledVersion string `json:"installedVersion"` // the actual version that is installed, which could vary from the user wanted or resolved values Error error `json:"error,omitempty"` // if there was an error getting the status for this tool, it will be here } @@ -67,11 +80,70 @@ func runList(cmdCfg ListConfig) error { // look for items in the store root that cannot be accounted for // TODO + statuses := filterStatus(allStatuses, cmdCfg.List.IncludeFilter) + + if cmdCfg.Format.Output == "json" { + return reportOnBus(renderListJSON(statuses, cmdCfg.List.Updates, cmdCfg.Format.JQCommand)) + } + if cmdCfg.List.Updates { - return presentUpdates(allStatuses) + return reportOnBus(renderListUpdatesTable(statuses), nil) + } + + return reportOnBus(renderListTable(statuses), nil) +} + +func reportOnBus(value string, err error) error { + if err != nil { + return err + } + bus.Report(value) + return nil +} + +func filterStatus(status []toolStatus, includeFilter []string) []toolStatus { + if len(includeFilter) == 0 { + return status } - return presentList(allStatuses) + filtered := make([]toolStatus, 0) + for _, s := range status { + for _, f := range includeFilter { + if s.Name == f { + filtered = append(filtered, s) + } + } + } + + return filtered +} + +func filterToolsWithoutUpdates(statuses []toolStatus) []toolStatus { + var updates []toolStatus + for _, status := range statuses { + if status.Error != nil { + status.InstalledVersion = "" + status.ResolvedVersion = "" + status.Constraint = "" + status.HashIsValid = false + updates = append(updates, status) + continue + } + + if status.WantVersion == "?" { + continue + } + + if status.InstalledVersion != status.ResolvedVersion { + updates = append(updates, status) + continue + } + + if !status.HashIsValid { + updates = append(updates, status) + } + } + return updates } func getAllStatuses(cmdCfg ListConfig, store *binny.Store) []toolStatus { @@ -110,7 +182,7 @@ func getAllStatuses(cmdCfg ListConfig, store *binny.Store) []toolStatus { } allStatus = append(allStatus, toolStatus{ Name: entry.Name, - WantedVersion: "?", + WantVersion: "?", ResolvedVersion: "", Constraint: "", IsInstalled: true, @@ -127,9 +199,9 @@ func getAllStatuses(cmdCfg ListConfig, store *binny.Store) []toolStatus { wantVersion = opt.Version.Want } allStatus = append(allStatus, toolStatus{ - Name: name, - WantedVersion: wantVersion, - Error: err, + Name: name, + WantVersion: wantVersion, + Error: err, }) } @@ -170,7 +242,7 @@ func getStatus(store *binny.Store, opt option.Tool) (*toolStatus, *binny.StoreEn return &toolStatus{ Name: opt.Name, - WantedVersion: opt.Version.Want, + WantVersion: opt.Version.Want, ResolvedVersion: resolvedVersion, Constraint: opt.Version.Constraint, IsInstalled: isInstalled, @@ -208,7 +280,69 @@ func removeEntry(entries []binny.StoreEntry, entry *binny.StoreEntry) []binny.St return entries } -func presentUpdates(statuses []toolStatus) error { +func renderListJSON(statuses []toolStatus, updatesOnly bool, jqCommand string) (string, error) { + if updatesOnly { + statuses = filterToolsWithoutUpdates(statuses) + } + + doc := make(map[string]any) + if statuses == nil { + // always allocate collections + statuses = make([]toolStatus, 0) + } + doc["tools"] = statuses + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(doc); err != nil { + return "", fmt.Errorf("unable to encode JSON: %w", err) + } + + if jqCommand != "" { + query, err := gojq.Parse(jqCommand) + if err != nil { + return "", fmt.Errorf("unable to parse JQ command: %w", err) + } + + decodeDoc := make(map[string]any) + if err := json.Unmarshal(buf.Bytes(), &decodeDoc); err != nil { + return "", fmt.Errorf("unable to decode JSON: %w", err) + } + + buf.Reset() + iter := query.Run(decodeDoc) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + return "", fmt.Errorf("error executing JQ command: %w", err) + } + + if err := enc.Encode(v); err != nil { + return "", fmt.Errorf("unable to encode JSON: %w", err) + } + } + } + + result := buf.String() + + // default to raw output for simple JSON output + if strings.HasPrefix(result, "\"") && strings.HasSuffix(result, "\"\n") { + result = strings.ReplaceAll(result, "\"", "") + } + + return result, nil +} + +func renderListUpdatesTable(statuses []toolStatus) string { + if len(statuses) == 0 { + return "no tools to check" + } + t := table.NewWriter() t.SetStyle(table.StyleLight) t.Style().Options.DrawBorder = false @@ -233,8 +367,7 @@ func presentUpdates(statuses []toolStatus) error { } if len(rows) == 0 { - bus.Report("all tools up to date") - return nil + return "all tools up to date" } sort.Slice(rows, func(i, j int) bool { @@ -246,8 +379,7 @@ func presentUpdates(statuses []toolStatus) error { t.AppendRow(row) } - bus.Report(t.Render()) - return nil + return t.Render() } func getToolUpdatesRow(item toolStatus) table.Row { @@ -264,10 +396,10 @@ func getToolUpdatesRow(item toolStatus) table.Row { commentary = "not installed" } else { switch { - case item.WantedVersion == "?": + case item.WantVersion == "?": commentary = "" case item.InstalledVersion != item.ResolvedVersion: - commentary = fmt.Sprintf("%s → %s", summarizeVersion(item.InstalledVersion), summarizeVersion(item.ResolvedVersion)) + commentary = fmt.Sprintf("%s → %s", summarizeGitVersion(item.InstalledVersion), summarizeGitVersion(item.ResolvedVersion)) case !item.HashIsValid: commentary = "" } @@ -286,10 +418,9 @@ func getToolUpdatesRow(item toolStatus) table.Row { return row } -func presentList(statuses []toolStatus) error { +func renderListTable(statuses []toolStatus) string { if len(statuses) == 0 { - bus.Report("no tools configured or installed") - return nil + return "no tools configured or installed" } t := table.NewWriter() t.SetStyle(table.StyleLight) @@ -341,8 +472,7 @@ func presentList(statuses []toolStatus) error { t.AppendRow(row) } - bus.Report(t.Render()) - return nil + return t.Render() } func getToolStatusRow(item toolStatus, constraintNeeded bool) table.Row { @@ -360,11 +490,11 @@ func getToolStatusRow(item toolStatus, constraintNeeded bool) table.Row { severity = 1 } else { switch { - case item.WantedVersion == "?": + case item.WantVersion == "?": commentary = "tool is not configured" severity = 2 case item.InstalledVersion != item.ResolvedVersion: - commentary = fmt.Sprintf("installed version (%s) does not match resolved version (%s)", summarizeVersion(item.InstalledVersion), summarizeVersion(item.ResolvedVersion)) + commentary = fmt.Sprintf("installed version (%s) does not match resolved version (%s)", summarizeGitVersion(item.InstalledVersion), summarizeGitVersion(item.ResolvedVersion)) severity = 1 case !item.HashIsValid: commentary = "hash is invalid" @@ -373,10 +503,10 @@ func getToolStatusRow(item toolStatus, constraintNeeded bool) table.Row { } } - version := item.WantedVersion + version := item.WantVersion - if item.WantedVersion != item.ResolvedVersion && item.ResolvedVersion != "" { - version += fmt.Sprintf(" (%s)", summarizeVersion(item.ResolvedVersion)) + if item.WantVersion != item.ResolvedVersion && item.ResolvedVersion != "" { + version += fmt.Sprintf(" (%s)", summarizeGitVersion(item.ResolvedVersion)) } style := toolStatusStyle(severity) @@ -395,15 +525,23 @@ func getToolStatusRow(item toolStatus, constraintNeeded bool) table.Row { return row } -func summarizeVersion(v string) string { - // TODO: there are probably better ways to do this +func summarizeGitVersion(v string) string { // if it looks like a git hash, then summarize it - if len(v) == 40 { + if len(v) == 40 && onlyAlphaNumeric(v) { return v[:7] } return v } +func onlyAlphaNumeric(v string) bool { + for _, c := range v { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + return false + } + } + return true +} + var ( goodStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // 10 = high intensity green (ANSI 16 bit color code) badStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // 214 = orange1 (ANSI 16 bit color code) diff --git a/cmd/binny/cli/command/list_test.go b/cmd/binny/cli/command/list_test.go new file mode 100644 index 0000000..69a31b0 --- /dev/null +++ b/cmd/binny/cli/command/list_test.go @@ -0,0 +1,282 @@ +package command + +import ( + "errors" + "testing" + + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type renderListTableTest struct { + name string + statuses []toolStatus +} + +func renderListTableTestCases() []renderListTableTest { + return []renderListTableTest{ + { + name: "empty", + statuses: []toolStatus{}, + }, + { + name: "has update", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "latest", + ResolvedVersion: "v1.0.0", + InstalledVersion: "v0.105.1", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: true, + Error: nil, + }, + }, + }, + { + name: "invalid hash", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "v0.105.1", + ResolvedVersion: "v0.105.1", + InstalledVersion: "v0.105.1", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: false, + Error: nil, + }, + }, + }, + { + name: "error", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "latest", + ResolvedVersion: "v1.0.0", + InstalledVersion: "v1.0.0", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: false, + Error: errors.New("something is wrong"), + }, + }, + }, + { + name: "unknown wanted version", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "?", + ResolvedVersion: "v1.0.1", + InstalledVersion: "v1.0.0", + Constraint: "<= v1.0.1", + IsInstalled: true, + HashIsValid: false, + Error: nil, + }, + }, + }, + { + name: "not installed", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "latest", + ResolvedVersion: "v1.0.0", + InstalledVersion: "", + Constraint: "<= v1.0.0", + IsInstalled: false, + HashIsValid: true, + Error: nil, + }, + }, + }, + { + name: "no update", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "latest", + ResolvedVersion: "v1.0.0", + InstalledVersion: "v1.0.0", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: true, + Error: nil, + }, + }, + }, + { + name: "sort by name", + statuses: []toolStatus{ + { + Name: "syft", + WantVersion: "latest", + ResolvedVersion: "v1.0.0", + InstalledVersion: "v0.105.1", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: true, + Error: nil, + }, + { + Name: "grype", + WantVersion: "v0.74.0", + ResolvedVersion: "v0.74.0", + InstalledVersion: "v0.53.0", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: true, + Error: nil, + }, + }, + }, + } +} + +func Test_renderListTable(t *testing.T) { + + for _, tt := range renderListTableTestCases() { + t.Run(tt.name, func(t *testing.T) { + got := renderListTable(tt.statuses) + snaps.MatchSnapshot(t, got) + }) + } +} + +func Test_renderListUpdatesTable(t *testing.T) { + + for _, tt := range renderListTableTestCases() { + t.Run(tt.name, func(t *testing.T) { + got := renderListUpdatesTable(tt.statuses) + snaps.MatchSnapshot(t, got) + }) + } +} + +func Test_renderListJSON(t *testing.T) { + + t.Run("updates", func(t *testing.T) { + for _, tt := range renderListTableTestCases() { + t.Run(tt.name, func(t *testing.T) { + got, err := renderListJSON(tt.statuses, true, "") + require.NoError(t, err) + snaps.MatchSnapshot(t, got) + }) + } + }) + + t.Run("no updates", func(t *testing.T) { + for _, tt := range renderListTableTestCases() { + t.Run(tt.name, func(t *testing.T) { + got, err := renderListJSON(tt.statuses, false, "") + require.NoError(t, err) + snaps.MatchSnapshot(t, got) + }) + } + }) + + t.Run("jq", func(t *testing.T) { + testStatuses := []toolStatus{ + { + Name: "syft", + WantVersion: "latest", + ResolvedVersion: "v1.0.0", + InstalledVersion: "v0.105.1", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: true, + Error: nil, + }, + { + Name: "grype", + WantVersion: "v0.74.0", + ResolvedVersion: "v0.74.0", + InstalledVersion: "v0.53.0", + Constraint: "<= v1.0.0", + IsInstalled: true, + HashIsValid: true, + Error: nil, + }, + } + + tests := []struct { + name string + statuses []toolStatus + jq string + wantErr require.ErrorAssertionFunc + }{ + { + name: "show keys", + statuses: testStatuses, + jq: "keys", + }, + { + name: "bad jq expression", + statuses: testStatuses, + jq: "shdjfkshf", + wantErr: require.Error, + }, + { + name: "filter by name", + statuses: testStatuses, + jq: ".tools[] | select(.name == \"syft\")", + }, + { + name: "raw scalar values", + statuses: testStatuses, + jq: ".tools[].wantVersion", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + got, err := renderListJSON(tt.statuses, false, tt.jq) + tt.wantErr(t, err) + snaps.MatchSnapshot(t, got) + }) + } + }) + +} + +func Test_summarizeVersion(t *testing.T) { + + tests := []struct { + name string + v string + want string + }{ + { + name: "empty", + v: "", + want: "", + }, + { + name: "semver", + v: "v1.0.0", + want: "v1.0.0", + }, + { + name: "commit-sha", + v: "250ca084c8ffd28fad8bf9d8725e2b0d5b8b11e2", + want: "250ca08", + }, + { + name: "40-char non-sha", + v: "SOMETHING-ffd28fad8bf9d8725e2b0d5b8b11e2", + want: "SOMETHING-ffd28fad8bf9d8725e2b0d5b8b11e2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, summarizeGitVersion(tt.v)) + }) + } +} diff --git a/cmd/binny/cli/option/format.go b/cmd/binny/cli/option/format.go new file mode 100644 index 0000000..02290e5 --- /dev/null +++ b/cmd/binny/cli/option/format.go @@ -0,0 +1,28 @@ +package option + +import ( + "fmt" + + "github.com/anchore/fangs" +) + +var _ fangs.FlagAdder = (*Format)(nil) + +type Format struct { + Output string `yaml:"output" json:"output" mapstructure:"output"` + AllowableFormats []string `yaml:"-" json:"-" mapstructure:"-"` + JQCommand string `yaml:"jqCommand" json:"jqCommand" mapstructure:"jqCommand"` +} + +func (o *Format) AddFlags(flags fangs.FlagSet) { + flags.StringVarP( + &o.Output, + "output", "o", + fmt.Sprintf("output format to report results in (allowable values: %s)", o.AllowableFormats), + ) + flags.StringVarP( + &o.JQCommand, + "jq", "", + "JQ command to apply to the JSON output", + ) +} diff --git a/cmd/binny/cli/option/list.go b/cmd/binny/cli/option/list.go index 3b1106d..3eb8260 100644 --- a/cmd/binny/cli/option/list.go +++ b/cmd/binny/cli/option/list.go @@ -3,7 +3,8 @@ package option import "github.com/anchore/clio" type List struct { - Updates bool `json:"updates" yaml:"updates" mapstructure:"updates"` + Updates bool `json:"updates" yaml:"updates" mapstructure:"updates"` + IncludeFilter []string `json:"includeFilter" yaml:"includeFilter" mapstructure:"includeFilter"` } func (o *List) AddFlags(flags clio.FlagSet) { diff --git a/go.mod b/go.mod index bdcec27..0f4dfb7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/OneOfOne/xxhash v1.2.8 github.com/anchore/bubbly v0.0.0-20230919123500-747f4abea05f github.com/anchore/clio v0.0.0-20230823172630-c42d666061af + github.com/anchore/fangs v0.0.0-20230818131516-2186b10924fe github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a github.com/chainguard-dev/yam v0.0.0-20230904174023-8d3c53b7e9d7 github.com/charmbracelet/bubbles v0.16.1 @@ -15,12 +16,13 @@ require ( github.com/charmbracelet/lipgloss v0.8.0 github.com/creack/pty v1.1.18 github.com/gabriel-vasile/mimetype v1.4.2 - github.com/gkampitakis/go-snaps v0.4.10 + github.com/gkampitakis/go-snaps v0.5.2 github.com/go-git/go-git/v5 v5.11.0 github.com/google/go-cmp v0.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/yamlfmt v0.9.1-0.20230607021126-908b19015fc4 github.com/hashicorp/go-multierror v1.1.1 + github.com/itchyny/gojq v0.12.14 github.com/jedib0t/go-pretty v4.3.0+incompatible github.com/mholt/archiver/v3 v3.5.1 github.com/mitchellh/hashstructure/v2 v2.0.2 @@ -47,7 +49,6 @@ require ( github.com/RageCage64/multilinediff v0.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/adrg/xdg v0.4.0 // indirect - github.com/anchore/fangs v0.0.0-20230818131516-2186b10924fe // indirect github.com/andybalholm/brotli v1.0.4 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -62,7 +63,7 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/fgprof v0.9.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/gkampitakis/ciinfo v0.2.5 // indirect + github.com/gkampitakis/ciinfo v0.3.0 // indirect github.com/gkampitakis/go-diff v1.3.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect @@ -80,6 +81,7 @@ require ( github.com/iancoleman/strcase v0.3.0 // indirect github.com/imdario/mergo v0.3.15 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.16.5 // indirect @@ -88,10 +90,11 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/maruel/natural v1.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -108,8 +111,8 @@ require ( github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect @@ -121,7 +124,7 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.16.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect - github.com/tidwall/gjson v1.16.0 // indirect + github.com/tidwall/gjson v1.17.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect diff --git a/go.sum b/go.sum index ea84437..c703ef2 100644 --- a/go.sum +++ b/go.sum @@ -139,12 +139,12 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/gkampitakis/ciinfo v0.2.5 h1:K0mac90lGguc1conc46l0YEsB7/nioWCqSnJp/6z8Eo= -github.com/gkampitakis/ciinfo v0.2.5/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8= +github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.4.10 h1:rUcTH4k6+rzw6ylDALMifzw2c/f9cG3NZe/n+7Ygdr4= -github.com/gkampitakis/go-snaps v0.4.10/go.mod h1:N4TpqxI4CqKUfHzDFqrqZ5UP0I0ESz2g2NMslh7MiJw= +github.com/gkampitakis/go-snaps v0.5.2 h1:ay/6f7WHwRkOgpBec9DjMLRBAApziJommZ21NkOOCwY= +github.com/gkampitakis/go-snaps v0.5.2/go.mod h1:ZABkO14uCuVxBHAXAfKG+bqNz+aa1bGPAg8jkI0Nk8Y= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -263,6 +263,10 @@ github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc= +github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s= +github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= +github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= @@ -292,16 +296,18 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= @@ -353,12 +359,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= @@ -407,8 +414,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= -github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=