blob: 8032a729bddf622abbaec02d480e2fe0b80264ab [file] [log] [blame]
// pushk pushes a new version of an app.
//
// pushk docserver
// pushk --rollback docserver
// pushk --cluster=skia-public docserver
// pushk --rollback --cluster=skia-corp docserver
// pushk --dry-run my-new-service
package main
import (
"context"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/gcr"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
)
const (
repoURLTemplate = "https://skia.googlesource.com/%s-config"
repoBaseDir = "/tmp"
repoDirTemplate = "/tmp/%s-config"
containerRegistryProject = "skia-public"
maxListSize = 10
)
// Project is used to map cluster name to GCE project info.
type Project struct {
Zone string
Project string // The full project name, e.g. google.com:skia-corp.
}
var (
clusters = map[string]*Project{
"skia-public": {
Zone: "us-central1-a",
Project: "skia-public",
},
"skia-corp": {
Zone: "us-central1-a",
Project: "google.com:skia-corp",
},
}
)
func init() {
flag.Usage = func() {
fmt.Printf("Usage: pushk <flags> [one or more image names]\n\n")
fmt.Printf(`pushk pushes a new version of an app.
The command:
1. Modifies the kubernetes yaml files with the new image.
2. Commits the changes to the config repo.
3. Applies the changes with kubectl.
The config is stored in a separate repo that will automaticaly be checked out
under /tmp.
The command applies the changes by default, or just changes the local yaml files
if --dry-run is supplied.
Examples:
# Pusk an exact tag.
pushk gcr.io/skia-public/fiddler:694900e3ca9468784a5794dc53382d1c8411ab07
# Push the latest version of docserver.
pushk docserver --message="Fix bug #1234"
# Push the latest version of docserver to the skia-corp cluster.
pushk docserver --cluster=skia-corp --message="Fix bug #1234"
# Push the latest version of docserver and iap-proxy
pushk docserver iap-proxy
# Rollback docserver.
pushk --rollback docserver
# List the last few versions of the docserver image. Doesn't apply anything.
pushk --list docserver
# Compute any changes a push to docserver will make, but do not apply them.
pushk --dry-run docserver
`)
flag.PrintDefaults()
}
}
// toFullRepoURL converts the project name into a git repo URL.
func toFullRepoURL(s string) string {
return fmt.Sprintf(repoURLTemplate, s)
}
// toRepoDir converts the project name into a git repo directory name.
func toRepoDir(s string) string {
return fmt.Sprintf(repoDirTemplate, s)
}
// flags
var (
cluster = flag.String("cluster", "skia-public", "Either 'skia-public' or 'skia-corp'.")
dryRun = flag.Bool("dry-run", false, "If true then do not run the kubectl command to apply the changes, and do not commit the changes to the config repo.")
ignoreDirty = flag.Bool("ignore-dirty", false, "If true, then do not fail out if the git repo is dirty.")
message = flag.String("message", "Push", "Message to go along with the change.")
rollback = flag.Bool("rollback", false, "If true go back to the second most recent image, otherwise use most recent image.")
list = flag.Bool("list", false, "List the last few versions of the given image.")
)
var (
validTag = regexp.MustCompile(`^\d\d\d\d-\d\d-\d\dT\d\d_\d\d_\d\dZ-.+$`)
)
// filter strips the list of tags down to only the ones that conform to our
// constraints and also checks that there are enough tags. The results
// are sorted in ascending order, so oldest tags are first, newest tags
// are last.
func filter(tags []string) ([]string, error) {
validTags := []string{}
for _, t := range tags {
if validTag.MatchString(t) {
validTags = append(validTags, t)
}
}
sort.Strings(validTags)
if len(validTags) == 0 {
return nil, fmt.Errorf("Not enough tags returned.")
}
return validTags, nil
}
// tagProvider is a type that returns the correct tag to push for the given imageName.
type tagProvider func(imageName string) ([]string, error)
// imageFromCmdLineImage handles image names, which can be either short, ala 'fiddler', or exact,
// such as gcr.io/skia-public/fiddler:694900e3ca9468784a5794dc53382d1c8411ab07, both of which can
// appear on the command-line.
func imageFromCmdLineImage(imageName string, tp tagProvider) (string, error) {
if strings.HasPrefix(imageName, "gcr.io/") {
if *rollback {
return "", fmt.Errorf("Supplying a fully qualified image name and the --rollback flag are mutually exclusive.")
}
if *list {
return "", fmt.Errorf("Supplying a fully qualified image name and the --list flag are mutually exclusive.")
}
return imageName, nil
}
// Get all the tags for the selected image.
tags, err := tp(imageName)
if err != nil {
return "", fmt.Errorf("Tag provider failed: %s", err)
}
// Filter the tags
tags, err = filter(tags)
if err != nil {
return "", fmt.Errorf("Failed to filter: %s", err)
}
if *list {
if len(tags) > maxListSize {
tags = tags[len(tags)-maxListSize:]
}
for _, tag := range tags {
fmt.Println(tag)
}
}
// Pick the target tag we want to move to.
tag := tags[len(tags)-1]
if *rollback {
if len(tags) < 2 {
return "", fmt.Errorf("No version to rollback to.")
}
tag = tags[len(tags)-2]
}
// The full docker image name and tag of the image we want to deploy.
return fmt.Sprintf("%s/%s/%s:%s", gcr.SERVER, containerRegistryProject, imageName, tag), nil
}
func main() {
common.Init()
ctx := context.Background()
repoDir := toRepoDir(*cluster)
checkout, err := git.NewCheckout(ctx, toFullRepoURL(*cluster), repoBaseDir)
if err != nil {
sklog.Fatalf("Failed to check out config repo: %s", err)
}
output, err := checkout.Git(ctx, "status", "-s")
if err != nil {
sklog.Fatal(err)
}
if strings.TrimSpace(output) != "" {
if !*ignoreDirty {
sklog.Fatalf("Found dirty checkout in %s:\n%s", repoBaseDir, output)
}
} else {
if err := checkout.Update(ctx); err != nil {
sklog.Fatal(err)
}
}
// Switch kubectl to the right project.
p := clusters[*cluster]
if p == nil {
fmt.Printf("Invalid value for --cluster flag: %q", *cluster)
flag.Usage()
os.Exit(1)
}
if err := exec.Run(context.Background(), &exec.Command{
Name: "gcloud",
Args: []string{
"container",
"clusters",
"get-credentials",
*cluster,
"--zone",
p.Zone,
"--project",
p.Project,
},
LogStderr: true,
LogStdout: true,
}); err != nil {
sklog.Errorf("Failed to run: %s", err)
}
// Get all the yaml files.
filenames, err := filepath.Glob(filepath.Join(repoDir, "*.yaml"))
if err != nil {
sklog.Fatal(err)
}
tokenSource := auth.NewGCloudTokenSource(containerRegistryProject)
imageNames := flag.Args()
if len(imageNames) == 0 {
fmt.Printf("At least one image name needs to be supplied.")
flag.Usage()
os.Exit(1)
}
sklog.Infof("Pushing the following images: %q", imageNames)
gcrTagProvider := func(imageName string) ([]string, error) {
return gcr.NewClient(tokenSource, containerRegistryProject, imageName).Tags()
}
changed := util.StringSet{}
for _, imageName := range imageNames {
image, err := imageFromCmdLineImage(imageName, gcrTagProvider)
if err != nil {
sklog.Fatal(err)
}
if *list {
// imageFromCmdLineImage printed out the tags, so nothing more to do.
continue
}
// imageRegex has the following groups returned on match:
// 0 - the entire line
// 1 - the prefix, i.e. image:, with correct spacing.
// 2 - full image name
// 3 - just the tag
//
// We pull out the 'prefix' so we can use it when
// we rewrite the image: line so the indent level is
// unchanged.
parts := strings.SplitN(image, ":", 2)
if len(parts) != 2 {
sklog.Fatalf("Failed to split imageName: %v", parts)
}
imageNoTag := parts[0]
imageRegex := regexp.MustCompile(fmt.Sprintf(`^(\s+image:\s+)(%s):.*$`, imageNoTag))
// Loop over all the yaml files and update tags for the given imageName.
for _, filename := range filenames {
b, err := ioutil.ReadFile(filename)
if err != nil {
sklog.Errorf("Failed to read %q (skipping): %s", filename, err)
continue
}
lines := strings.Split(string(b), "\n")
for i, line := range lines {
matches := imageRegex.FindStringSubmatch(line)
if len(matches) != 3 {
continue
}
changed[filename] = true
lines[i] = matches[1] + image
}
if changed[filename] {
err := util.WithWriteFile(filename, func(w io.Writer) error {
_, err := w.Write([]byte(strings.Join(lines, "\n")))
return err
})
if err != nil {
sklog.Fatalf("Failed to write update config file %q: %s", filename, err)
}
}
}
}
// Were any files updated?
if len(changed) != 0 {
filenameFlag := fmt.Sprintf("--filename=%s\n", strings.Join(changed.Keys(), ","))
if !*dryRun {
for filename := range changed {
msg, err := checkout.Git(ctx, "add", filepath.Base(filename))
if err != nil {
sklog.Fatalf("Failed to stage changes to the config repo: %s: %q", err, msg)
}
}
msg, err := checkout.Git(ctx, "diff", "--cached", "--name-only")
if err != nil {
sklog.Fatalf("Failed to diff :%s: %q", err, msg)
}
if msg == "" {
sklog.Infof("Not pushing since no files changed.")
return
}
msg, err = checkout.Git(ctx, "commit", "-m", *message)
if err != nil {
sklog.Fatalf("Failed to commit to the config repo: %s: %q", err, msg)
}
msg, err = checkout.Git(ctx, "push", "origin", "master")
if err != nil {
sklog.Fatalf("Failed to push the config repo: %s: %q", err, msg)
}
if err := exec.Run(context.Background(), &exec.Command{
Name: "kubectl",
Args: []string{"apply", filenameFlag},
LogStderr: true,
LogStdout: true,
}); err != nil {
sklog.Errorf("Failed to run: %s", err)
}
} else {
fmt.Printf("\nkubectl apply %s\n", filenameFlag)
}
} else {
fmt.Println("Nothing to do.")
}
}