[BCID] Add update-references subcommand to build-images

This should allow us to use the same entrypoint for multiple stages of
the pipeline, which will simplify the information we have to carry over
between stages.

Change-Id: I33c5cb7b6d9fbd26afa31587566f3fe6b24c59a5
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/583742
Reviewed-by: Ravi Mistry <rmistry@google.com>
Commit-Queue: Eric Boren <borenet@google.com>
diff --git a/cd/go/build-images/BUILD.bazel b/cd/go/build-images/BUILD.bazel
index c03ef80..bb94c2b 100644
--- a/cd/go/build-images/BUILD.bazel
+++ b/cd/go/build-images/BUILD.bazel
@@ -3,14 +3,23 @@
 
 go_library(
     name = "build-images_lib",
-    srcs = ["main.go"],
+    srcs = [
+        "build.go",
+        "main.go",
+        "update-refs.go",
+    ],
     importpath = "go.skia.org/infra/cd/go/build-images",
     visibility = ["//visibility:private"],
     deps = [
-        "//go/common",
         "//go/exec",
+        "//go/gerrit/rubberstamper",
         "//go/git",
+        "//go/gitauth",
+        "//go/skerr",
+        "//go/util",
+        "//task_driver/go/lib/git_steps",
         "//task_driver/go/td",
+        "@com_github_urfave_cli_v2//:cli",
         "@org_golang_x_sync//errgroup",
     ],
 )
diff --git a/cd/go/build-images/build.go b/cd/go/build-images/build.go
new file mode 100644
index 0000000..d455f4e
--- /dev/null
+++ b/cd/go/build-images/build.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"path"
+	"strings"
+	"time"
+
+	"go.skia.org/infra/go/exec"
+	"go.skia.org/infra/go/gitauth"
+	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/task_driver/go/lib/git_steps"
+	"go.skia.org/infra/task_driver/go/td"
+	"golang.org/x/sync/errgroup"
+)
+
+func build(ctx context.Context, commit, repo, workspace, username, email string, targets []string, rbe bool) error {
+	ctx = td.StartStep(ctx, td.Props("Build Images"))
+	defer td.EndStep(ctx)
+
+	// Initialize git authentication.
+	ts, err := git_steps.Init(ctx, true)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+	if _, err := gitauth.New(ts, "/tmp/.gitcookies", true, email); err != nil {
+		return td.FailStep(ctx, err)
+	}
+
+	bazelTargetToImagePath := make(map[string]string, len(targets))
+	for _, target := range targets {
+		targetSplit := strings.Split(target, ":")
+		if len(targetSplit) != 3 {
+			return td.FailStep(ctx, skerr.Fmt("Invalid target specification %q; expected \"//bazel-target:bazel-target:gcr.io/image/path\"", target))
+		}
+		bazelTarget := strings.Join(targetSplit[:2], ":")
+		imagePath := targetSplit[2]
+		bazelTargetToImagePath[bazelTarget] = imagePath
+	}
+
+	// Create a shallow clone of the repo.
+	checkoutDir, err := shallowClone(ctx, repo, commit)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+
+	// Create the timestamped Docker image tag.
+	timestamp := time.Now().UTC().Format("2006-01-02T15_04_05Z")
+	imageTag := fmt.Sprintf("%s-%s-%s-%s", timestamp, username, commit[:7], "clean")
+
+	// Perform the builds concurrently.
+	imageInfo := &buildImagesJSON{
+		Images: make([]SingleImageInfo, 0, len(bazelTargetToImagePath)),
+	}
+	eg, ctx := errgroup.WithContext(ctx)
+	for bazelTarget, imagePath := range bazelTargetToImagePath {
+		// https://golang.org/doc/faq#closures_and_goroutines
+		bazelTarget := bazelTarget
+		louhiImageTag := fmt.Sprintf("louhi_ws/%s:%s", imagePath, imageTag)
+		imageInfo.Images = append(imageInfo.Images, SingleImageInfo{
+			Image: imagePath,
+			Tag:   louhiImageTag,
+		})
+		eg.Go(func() error {
+			return bazelRun(ctx, checkoutDir, bazelTarget, louhiImageTag, rbe)
+		})
+	}
+	if err := eg.Wait(); err != nil {
+		return td.FailStep(ctx, err)
+	}
+	return writeBuildImagesJSON(ctx, workspace, imageInfo)
+}
+
+// bazelTargetToDockerTag converts a Bazel target specification to a Docker
+// image tag which is applied to the image during the Bazel build.
+func bazelTargetToDockerTag(target string) string {
+	return path.Join("bazel", target)
+}
+
+// bazelRun executes `bazel run` for the given target and applies the given tag
+// to the resulting image.
+func bazelRun(ctx context.Context, cwd, target, louhiImageTag string, rbe bool) error {
+	ctx = td.StartStep(ctx, td.Props(fmt.Sprintf("Build %s", target)))
+	defer td.EndStep(ctx)
+
+	cmd := []string{"bazelisk", "run"}
+	if rbe {
+		cmd = append(cmd, "--config=remote", "--google_default_credentials")
+	}
+	cmd = append(cmd, target)
+	if _, err := exec.RunCwd(ctx, cwd, cmd...); err != nil {
+		return td.FailStep(ctx, err)
+	}
+	if _, err := exec.RunCwd(ctx, cwd, "docker", "tag", bazelTargetToDockerTag(target), louhiImageTag); err != nil {
+		return td.FailStep(ctx, err)
+	}
+	return nil
+}
diff --git a/cd/go/build-images/main.go b/cd/go/build-images/main.go
index caa7b40..cc20db0 100644
--- a/cd/go/build-images/main.go
+++ b/cd/go/build-images/main.go
@@ -1,35 +1,119 @@
 package main
 
