diff --git a/cmd/dependabot/internal/cmd/graph.go b/cmd/dependabot/internal/cmd/graph.go new file mode 100644 index 00000000..0c1198eb --- /dev/null +++ b/cmd/dependabot/internal/cmd/graph.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "os" + "slices" + + "github.com/MakeNowJust/heredoc" + "github.com/dependabot/cli/internal/infra" + "github.com/spf13/cobra" +) + +var graphSupportedEcosystems = []string{ + "bundler", + "go_modules", +} + +var graphCmd = NewGraphCommand() + +func init() { + rootCmd.AddCommand(graphCmd) +} + +func NewGraphCommand() *cobra.Command { + var flags UpdateFlags + + cmd := &cobra.Command{ + Use: "graph [ | -f ] [flags]", + Short: "[Experimental] List the dependencies of a manifest/lockfile", + Example: heredoc.Doc(` + NOTE: This command is a work in progress. + + It will only work with some package managers and the dependency list + may be incomplete. + + $ dependabot graph bundler dependabot/dependabot-core + $ dependabot graph bundler --local . + $ dependabot graph -f input.yml + `), + RunE: func(cmd *cobra.Command, args []string) error { + var outFile *os.File + if flags.output != "" { + var err error + outFile, err = os.Create(flags.output) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + } + + input, err := extractInput(cmd, &flags) + if err != nil { + return err + } + + processInput(input, &flags) + + if !slices.Contains(graphSupportedEcosystems, input.Job.PackageManager) { + return fmt.Errorf( + "package manager '%s' is not supported for graphing. Supported ecosystems: %v", + input.Job.PackageManager, + graphSupportedEcosystems, + ) + } + + var writer io.Writer + if !flags.debugging { + writer = os.Stdout + } + + if err := infra.Run(infra.RunParams{ + Command: infra.UpdateGraphCommand, + CacheDir: flags.cache, + CollectorConfigPath: flags.collectorConfigPath, + CollectorImage: collectorImage, + Creds: input.Credentials, + Debug: flags.debugging, + Flamegraph: flags.flamegraph, + Expected: nil, // graph subcommand doesn't use expectations + ExtraHosts: flags.extraHosts, + InputName: flags.file, + Job: &input.Job, + LocalDir: flags.local, + Output: flags.output, + ProxyCertPath: flags.proxyCertPath, + ProxyImage: proxyImage, + PullImages: flags.pullImages, + Timeout: flags.timeout, + UpdaterImage: updaterImage, + Volumes: flags.volumes, + Writer: writer, + ApiUrl: flags.apiUrl, + }); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + log.Fatalf("update timed out after %s", flags.timeout) + } + log.Fatalf("updater failure: %v", err) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&flags.file, "file", "f", "", "path to input file") + + cmd.Flags().StringVarP(&flags.provider, "provider", "p", "github", "provider of the repository") + cmd.Flags().StringVarP(&flags.branch, "branch", "b", "", "target branch to update") + cmd.Flags().StringVarP(&flags.directory, "directory", "d", "/", "directory to update") + cmd.Flags().StringVarP(&flags.commit, "commit", "", "", "commit to update") + + cmd.Flags().StringVarP(&flags.output, "output", "o", "", "write scenario to file") + cmd.Flags().StringVar(&flags.cache, "cache", "", "cache import/export directory") + cmd.Flags().StringVar(&flags.local, "local", "", "local directory to use as fetched source") + cmd.Flags().StringVar(&flags.proxyCertPath, "proxy-cert", "", "path to a certificate the proxy will trust") + cmd.Flags().StringVar(&flags.collectorConfigPath, "collector-config", "", "path to an OpenTelemetry collector config file") + cmd.Flags().BoolVar(&flags.pullImages, "pull", true, "pull the image if it isn't present") + cmd.Flags().BoolVar(&flags.debugging, "debug", false, "run an interactive shell inside the updater") + cmd.Flags().BoolVar(&flags.flamegraph, "flamegraph", false, "generate a flamegraph and other metrics") + cmd.Flags().StringArrayVarP(&flags.volumes, "volume", "v", nil, "mount volumes in Docker") + cmd.Flags().StringArrayVar(&flags.extraHosts, "extra-hosts", nil, "Docker extra hosts setting on the proxy") + cmd.Flags().DurationVarP(&flags.timeout, "timeout", "t", 0, "max time to run an update") + cmd.Flags().IntVar(&flags.inputServerPort, "input-port", 0, "port to use for securely passing input to the updater") + cmd.Flags().StringVarP(&flags.apiUrl, "api-url", "a", "", "the api dependabot should connect to.") + + return cmd +} diff --git a/internal/infra/run.go b/internal/infra/run.go index be54b6d9..933a82b8 100644 --- a/internal/infra/run.go +++ b/internal/infra/run.go @@ -30,9 +30,23 @@ import ( "gopkg.in/yaml.v3" ) +type RunCommand int + +const ( + UpdateFilesCommand RunCommand = iota + UpdateGraphCommand +) + +var runCmds = map[RunCommand]string{ + UpdateFilesCommand: "bin/run fetch_files && bin/run update_files", + UpdateGraphCommand: "bin/run fetch_files && bin/run update_graph", +} + type RunParams struct { // Input file Input string + // Which command to use, this will default to UpdateFilesCommand + Command RunCommand // job definition passed to the updater Job *model.Job // expectations asserted at the end of a test @@ -452,8 +466,7 @@ func runContainers(ctx context.Context, params RunParams) (err error) { if params.Flamegraph { env = append(env, "FLAMEGRAPH=1") } - const cmd = "bin/run fetch_files && bin/run update_files" - if err := updater.RunCmd(ctx, cmd, dependabot, env...); err != nil { + if err := updater.RunCmd(ctx, runCmds[params.Command], dependabot, env...); err != nil { return err } if params.Flamegraph { diff --git a/internal/model/update.go b/internal/model/update.go index 00286503..63d72093 100644 --- a/internal/model/update.go +++ b/internal/model/update.go @@ -29,6 +29,16 @@ type UpdatePullRequest struct { DependencyGroup map[string]any `json:"dependency-group" yaml:"dependency-group,omitempty"` } +type DependencySubmissionRequest struct { + Version int8 `json:"version" yaml:"version"` + Sha string `json:"sha" yaml:"sha"` + Ref string `json:"ref" yaml:"ref"` + Job map[string]any `json:"job" yaml:"job"` + Detector map[string]any `json:"detector" yaml:"detector"` + Scanned string `json:"scanned" yaml:"scanned"` + Manifests map[string]any `json:"manifests" yaml:"manifests"` +} + type DependencyFile struct { Content string `json:"content" yaml:"content"` ContentEncoding string `json:"content_encoding" yaml:"content_encoding"` diff --git a/internal/server/api.go b/internal/server/api.go index c2bb5bac..20a05eb3 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -230,6 +230,8 @@ func decodeWrapper(kind string, data []byte) (actual *model.UpdateWrapper, err e actual.Data, err = decode[model.UpdateDependencyList](data) case "create_pull_request": actual.Data, err = decode[model.CreatePullRequest](data) + case "create_dependency_submission": + actual.Data, err = decode[model.DependencySubmissionRequest](data) case "update_pull_request": actual.Data, err = decode[model.UpdatePullRequest](data) case "close_pull_request": @@ -280,6 +282,11 @@ func decode[T any](data []byte) (T, error) { return wrapper.Data, nil } +// TODO(brrygrdn): Add model.DependencySubmissionRequest to support smoke tests +// +// The test command only expects to run with `update` operations right now so +// we will need to incorporate which run command is expected as well, but we +// don't need regression coverage yet. func compare(expect, actual *model.UpdateWrapper) error { switch v := expect.Data.(type) { case model.UpdateDependencyList: