From 97f0f83544ac3874119b30d052051e2bc1af7484 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 18 Mar 2021 08:53:31 -0400 Subject: [PATCH] add poweruser command and alias root to packages subcommand Signed-off-by: Alex Goodman --- README.md | 23 ++- cmd/check_for_application_update.go | 30 ++++ cmd/cmd.go | 144 +++++---------- cmd/completion.go | 53 +++++- cmd/packages.go | 270 ++++++++++++++++++++++++++++ cmd/power_user.go | 100 +++++++++++ cmd/power_user_tasks.go | 106 +++++++++++ cmd/root.go | 230 +++--------------------- cmd/version.go | 3 +- 9 files changed, 646 insertions(+), 313 deletions(-) create mode 100644 cmd/check_for_application_update.go create mode 100644 cmd/packages.go create mode 100644 cmd/power_user.go create mode 100644 cmd/power_user_tasks.go diff --git a/README.md b/README.md index efcfbe7eb..83d1f2f6e 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,6 @@ Configuration options (example values are the default): # same as -o ; SYFT_OUTPUT env var output: "table" -# the search space to look for packages (options: all-layers, squashed) -# same as -s ; SYFT_SCOPE env var -scope: "squashed" - # suppress all output (except for the SBOM report) # same as -q ; SYFT_QUIET env var quiet: false @@ -97,6 +93,21 @@ quiet: false # same as SYFT_CHECK_FOR_APP_UPDATE env var check-for-app-update: true +packages: + # the search space to look for packages (options: all-layers, squashed) + # same as -s ; SYFT_SCOPE env var + scope: "squashed" + +file-metadata: + # enable/disable cataloging if file metadata + cataloging-enabled: true + + # the search space to look for file metadata (options: all-layers, squashed) + scope: "squashed" + + # the file digest algorithms to use when cataloging files (options: "sha256", "md5", "sha1") + digests: ["sha256"] + log: # use structured logging # same as SYFT_LOG_STRUCTURED env var @@ -111,10 +122,6 @@ log: file: "" anchore: - # (feature-preview) enable uploading of results to Anchore Enterprise automatically (supported on Enterprise 3.0+) - # same as SYFT_ANCHORE_UPLOAD_ENABLED env var - upload-enabled: false - # (feature-preview) the Anchore Enterprise Host or URL to upload results to (supported on Enterprise 3.0+) # same as -H ; SYFT_ANCHORE_HOST env var host: "" diff --git a/cmd/check_for_application_update.go b/cmd/check_for_application_update.go new file mode 100644 index 000000000..409d6beb0 --- /dev/null +++ b/cmd/check_for_application_update.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/version" + "github.com/anchore/syft/syft/event" + "github.com/wagoodman/go-partybus" +) + +func checkForApplicationUpdate() { + if appConfig.CheckForAppUpdate { + isAvailable, newVersion, err := version.IsUpdateAvailable() + if err != nil { + // this should never stop the application + log.Errorf(err.Error()) + } + if isAvailable { + log.Infof("new version of %s is available: %s (current version is %s)", internal.ApplicationName, newVersion, version.FromBuild().Version) + + bus.Publish(partybus.Event{ + Type: event.AppUpdateAvailable, + Value: newVersion, + }) + } else { + log.Debugf("no new %s update available", internal.ApplicationName) + } + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index e813174a9..450d3e469 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -3,29 +3,29 @@ package cmd import ( "fmt" "os" + "strings" + + "github.com/spf13/cobra" "github.com/anchore/stereoscope" "github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/logger" "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/presenter" - "github.com/anchore/syft/syft/source" "github.com/gookit/color" - "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/wagoodman/go-partybus" ) -var appConfig *config.Application -var eventBus *partybus.Bus -var eventSubscription *partybus.Subscription -var cliOpts = config.CliOnlyOptions{} +var ( + appConfig *config.Application + eventBus *partybus.Bus + eventSubscription *partybus.Subscription +) func init() { - setGlobalCliOptions() - cobra.OnInitialize( + initCmdAliasBindings, initAppConfig, initLogging, logAppConfig, @@ -33,111 +33,55 @@ func init() { ) } +// provided to disambiguate the root vs packages command, whichever is indicated by the cli args will be set here. +// TODO: when the root alias command is removed, this function (hack) can be removed +var activeCmd *cobra.Command + func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, color.Red.Sprint(err.Error())) os.Exit(1) } } -func setGlobalCliOptions() { - rootCmd.PersistentFlags().StringVarP(&cliOpts.ConfigPath, "config", "c", "", "application config file") +func initCmdAliasBindings() { + // TODO: when the root alias command is removed, this function (hack) can be removed - // scan options - flag := "scope" - rootCmd.Flags().StringP( - "scope", "s", source.SquashedScope.String(), - fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes)) - if err := viper.BindPFlag(flag, rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '%s': %+v", flag, err) - os.Exit(1) + activeCmd = rootCmd + for i, a := range os.Args { + if i == 0 { + // don't consider the bin + continue + } + if a == "packages" { + // this is positively the first subcommand directive, and is "packages" + activeCmd = packagesCmd + break + } + if !strings.HasPrefix("-", a) { + // this is the first non-switch provided and was not "packages" + break + } } - setGlobalFormatOptions() - setGlobalUploadOptions() -} - -func setGlobalFormatOptions() { - // output & formatting options - flag := "output" - rootCmd.Flags().StringP( - flag, "o", string(presenter.TablePresenter), - fmt.Sprintf("report output formatter, options=%v", presenter.Options), - ) - if err := viper.BindPFlag(flag, rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '%s': %+v", flag, err) - os.Exit(1) + if activeCmd == rootCmd { + // note: cobra supports command deprecation, however the command name is empty and does not report to stderr + fmt.Fprintln(os.Stderr, color.New(color.Bold, color.Red).Sprintf("The root command is deprecated, please use the 'packages' subcommand")) } - flag = "quiet" - rootCmd.Flags().BoolP( - flag, "q", false, - "suppress all logging output", - ) - if err := viper.BindPFlag(flag, rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '%s': %+v", flag, err) - os.Exit(1) - } - - rootCmd.Flags().CountVarP(&cliOpts.Verbosity, "verbose", "v", "increase verbosity (-v = info, -vv = debug)") -} - -func setGlobalUploadOptions() { - flag := "host" - rootCmd.Flags().StringP( - flag, "H", "", - "the hostname or URL of the Anchore Enterprise instance to upload to", - ) - if err := viper.BindPFlag("anchore.host", rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '%s': %+v", flag, err) - os.Exit(1) - } - - flag = "username" - rootCmd.Flags().StringP( - flag, "u", "", - "the username to authenticate against Anchore Enterprise", - ) - if err := viper.BindPFlag("anchore.username", rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '%s': %+v", flag, err) - os.Exit(1) - } - - flag = "password" - rootCmd.Flags().StringP( - flag, "p", "", - "the password to authenticate against Anchore Enterprise", - ) - if err := viper.BindPFlag("anchore.password", rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '%s': %+v", flag, err) - os.Exit(1) - } - - flag = "dockerfile" - rootCmd.Flags().StringP( - flag, "d", "", - "include dockerfile for upload to Anchore Enterprise", - ) - if err := viper.BindPFlag("anchore.dockerfile", rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '#{flag}': #{err}") - os.Exit(1) - } - - flag = "overwrite-existing-image" - rootCmd.Flags().Bool( - flag, false, - "overwrite an existing image during the upload to Anchore Enterprise", - ) - if err := viper.BindPFlag("anchore.overwrite-existing-image", rootCmd.Flags().Lookup(flag)); err != nil { - fmt.Printf("unable to bind flag '#{flag}': #{err}") - os.Exit(1) + // note: we need to lazily bind config options since they are shared between both the root command + // and the packages command. Otherwise there will be global viper state that is in contention. + // See for more details: https://github.com/spf13/viper/issues/233 . Additionally, the bindings must occur BEFORE + // reading the application configuration, which implies that it must be an initializer (or rewrite the command + // initialization structure against typical patterns used with cobra, which is somewhat extreme for a + // temporary alias) + if err := bindConfigOptions(activeCmd.Flags()); err != nil { + panic(err) } } func initAppConfig() { - cfgVehicle := viper.GetViper() - wasHostnameSet := rootCmd.Flags().Changed("host") - cfg, err := config.LoadApplicationConfig(cfgVehicle, cliOpts, wasHostnameSet) + cfg, err := config.LoadApplicationConfig(viper.GetViper(), persistentOpts) if err != nil { fmt.Printf("failed to load application config: \n\t%+v\n", err) os.Exit(1) @@ -163,7 +107,7 @@ func initLogging() { } func logAppConfig() { - log.Debugf("Application config:\n%+v", color.Magenta.Sprint(appConfig.String())) + log.Debugf("application config:\n%+v", color.Magenta.Sprint(appConfig.String())) } func initEventBus() { diff --git a/cmd/completion.go b/cmd/completion.go index 7ec259b58..d71c0b4eb 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -1,15 +1,22 @@ package cmd import ( + "context" "os" + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" "github.com/spf13/cobra" ) // completionCmd represents the completion command var completionCmd = &cobra.Command{ - Use: "completion [bash|zsh|fish]", - Short: "Generate a shell completion for Syft (listing local docker images)", + Hidden: true, + Use: "completion [bash|zsh|fish]", + Short: "Generate a shell completion for Syft (listing local docker images)", Long: `To load completions (docker image list): Bash: @@ -63,3 +70,45 @@ $ syft completion fish > ~/.config/fish/completions/syft.fish func init() { rootCmd.AddCommand(completionCmd) } + +func dockerImageValidArgsFunction(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided + dockerImageRepoTags, err := listLocalDockerImages(toComplete) + if err != nil { + // Indicates that an error occurred and completions should be ignored + return []string{"completion failed"}, cobra.ShellCompDirectiveError + } + if len(dockerImageRepoTags) == 0 { + return []string{"no docker images found"}, cobra.ShellCompDirectiveError + } + // ShellCompDirectiveDefault indicates that the shell will perform its default behavior after completions have + // been provided (without implying other possible directives) + return dockerImageRepoTags, cobra.ShellCompDirectiveDefault +} + +func listLocalDockerImages(prefix string) ([]string, error) { + var repoTags = make([]string, 0) + ctx := context.Background() + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return repoTags, err + } + + // Only want to return tagged images + imageListArgs := filters.NewArgs() + imageListArgs.Add("dangling", "false") + images, err := cli.ImageList(ctx, types.ImageListOptions{All: false, Filters: imageListArgs}) + if err != nil { + return repoTags, err + } + + for _, image := range images { + // image may have multiple tags + for _, tag := range image.RepoTags { + if strings.HasPrefix(tag, prefix) { + repoTags = append(repoTags, tag) + } + } + } + return repoTags, nil +} diff --git a/cmd/packages.go b/cmd/packages.go new file mode 100644 index 000000000..1e95adec5 --- /dev/null +++ b/cmd/packages.go @@ -0,0 +1,270 @@ +package cmd + +import ( + "context" + "fmt" + "io/ioutil" + "os" + + "github.com/spf13/viper" + + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/anchore" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/presenter/packages" + "github.com/anchore/syft/internal/ui" + "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/distro" + "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/source" + "github.com/pkg/profile" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/wagoodman/go-partybus" +) + +const ( + packagesExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages + {{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details + {{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX SBOM + {{.appName}} {{.command}} alpine:latest -vv show verbose debug information + + Supports the following image sources: + {{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon + {{.appName}} {{.command}} path/to/a/file/or/dir a Docker tar, OCI tar, OCI directory, or generic filesystem directory + + You can also explicitly specify the scheme to use: + {{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon + {{.appName}} {{.command}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save" + {{.appName}} {{.command}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise) + {{.appName}} {{.command}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise) + {{.appName}} {{.command}} dir:path/to/yourproject read directly from a path on disk (any directory) +` +) + +var ( + packagesPresenterOpt packages.PresenterOption + packagesArgs = cobra.MinimumNArgs(1) + packagesCmd = &cobra.Command{ + Use: "packages [SOURCE]", + Short: "Generate a package SBOM", + Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems", + Example: internal.Tprintf(packagesExample, map[string]interface{}{ + "appName": internal.ApplicationName, + "command": "packages", + }), + Args: packagesArgs, + SilenceUsage: true, + SilenceErrors: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + err := cmd.Help() + if err != nil { + return err + } + // silently exit + return fmt.Errorf("") + } + + // set the presenter + presenterOption := packages.ParsePresenterOption(appConfig.Output) + if presenterOption == packages.UnknownPresenterOption { + return fmt.Errorf("bad --output value '%s'", appConfig.Output) + } + packagesPresenterOpt = presenterOption + + if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem { + return fmt.Errorf("cannot profile CPU and memory simultaneously") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if appConfig.Dev.ProfileCPU { + defer profile.Start(profile.CPUProfile).Stop() + } else if appConfig.Dev.ProfileMem { + defer profile.Start(profile.MemProfile).Stop() + } + + return packagesExec(cmd, args) + }, + ValidArgsFunction: dockerImageValidArgsFunction, + } +) + +func init() { + setPackageFlags(packagesCmd.Flags()) + + rootCmd.AddCommand(packagesCmd) +} + +func setPackageFlags(flags *pflag.FlagSet) { + ///////// Formatting & Input options ////////////////////////////////////////////// + + flags.StringP( + "scope", "s", source.SquashedScope.String(), + fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes)) + + flags.StringP( + "output", "o", string(packages.TablePresenterOption), + fmt.Sprintf("report output formatter, options=%v", packages.AllPresenters), + ) + + ///////// Upload options ////////////////////////////////////////////////////////// + flags.StringP( + "host", "H", "", + "the hostname or URL of the Anchore Enterprise instance to upload to", + ) + + flags.StringP( + "username", "u", "", + "the username to authenticate against Anchore Enterprise", + ) + + flags.StringP( + "password", "p", "", + "the password to authenticate against Anchore Enterprise", + ) + + flags.StringP( + "dockerfile", "d", "", + "include dockerfile for upload to Anchore Enterprise", + ) + + flags.Bool( + "overwrite-existing-image", false, + "overwrite an existing image during the upload to Anchore Enterprise", + ) +} + +func bindConfigOptions(flags *pflag.FlagSet) error { + ///////// Formatting & Input options ////////////////////////////////////////////// + + if err := viper.BindPFlag("packages.scope", flags.Lookup("scope")); err != nil { + return err + } + + if err := viper.BindPFlag("output", flags.Lookup("output")); err != nil { + return err + } + + ///////// Upload options ////////////////////////////////////////////////////////// + + if err := viper.BindPFlag("anchore.host", flags.Lookup("host")); err != nil { + return err + } + + if err := viper.BindPFlag("anchore.username", flags.Lookup("username")); err != nil { + return err + } + + if err := viper.BindPFlag("anchore.password", flags.Lookup("password")); err != nil { + return err + } + + if err := viper.BindPFlag("anchore.dockerfile", flags.Lookup("dockerfile")); err != nil { + return err + } + + if err := viper.BindPFlag("anchore.overwrite-existing-image", flags.Lookup("overwrite-existing-image")); err != nil { + return err + } + + return nil +} + +func packagesExec(_ *cobra.Command, args []string) error { + errs := packagesExecWorker(args[0]) + ux := ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet) + return ux(errs, eventSubscription) +} + +func packagesExecWorker(userInput string) <-chan error { + errs := make(chan error) + go func() { + defer close(errs) + + checkForApplicationUpdate() + + src, cleanup, err := source.New(userInput) + if err != nil { + errs <- fmt.Errorf("failed to determine image source: %+v", err) + return + } + defer cleanup() + + catalog, d, err := syft.CatalogPackages(src, appConfig.Packages.ScopeOpt) + if err != nil { + errs <- fmt.Errorf("failed to catalog input: %+v", err) + return + } + + if appConfig.Anchore.Host != "" { + if err := runPackageSbomUpload(src, src.Metadata, catalog, d, appConfig.Packages.ScopeOpt); err != nil { + errs <- err + return + } + } + + bus.Publish(partybus.Event{ + Type: event.PresenterReady, + Value: packages.Presenter(packagesPresenterOpt, packages.PresenterConfig{ + SourceMetadata: src.Metadata, + Catalog: catalog, + Distro: d, + Scope: appConfig.Packages.ScopeOpt, + }), + }) + }() + return errs +} + +func runPackageSbomUpload(src source.Source, s source.Metadata, catalog *pkg.Catalog, d *distro.Distro, scope source.Scope) error { + log.Infof("uploading results to %s", appConfig.Anchore.Host) + + if src.Metadata.Scheme != source.ImageScheme { + return fmt.Errorf("unable to upload results: only images are supported") + } + + var dockerfileContents []byte + if appConfig.Anchore.Dockerfile != "" { + if _, err := os.Stat(appConfig.Anchore.Dockerfile); os.IsNotExist(err) { + return fmt.Errorf("unable dockerfile=%q does not exist: %w", appConfig.Anchore.Dockerfile, err) + } + + fh, err := os.Open(appConfig.Anchore.Dockerfile) + if err != nil { + return fmt.Errorf("unable to open dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err) + } + + dockerfileContents, err = ioutil.ReadAll(fh) + if err != nil { + return fmt.Errorf("unable to read dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err) + } + } + + c, err := anchore.NewClient(anchore.Configuration{ + BaseURL: appConfig.Anchore.Host, + Username: appConfig.Anchore.Username, + Password: appConfig.Anchore.Password, + }) + if err != nil { + return fmt.Errorf("failed to create anchore client: %+v", err) + } + + importCfg := anchore.ImportConfig{ + ImageMetadata: src.Image.Metadata, + SourceMetadata: s, + Catalog: catalog, + Distro: d, + Dockerfile: dockerfileContents, + OverwriteExistingUpload: appConfig.Anchore.OverwriteExistingImage, + Scope: scope, + } + + if err := c.Import(context.Background(), importCfg); err != nil { + return fmt.Errorf("failed to upload results to host=%s: %+v", appConfig.Anchore.Host, err) + } + return nil +} diff --git a/cmd/power_user.go b/cmd/power_user.go new file mode 100644 index 000000000..1f48459a6 --- /dev/null +++ b/cmd/power_user.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/presenter/poweruser" + "github.com/anchore/syft/internal/ui" + "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/source" + "github.com/pkg/profile" + "github.com/spf13/cobra" + "github.com/wagoodman/go-partybus" +) + +var powerUserOpts = struct { + configPath string +}{} + +var powerUserCmd = &cobra.Command{ + Use: "power-user [SOURCE]", + Short: "Run bulk operations on container images", + Example: ` {{.appName}} power-user `, + Args: cobra.ExactArgs(1), + Hidden: true, + SilenceUsage: true, + SilenceErrors: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem { + return fmt.Errorf("cannot profile CPU and memory simultaneously") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if appConfig.Dev.ProfileCPU { + defer profile.Start(profile.CPUProfile).Stop() + } else if appConfig.Dev.ProfileMem { + defer profile.Start(profile.MemProfile).Stop() + } + + return powerUserExec(cmd, args) + }, + ValidArgsFunction: dockerImageValidArgsFunction, +} + +func init() { + powerUserCmd.Flags().StringVarP(&powerUserOpts.configPath, "config", "c", "", "config file path with all power-user options") + + rootCmd.AddCommand(powerUserCmd) +} + +func powerUserExec(_ *cobra.Command, args []string) error { + errs := powerUserExecWorker(args[0]) + ux := ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet) + return ux(errs, eventSubscription) +} + +func powerUserExecWorker(userInput string) <-chan error { + errs := make(chan error) + go func() { + defer close(errs) + + checkForApplicationUpdate() + + src, cleanup, err := source.New(userInput) + if err != nil { + errs <- err + return + } + defer cleanup() + + if src.Metadata.Scheme != source.ImageScheme { + errs <- fmt.Errorf("the power-user subcommand only allows for 'image' schemes, given %q", src.Metadata.Scheme) + return + } + + analysisResults := poweruser.JSONDocumentConfig{ + SourceMetadata: src.Metadata, + ApplicationConfig: *appConfig, + } + tasks, err := powerUserTasks(src) + if err != nil { + errs <- err + return + } + + for _, task := range tasks { + if err = task(&analysisResults); err != nil { + errs <- err + return + } + } + + bus.Publish(partybus.Event{ + Type: event.PresenterReady, + Value: poweruser.NewJSONPresenter(analysisResults), + }) + }() + return errs +} diff --git a/cmd/power_user_tasks.go b/cmd/power_user_tasks.go new file mode 100644 index 000000000..0d5c21746 --- /dev/null +++ b/cmd/power_user_tasks.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "github.com/anchore/syft/internal/presenter/poweruser" + "github.com/anchore/syft/syft" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/source" +) + +type powerUserTask func(*poweruser.JSONDocumentConfig) error + +func powerUserTasks(src source.Source) ([]powerUserTask, error) { + var tasks []powerUserTask + var err error + var task powerUserTask + + task = catalogPackagesTask(src) + if task != nil { + tasks = append(tasks, task) + } + + task, err = catalogFileMetadataTask(src) + if err != nil { + return nil, err + } else if task != nil { + tasks = append(tasks, task) + } + + task, err = catalogFileDigestTask(src) + if err != nil { + return nil, err + } else if task != nil { + tasks = append(tasks, task) + } + + return tasks, nil +} + +func catalogPackagesTask(src source.Source) powerUserTask { + if !appConfig.Packages.CatalogingEnabled { + return nil + } + + task := func(results *poweruser.JSONDocumentConfig) error { + packageCatalog, theDistro, err := syft.CatalogPackages(src, appConfig.Packages.ScopeOpt) + if err != nil { + return err + } + + results.PackageCatalog = packageCatalog + results.Distro = theDistro + + return nil + } + + return task +} + +func catalogFileMetadataTask(src source.Source) (powerUserTask, error) { + if !appConfig.FileMetadata.CatalogingEnabled { + return nil, nil + } + + resolver, err := src.FileResolver(appConfig.FileMetadata.ScopeOpt) + if err != nil { + return nil, err + } + + task := func(results *poweruser.JSONDocumentConfig) error { + result, err := file.NewMetadataCataloger(resolver).Catalog() + if err != nil { + return err + } + results.FileMetadata = result + return nil + } + + return task, nil +} + +func catalogFileDigestTask(src source.Source) (powerUserTask, error) { + if !appConfig.FileMetadata.CatalogingEnabled { + return nil, nil + } + + resolver, err := src.FileResolver(appConfig.FileMetadata.ScopeOpt) + if err != nil { + return nil, err + } + + cataloger, err := file.NewDigestsCataloger(resolver, appConfig.FileMetadata.Digests) + if err != nil { + return nil, err + } + + task := func(results *poweruser.JSONDocumentConfig) error { + result, err := cataloger.Catalog() + if err != nil { + return err + } + results.FileDigests = result + return nil + } + + return task, nil +} diff --git a/cmd/root.go b/cmd/root.go index d89f8e739..cb131972e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,217 +1,45 @@ package cmd import ( - "context" "fmt" - "io/ioutil" "os" - "strings" - "github.com/pkg/profile" - - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/anchore" - "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/ui" - "github.com/anchore/syft/internal/version" - "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/distro" - "github.com/anchore/syft/syft/event" - "github.com/anchore/syft/syft/pkg" - "github.com/anchore/syft/syft/presenter" - "github.com/anchore/syft/syft/source" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/client" + "github.com/anchore/syft/internal/config" "github.com/spf13/cobra" - "github.com/wagoodman/go-partybus" + "github.com/spf13/viper" ) +var persistentOpts = config.CliOnlyOptions{} + +// rootCmd is currently an alias for the packages command var rootCmd = &cobra.Command{ - Use: fmt.Sprintf("%s [SOURCE]", internal.ApplicationName), - Short: "A tool for generating a Software Bill Of Materials (PackageSBOM) from container images and filesystems", - Long: internal.Tprintf(` -Supports the following image sources: - {{.appName}} yourrepo/yourimage:tag defaults to using images from a Docker daemon - {{.appName}} path/to/yourproject a Docker tar, OCI tar, OCI directory, or generic filesystem directory - -You can also explicitly specify the scheme to use: - {{.appName}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon - {{.appName}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save" - {{.appName}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Podman or otherwise) - {{.appName}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise) - {{.appName}} dir:path/to/yourproject read directly from a path on disk (any directory) -`, map[string]interface{}{ - "appName": internal.ApplicationName, - }), - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - err := cmd.Help() - if err != nil { - log.Errorf(err.Error()) - os.Exit(1) - } - os.Exit(1) - } - - if appConfig.Dev.ProfileCPU && appConfig.Dev.ProfileMem { - log.Errorf("cannot profile CPU and memory simultaneously") - os.Exit(1) - } - - if appConfig.Dev.ProfileCPU { - defer profile.Start(profile.CPUProfile).Stop() - } else if appConfig.Dev.ProfileMem { - defer profile.Start(profile.MemProfile).Stop() - } - - err := doRunCmd(cmd, args) - - if err != nil { - log.Errorf(err.Error()) - os.Exit(1) - } - }, - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Since we use ValidArgsFunction, Cobra will call this AFTER having parsed all flags and arguments provided - dockerImageRepoTags, err := ListLocalDockerImages(toComplete) - if err != nil { - // Indicates that an error occurred and completions should be ignored - return []string{"completion failed"}, cobra.ShellCompDirectiveError - } - if len(dockerImageRepoTags) == 0 { - return []string{"no docker images found"}, cobra.ShellCompDirectiveError - } - // ShellCompDirectiveDefault indicates that the shell will perform its default behavior after completions have - // been provided (without implying other possible directives) - return dockerImageRepoTags, cobra.ShellCompDirectiveDefault - }, + Short: packagesCmd.Short, + Long: packagesCmd.Long, + Args: packagesCmd.Args, + Example: packagesCmd.Example, + SilenceUsage: true, + SilenceErrors: true, + PreRunE: packagesCmd.PreRunE, + RunE: packagesCmd.RunE, + ValidArgsFunction: packagesCmd.ValidArgsFunction, } -func startWorker(userInput string) <-chan error { - errs := make(chan error) - go func() { - defer close(errs) +func init() { + // set universal flags + rootCmd.PersistentFlags().StringVarP(&persistentOpts.ConfigPath, "config", "c", "", "application config file") - if appConfig.CheckForAppUpdate { - isAvailable, newVersion, err := version.IsUpdateAvailable() - if err != nil { - log.Errorf(err.Error()) - } - if isAvailable { - log.Infof("new version of %s is available: %s", internal.ApplicationName, newVersion) + flag := "quiet" + rootCmd.PersistentFlags().BoolP( + flag, "q", false, + "suppress all logging output", + ) + if err := viper.BindPFlag(flag, rootCmd.PersistentFlags().Lookup(flag)); err != nil { + fmt.Printf("unable to bind flag '%s': %+v", flag, err) + os.Exit(1) + } - bus.Publish(partybus.Event{ - Type: event.AppUpdateAvailable, - Value: newVersion, - }) - } else { - log.Debugf("no new %s update available", internal.ApplicationName) - } - } + rootCmd.PersistentFlags().CountVarP(&persistentOpts.Verbosity, "verbose", "v", "increase verbosity (-v = info, -vv = debug)") - src, catalog, distro, err := syft.Catalog(userInput, appConfig.ScopeOpt) - if err != nil { - errs <- fmt.Errorf("failed to catalog input: %+v", err) - return - } - - if appConfig.Anchore.UploadEnabled { - if err := doImport(src, src.Metadata, catalog, distro); err != nil { - errs <- err - return - } - } - - bus.Publish(partybus.Event{ - Type: event.CatalogerFinished, - Value: presenter.GetPresenter(appConfig.PresenterOpt, src.Metadata, catalog, distro), - }) - }() - return errs -} - -func doImport(src source.Source, s source.Metadata, catalog *pkg.Catalog, d *distro.Distro) error { - // TODO: ETUI element for this - log.Infof("uploading results to %s", appConfig.Anchore.Host) - - if src.Metadata.Scheme != source.ImageScheme { - return fmt.Errorf("unable to upload results: only images are supported") - } - - var dockerfileContents []byte - if appConfig.Anchore.Dockerfile != "" { - if _, err := os.Stat(appConfig.Anchore.Dockerfile); os.IsNotExist(err) { - return fmt.Errorf("unable to read dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err) - } - - fh, err := os.Open(appConfig.Anchore.Dockerfile) - if err != nil { - return fmt.Errorf("unable to open dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err) - } - - dockerfileContents, err = ioutil.ReadAll(fh) - if err != nil { - return fmt.Errorf("unable to read dockerfile=%q: %w", appConfig.Anchore.Dockerfile, err) - } - } - - c, err := anchore.NewClient(anchore.Configuration{ - BaseURL: appConfig.Anchore.Host, - Username: appConfig.Anchore.Username, - Password: appConfig.Anchore.Password, - }) - if err != nil { - return fmt.Errorf("unable to upload results: %w", err) - } - - importCfg := anchore.ImportConfig{ - ImageMetadata: src.Image.Metadata, - SourceMetadata: s, - Catalog: catalog, - Distro: d, - Dockerfile: dockerfileContents, - OverwriteExistingUpload: appConfig.Anchore.OverwriteExistingImage, - } - - if err := c.Import(context.Background(), importCfg); err != nil { - return fmt.Errorf("failed to upload results to host=%s: %+v", appConfig.Anchore.Host, err) - } - return nil -} - -func doRunCmd(_ *cobra.Command, args []string) error { - userInput := args[0] - errs := startWorker(userInput) - ux := ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet) - return ux(errs, eventSubscription) -} - -func ListLocalDockerImages(prefix string) ([]string, error) { - var repoTags = make([]string, 0) - ctx := context.Background() - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return repoTags, err - } - - // Only want to return tagged images - imageListArgs := filters.NewArgs() - imageListArgs.Add("dangling", "false") - images, err := cli.ImageList(ctx, types.ImageListOptions{All: false, Filters: imageListArgs}) - if err != nil { - return repoTags, err - } - - for _, image := range images { - // image may have multiple tags - for _, tag := range image.RepoTags { - if strings.HasPrefix(tag, prefix) { - repoTags = append(repoTags, tag) - } - } - } - return repoTags, nil + // set common options that are not universal (package subcommand-alias specific) + setPackageFlags(rootCmd.Flags()) } diff --git a/cmd/version.go b/cmd/version.go index 8ceddca02..757132ec5 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -8,7 +8,6 @@ import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/version" - "github.com/anchore/syft/syft/presenter" "github.com/spf13/cobra" ) @@ -21,7 +20,7 @@ var versionCmd = &cobra.Command{ } func init() { - versionCmd.Flags().StringVarP(&outputFormat, "output", "o", string(presenter.TextPresenter), "format to show version information (available=[text, json])") + versionCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "format to show version information (available=[text, json])") rootCmd.AddCommand(versionCmd) }