-import (
-	"context"
-	"flag"
-	"fmt"
-	"io/ioutil"
-	"os/user"
-	"path"
-	"strings"
-	"time"
-
-	"go.skia.org/infra/go/common"
-	"go.skia.org/infra/go/exec"
-	"go.skia.org/infra/go/git"
-	"go.skia.org/infra/task_driver/go/td"
-	"golang.org/x/sync/errgroup"
-)
-
 /*
 	build-images is used for building Skia Infrastructure Docker images using
 	Bazel. It is intended to run inside of Louhi as part of a CD pipeline.
 */
 
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/urfave/cli/v2"
+
+	"go.skia.org/infra/go/exec"
+	"go.skia.org/infra/go/git"
+	"go.skia.org/infra/go/util"
+	"go.skia.org/infra/task_driver/go/td"
+)
+
 func main() {
-	// Setup.
-	commit := flag.String("commit", "", "Commit at which to build the image.")
-	repo := flag.String("repo", "", "Repository URL.")
-	workspace := flag.String("workspace", "", "Path to Louhi workspace.")
-	targets := common.NewMultiStringFlag("target", nil, "Bazel target + image path pairs in the form \"//bazel-package:bazel-target:gcr.io/image/path\". ")
-	rbe := flag.Bool("rbe", false, "Whether or not to use Bazel RBE.")
-	username := flag.String("user", "", "User name to attribute the build. If not specified, attempt to determine automatically.")
+	const (
+		flagCommit    = "commit"
+		flagRepo      = "repo"
+		flagWorkspace = "workspace"
+		flagTarget    = "target"
+		flagRBE       = "rbe"
+		flagUser      = "user"
+		flagEmail     = "email"
+	)
+	app := &cli.App{
+		Name:        "build-images",
+		Description: `build-images is used for building Skia Infrastructure Docker images using Bazel. It is intended to run inside of Louhi as part of a CD pipeline.`,
+		Commands: []*cli.Command{
+			{
+				Name:        "build",
+				Description: "Build Docker images.",
+				Usage:       "build-images <options>",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     flagCommit,
+						Usage:    "Commit at which to build the image(s).",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:     flagRepo,
+						Usage:    "Repository URL.",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:     flagWorkspace,
+						Usage:    "Path to Louhi workspace.",
+						Required: true,
+					},
+					&cli.StringSliceFlag{
+						Name:     flagTarget,
+						Usage:    "Bazel target + image path pairs in the form \"//bazel-package:bazel-target:gcr.io/image/path\".",
+						Required: true,
+					},
+					&cli.BoolFlag{
+						Name:  flagRBE,
+						Usage: "Whether or not to use Bazel RBE",
+						Value: false,
+					},
+					&cli.StringFlag{
+						Name:     flagUser,
+						Usage:    "User name to attribute the build.",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:     flagEmail,
+						Usage:    "Email address to attribute the build.",
+						Required: true,
+					},
+				},
+				Action: func(ctx *cli.Context) error {
+					return build(ctx.Context, ctx.String(flagCommit), ctx.String(flagRepo), ctx.String(flagWorkspace), ctx.String(flagUser), ctx.String(flagEmail), ctx.StringSlice(flagTarget), ctx.Bool(flagRBE))
+				},
+			},
+			{
+				Name:        "update-references",
+				Description: "Update references to the images we built.",
+				Usage:       "update-references <options>",
+				Flags: []cli.Flag{
+					&cli.StringFlag{
+						Name:     flagRepo,
+						Usage:    "Repository URL.",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:     flagWorkspace,
+						Usage:    "Path to Louhi workspace.",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:     flagUser,
+						Usage:    "User name to attribute the build.",
+						Required: true,
+					},
+					&cli.StringFlag{
+						Name:     flagEmail,
+						Usage:    "Email address to attribute the build.",
+						Required: true,
+					},
+				},
+				Action: func(ctx *cli.Context) error {
+					return updateRefs(ctx.Context, ctx.String(flagRepo), ctx.String(flagWorkspace), ctx.String(flagUser), ctx.String(flagEmail))
+				},
+			},
+		},
+		Usage: "build-images <subcommand>",
+	}
 
 	// We're using the task driver framework because it provides logging and
 	// helpful insight into what's occurring as the program runs.
