diff --git a/dev/sg/BUILD.bazel b/dev/sg/BUILD.bazel index c20168b51d9..ceda5675089 100644 --- a/dev/sg/BUILD.bazel +++ b/dev/sg/BUILD.bazel @@ -21,6 +21,7 @@ go_library( "sg_embeddings_qa.go", "sg_generate.go", "sg_help.go", + "sg_images.go", "sg_insights.go", "sg_install.go", "sg_lint.go", @@ -141,6 +142,7 @@ go_test( timeout = "short", srcs = [ "main_test.go", + "sg_images_test.go", "sg_start_test.go", ], # Required by func findRoot() to check if sg is running in sourcegraph/sourcegraph diff --git a/dev/sg/main.go b/dev/sg/main.go index f4875ba4e7c..9c90f71cbb0 100644 --- a/dev/sg/main.go +++ b/dev/sg/main.go @@ -283,6 +283,7 @@ var sg = &cli.App{ setupCommand, srcCommand, srcInstanceCommand, + imagesCommand, // Company teammateCommand, diff --git a/dev/sg/sg_images.go b/dev/sg/sg_images.go new file mode 100644 index 00000000000..8117ff984c6 --- /dev/null +++ b/dev/sg/sg_images.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/grafana/regexp" + "github.com/urfave/cli/v2" + + "github.com/sourcegraph/run" + "github.com/sourcegraph/sourcegraph/dev/sg/internal/category" + "github.com/sourcegraph/sourcegraph/dev/sg/internal/std" + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/sourcegraph/lib/output" +) + +var imagesCommand = &cli.Command{ + Name: "images", + Usage: "Commands for interacting with containers", + Category: category.Dev, + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List container images available to build", + Action: func(cctx *cli.Context) error { + lines, err := listBazelOCITarballs(cctx.Context) + if err != nil { + return err + } + std.Out.WriteLine(output.Styledf(output.StylePending, "Found %d targets", len(lines))) + std.Out.WriteLine(output.Styledf(output.StyleReset, strings.Join(lines, "\n"))) + std.Out.WriteLine(output.Styledf(output.StylePending, "💡 You can build and load the above targets using glob patterns with 'sg images build [pattern]...")) + std.Out.WriteLine(output.Styledf(output.StylePending, "💡 Examples:")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " 'worker' to match //cmd/worker:image_tarball")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " 'cmd/*' to match all containers under //cmd/")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " 'postgres*' to match")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " //docker-images/postgres-12-alpine:base_tarball//docker-images/postgres-12-alpine:image_tarball")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " //docker-images/postgres-12-alpine:legacy_tarball")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " //docker-images/postgres_exporter:base_tarball")) + std.Out.WriteLine(output.Styledf(output.StyleReset, " //docker-images/postgres_exporter:image_tarball")) + + return nil + }, + }, + { + Name: "build", + Usage: "builds a container image by matching [pattern] using glob syntax to the target.\nExamples:\n- sg images build worker\n- sg images build cmd/*\n- sg images build postgres*", + UsageText: "build [pattern1] ([pattern2] ...)", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "load", + Usage: "Load the image into the local Docker daemon", + Value: true, + }, + }, + Action: func(cctx *cli.Context) error { + allTargets, err := listBazelOCITarballs(cctx.Context) + if err != nil { + return err + } + + patterns := cctx.Args().Slice() + selectedTargets := []string{} + + for _, name := range patterns { + if strings.HasPrefix(name, "//") { + std.Out.WriteLine(output.Styledf(output.StyleYellow, "Detected a Bazel target path (%q) aborting.", name)) + std.Out.WriteLine(output.Styledf(output.StyleBold, "Run the following command instead:")) + std.Out.WriteLine(output.Styledf(output.StyleReset, "Building the image without loading it:\n sg bazel build %s", name)) + std.Out.WriteLine(output.Styledf(output.StyleReset, "Building the image and loading it:\n sg bazel run %s", name)) + return nil + } + } + + for _, target := range allTargets { + for _, name := range patterns { + var ok bool + var err error + if strings.Contains(name, "/") { + ok, err = filepath.Match(name, trimImageTarballTarget(target)) + } else { + ok, err = filepath.Match(fmt.Sprintf("*/%s", name), trimImageTarballTarget(target)) + } + if err != nil { + return err + } + if ok { + selectedTargets = append(selectedTargets, target) + } + } + } + + std.Out.WriteLine(output.Styledf(output.StylePending, "Found Bazel targets: \n%s", strings.Join(selectedTargets, "\n"))) + + commandText := fmt.Sprintf("sg bazel build %s", strings.Join(selectedTargets, " ")) + std.Out.WriteLine(output.Styledf(output.StyleBold, "Running Bazel 'build' command for you")) + std.Out.WriteLine(output.Styledf(output.StyleYellow, " "+commandText)) + std.Out.WriteLine(output.Styledf(output.StyleReset, "--- 👇 Bazel output ---")) + + // Please note the added --color flag here, to ensure we keep the colors when streaming back the output. + // And we run `bazel` directly, because we know that for build/run you don't need `sg bazel` but our users + // don't have to know that. + cmd := run.Bash(cctx.Context, fmt.Sprintf("bazel build --color=yes %s", strings.Join(selectedTargets, " "))) + if err := cmd.Run().Stream(os.Stdout); err != nil { + return err + } + std.Out.WriteLine(output.Styledf(output.StyleReset, "----------------------")) + + if cctx.Bool("load") { + for _, target := range selectedTargets { + commandText := fmt.Sprintf("bazel run %s", target) + std.Out.WriteLine(output.Styledf(output.StyleBold, "Running Bazel command for you")) + std.Out.WriteLine(output.Styledf(output.StyleYellow, " "+commandText)) + std.Out.WriteLine(output.Styledf(output.StyleReset, "--- 👇 Bazel output ---")) + + // Please note the added --color flag here, to ensure we keep the colors when streaming back the output. + cmd := run.Bash(cctx.Context, fmt.Sprintf("sg bazel run --color=yes %s", target)) + if err := cmd.Run().Stream(os.Stdout); err != nil { + return err + } + std.Out.WriteLine(output.Styledf(output.StyleReset, "----------------------")) + } + } + return nil + }, + }, + }, +} + +func listBazelOCITarballs(ctx context.Context) ([]string, error) { + cmd := run.Cmd(ctx, "bazel", "query", "kind('oci_tarball', //...)") + lines, err := cmd.Run().Lines() + + if err != nil { + return nil, errors.Wrap(err, "failed to list bazel images") + } + + if len(lines) > 0 { + if strings.HasPrefix(lines[0], "Loading") { + // Trim the first line, which is just "Loading: (...)" + lines = lines[1:] + } + } + + return lines, nil +} + +// trimImageTarballTarget simplifies the Bazel target path for easier matching with glob syntax. +// For example, the target '//cmd/worker:image_tarball' becomes 'worker'. +func trimImageTarballTarget(target string) string { + // Remove the leading '//' + target = strings.TrimPrefix(target, "//") + + // Remove the trailing ':.*' + reg := regexp.MustCompile(":.*") + target = reg.ReplaceAllString(target, "") + + return target +} diff --git a/dev/sg/sg_images_test.go b/dev/sg/sg_images_test.go new file mode 100644 index 00000000000..53688d79a17 --- /dev/null +++ b/dev/sg/sg_images_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "testing" +) + +func Test_trimImageTarballTarget(t *testing.T) { + tests := []struct { + target string + want string + }{ + { + target: "//cmd/worker:image_tarball", + want: "cmd/worker", + }, + { + target: "//docker-images/caddy:image_tarball", + want: "docker-images/caddy", + }, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%q to %q", tt.target, tt.want), func(t *testing.T) { + got := trimImageTarballTarget(tt.target) + if got != tt.want { + t.Logf("got %q but wanted %q", got, tt.want) + t.Fail() + } + }) + } +}