Add script to delete old docker images

It takes a day as the "oldest image to keep around" and
supports a minimum number of images to keep around.

Bug: skia:
Change-Id: I2f236cb1e8e86fc5a8ea3c897a0ae3bd309699f0
Reviewed-on: https://skia-review.googlesource.com/c/180766
Commit-Queue: Kevin Lubick <kjlubick@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/scripts/gcr_image_cleanup/gcr_image_cleanup.go b/scripts/gcr_image_cleanup/gcr_image_cleanup.go
new file mode 100644
index 0000000..8aa51b8
--- /dev/null
+++ b/scripts/gcr_image_cleanup/gcr_image_cleanup.go
@@ -0,0 +1,145 @@
+package main
+
+// This script will clear out Docker images in gcr.io that are older than the
+// specified date, leaving at least min_images remaining. The min_images
+// supersedes the age.
+
+import (
+	"bytes"
+	"context"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"go.skia.org/infra/go/exec"
+)
+
+var (
+	project   = flag.String("project", "", "[REQUIRED] The GCP project to clean up images.")
+	olderThan = flag.String("older_than", "", "[REQUIRED] Date in YYYY-MM-DD of newest image to get rid of.")
+	minImages = flag.Int("min_images", 10, "Minimum number of images to keep around, ignoring age.")
+	dryRun    = flag.Bool("dry_run", false, "Print out those images that would be deleted instead of actually deleting them.")
+)
+
+const YMD_FORMAT = "2006-01-02"
+
+func main() {
+	flag.Parse()
+	if *project == "" || *olderThan == "" {
+		fmt.Println("--project and --older_than are required")
+		flag.PrintDefaults()
+		os.Exit(1)
+	}
+
+	oldestDate, err := time.Parse(YMD_FORMAT, *olderThan)
+	if err != nil {
+		fmt.Println("Date must be in YYYY-MM-DD format")
+		os.Exit(1)
+	}
+
+	fmt.Println("Fetching images in project")
+	output := bytes.Buffer{}
+	err = exec.Run(context.Background(), &exec.Command{
+		Name:   "gcloud",
+		Args:   []string{"--project", *project, "container", "images", "list"},
+		Stdout: &output,
+	})
+	if err != nil {
+		fmt.Printf("Could not retrieve images: %s\n", err)
+		os.Exit(1)
+	}
+
+	images := strings.Split(strings.TrimSpace(output.String()), "\n")
+	fmt.Printf("Found %d images\n", len(images))
+	fmt.Println("Deleting old images")
+
+	for _, image := range images {
+		if !strings.HasPrefix(image, "gcr.io") {
+			// Skip the header and any footer
+			continue
+		}
+		olderThan := oldestDate
+
+		output := bytes.Buffer{}
+		err = exec.Run(context.Background(), &exec.Command{
+			Name: "gcloud",
+			Args: []string{"--project", *project, "container", "images", "list-tags",
+				image, "--sort-by=~TIMESTAMP", fmt.Sprintf("--limit=%d", *minImages),
+				"--format=csv(timestamp.year,timestamp.month,timestamp.day)"},
+			Stdout: &output,
+		})
+		if err != nil {
+			fmt.Printf("Could not retrieve image tags for %s: %s\n", image, err)
+			os.Exit(1)
+		}
+		// output now looks like
+		//year,month,day
+		// 2019,1,3
+		// 2019,1,2
+		// 2018,12,28
+
+		// trim off the header
+		newestDates := strings.Split(strings.TrimSpace(output.String()), "\n")[1:]
+		if len(newestDates) < *minImages {
+			fmt.Printf("%s has fewer than %d tags (%d), skipping\n", image, *minImages, len(newestDates))
+			continue
+		}
+		// Look at when nth newest image, if it is before the min timeline, we use
+		// 1 day before that nth newest image, just to be safe.
+		nthNewest := newestDates[len(newestDates)-1]
+		ymd := strings.Split(nthNewest, ",")
+		if altDate := time.Date(safeAtoI(ymd[0]), time.Month(safeAtoI(ymd[1])), safeAtoI(ymd[2]), 0, 0, 0, 0, time.UTC); altDate.Equal(olderThan) || altDate.Before(olderThan) {
+			olderThan = altDate.AddDate(0, 0, -1)
+		}
+
+		output = bytes.Buffer{}
+		err = exec.Run(context.Background(), &exec.Command{
+			Name: "gcloud",
+			Args: []string{"--project", *project, "container", "images", "list-tags",
+				image, "--sort-by=TIMESTAMP", "--limit=999999",
+				"--filter=timestamp.datetime < " + olderThan.Format(YMD_FORMAT),
+				"--format=get(digest)"},
+			Stdout: &output,
+		})
+		if err != nil {
+			fmt.Printf("Could not retrieve image tags for %s: %s\n", image, err)
+			os.Exit(1)
+		}
+		toDelete := strings.Split(strings.TrimSpace(output.String()), "\n")
+
+		fmt.Printf("=== Will delete %d containers from %s\n", len(toDelete), image)
+
+		for _, digest := range toDelete {
+			i := fmt.Sprintf("%s@%s", image, digest)
+			if *dryRun {
+				fmt.Println("dry run delete ", i)
+			} else {
+				err = exec.Run(context.Background(), &exec.Command{
+					Name: "gcloud",
+					Args: []string{"--project", *project, "container", "images", "delete",
+						"--quiet", "--force-delete-tags",
+						i},
+					LogStderr: true,
+					LogStdout: true,
+				})
+				if err != nil {
+					fmt.Printf("error while deleting %s, continuing anyway: %s\n", i, err)
+				} else {
+					fmt.Println("deleted ", i)
+				}
+			}
+		}
+	}
+}
+
+func safeAtoI(s string) int {
+	i, err := strconv.Atoi(s)
+	if err != nil {
+		log.Fatal(err)
+	}
+	return i
+}