@@ -41,60 +125,61 @@
 	ctx := td.StartRun(&fakeProjectId, &fakeTaskId, &fakeTaskName, &output, &local)
 	defer td.EndRun(ctx)
 
-	if *commit == "" {
-		td.Fatalf(ctx, "--commit is required.")
-	}
-	if *repo == "" {
-		td.Fatalf(ctx, "--repo is required.")
-	}
-	if *workspace == "" {
-		td.Fatalf(ctx, "--workspace is required.")
-	}
-	if len(*targets) == 0 {
-		td.Fatalf(ctx, "At least one --target is required.")
-	}
-	bazelTargetToImagePath := make(map[string]string, len(*targets))
-	for _, target := range *targets {
-		targetSplit := strings.Split(target, ":")
-		if len(targetSplit) != 3 {
-			td.Fatalf(ctx, "Invalid target specification %q; expected \"//bazel-target:bazel-target:gcr.io/image/path\"", target)
-		}
-		bazelTarget := strings.Join(targetSplit[:2], ":")
-		imagePath := targetSplit[2]
-		bazelTargetToImagePath[bazelTarget] = imagePath
-	}
-
-	// Create a shallow clone of the repo.
-	checkoutDir, err := shallowClone(ctx, *repo, *commit)
-	if err != nil {
+	// Run the app.
+	if err := app.RunContext(ctx, os.Args); err != nil {
 		td.Fatal(ctx, err)
 	}
+}
 
-	// Create the timestamped Docker image tag.
-	// 2022-09-21T13_13_46Z-louhi-02b6ac9-clean
-	ts := time.Now().UTC().Format("2006-01-02T15_04_05Z")
-	if *username == "" {
-		userObj, err := user.Current() // TODO(borenet): Will this work in Louhi?
-		if err != nil {
-			td.Fatal(ctx, err)
-		}
-		*username = userObj.Username
-	}
-	imageTag := fmt.Sprintf("%s-%s-%s-%s", ts, *username, (*commit)[:7], "clean")
+const (
+	// buildImagesJSONFile persists information about the images built by this
+	// program between invocations. This is necessary because the push to the
+	// image repository must be performed using the built-in Louhi stage in
+	// order to create the attestation which can be used to verify the image.
+	// After that is done, we invoke build-images again to obtain the sha256
+	// sum for each image (which can only be done after the push) and update
+	// the image references in Git.
+	buildImagesJSONFile = "build-images.json"
+)
 
-	// Perform the builds concurrently.
-	eg, ctx := errgroup.WithContext(ctx)
-	for bazelTarget, imagePath := range bazelTargetToImagePath {
-		// https://golang.org/doc/faq#closures_and_goroutines
-		bazelTarget := bazelTarget
-		louhiImageTag := fmt.Sprintf("louhi_ws/%s:%s", imagePath, imageTag)
-		eg.Go(func() error {
-			return bazelRun(ctx, checkoutDir, bazelTarget, louhiImageTag, *rbe)
-		})
+// buildImagesJSON describes the structure of buildImagesJSONFile.
+type buildImagesJSON struct {
+	Images []SingleImageInfo `json:"images"`
+}
+
+type SingleImageInfo struct {
+	Image  string `json:"image"`
+	Tag    string `json:"tag"`
+	Sha256 string `json:"sha256"`
+}
+
+// readBuildImagesJSON reads the buildImagesJSONFile.
+func readBuildImagesJSON(ctx context.Context, workspace string) (*buildImagesJSON, error) {
+	ctx = td.StartStep(ctx, td.Props(fmt.Sprintf("Read %s", buildImagesJSONFile)))
+	defer td.EndStep(ctx)
+
+	f := filepath.Join(workspace, buildImagesJSONFile)
+	var imageInfo buildImagesJSON
+	if err := util.WithReadFile(f, func(r io.Reader) error {
+		return json.NewDecoder(r).Decode(&imageInfo)
+	}); err != nil {
+		return nil, td.FailStep(ctx, err)
 	}
-	if err := eg.Wait(); err != nil {
-		td.Fatal(ctx, err)
+	return &imageInfo, nil
+}
+
+// writeBuildImagesJSON writes the buildImagesJSONFile.
+func writeBuildImagesJSON(ctx context.Context, workspace string, imageInfo *buildImagesJSON) error {
+	ctx = td.StartStep(ctx, td.Props(fmt.Sprintf("Read %s", buildImagesJSONFile)))
+	defer td.EndStep(ctx)
+
+	f := filepath.Join(workspace, buildImagesJSONFile)
+	if err := util.WithWriteFile(f, func(w io.Writer) error {
+		return json.NewEncoder(w).Encode(imageInfo)
+	}); err != nil {
+		return td.FailStep(ctx, err)
 	}
+	return nil
 }
 
 // shallowClone creates a shallow clone of the given repo at the given commit.
@@ -125,29 +210,3 @@
 	}
 	return checkoutDir, nil
 }
-
-// bazelTargetToDockerTag converts a Bazel target specification to a Docker
-// image tag which is applied to the image during the Bazel build.
-func bazelTargetToDockerTag(target string) string {
-	return path.Join("bazel", target)
-}
-
-// bazelRun executes `bazel run` for the given target and applies the given tag
-// to the resulting image.
-func bazelRun(ctx context.Context, cwd, target, louhiImageTag string, rbe bool) error {
-	ctx = td.StartStep(ctx, td.Props(fmt.Sprintf("Build %s", target)))
-	defer td.EndStep(ctx)
-
-	cmd := []string{"bazelisk", "run"}
-	if rbe {
-		cmd = append(cmd, "--config=remote", "--google_default_credentials")
-	}
-	cmd = append(cmd, target)
-	if _, err := exec.RunCwd(ctx, cwd, cmd...); err != nil {
-		return td.FailStep(ctx, err)
-	}
-	if _, err := exec.RunCwd(ctx, cwd, "docker", "tag", bazelTargetToDockerTag(target), louhiImageTag); err != nil {
-		return td.FailStep(ctx, err)
-	}
-	return nil
-}
diff --git a/cd/go/build-images/update-refs.go b/cd/go/build-images/update-refs.go
new file mode 100644
index 0000000..75cd4a9
--- /dev/null
+++ b/cd/go/build-images/update-refs.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"go.skia.org/infra/go/exec"
+	"go.skia.org/infra/go/gerrit/rubberstamper"
+	"go.skia.org/infra/go/git"
+	"go.skia.org/infra/go/gitauth"
+	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/task_driver/go/lib/git_steps"
+	"go.skia.org/infra/task_driver/go/td"
+)
+
+func updateRefs(ctx context.Context, repo, workspace, username, email string) error {
+	ctx = td.StartStep(ctx, td.Props("Update References"))
+	defer td.EndStep(ctx)
+
+	// Initialize git authentication.
+	ts, err := git_steps.Init(ctx, true)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+	if _, err := gitauth.New(ts, "/tmp/.gitcookies", true, email); err != nil {
+		return td.FailStep(ctx, err)
+	}
+
+	imageInfo, err := readBuildImagesJSON(ctx, workspace)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+
+	// First, obtain the sha256 sums for the images.
+	for _, image := range imageInfo.Images {
+		imageAndTag := fmt.Sprintf("%s:%s", image.Image, image.Tag)
+		if _, err := exec.RunCwd(ctx, ".", "docker", "pull", imageAndTag); err != nil {
+			return td.FailStep(ctx, err)
+		}
+		output, err := exec.RunCwd(ctx, ".", "docker", "inspect", "--format='{{index .RepoDigests 0}}'", imageAndTag)
+		if err != nil {
+			return td.FailStep(ctx, err)
+		}
+		split := strings.Split(strings.TrimSpace(output), "@")
+		if len(split) != 2 {
+			return td.FailStep(ctx, skerr.Fmt("Failed to obtain sha256 sum for %s; expected <image>@<sha256> but got %q", image.Image, output))
+		}
+		image.Sha256 = split[1]
+	}
+
+	// Create a shallow clone of the repo.
+	checkoutDir, err := shallowClone(ctx, repo, git.DefaultRef)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+
+	// Create a branch.
+	gitExec, err := git.Executable(ctx)
+	if err != nil {
+		return td.FailStep(ctx, err)
+	}
+	if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "checkout", "-b", "update", "-t", git.DefaultRemoteBranch); err != nil {
+		return td.FailStep(ctx, err)
+	}
+
+	// Find-and-replace each of the image references.
+	for _, image := range imageInfo.Images {
+		if _, err := exec.RunCwd(ctx, checkoutDir, "find", "./", "-type", "f", "-exec", "sed", "-r", "-i", fmt.Sprintf("s;%s@sha256:[a-f0-9]+;%s@sha256:%s;g", image.Image, image.Image, image.Sha256), "{}", "\\;"); err != nil {
+			return td.FailStep(ctx, err)
+		}
+	}
+
+	// Did we change anything?
+	if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "diff", "--exit-code"); err != nil {
+		// If so, create a CL.
+		imageList := make([]string, 0, len(imageInfo.Images))
+		for _, image := range imageInfo.Images {
+			imageList = append(imageList, image.Image)
+		}
+		commitMsg := fmt.Sprintf(`Update %s
+
+%s`, strings.Join(imageList, ", "), rubberstamper.RandomChangeID())
+		if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "commit", "-a", "-m", commitMsg); err != nil {
+			return td.FailStep(ctx, err)
+		}
+		if _, err := exec.RunCwd(ctx, checkoutDir, gitExec, "push", git.DefaultRemote, rubberstamper.PushRequestAutoSubmit); err != nil {
+			return td.FailStep(ctx, err)
+		}
+	}
+
+	return nil
+}