|  | // pushk pushes a new version of an app. | 
|  | // | 
|  | // See flag.Usage for details. | 
|  | package main | 
|  |  | 
|  | import ( | 
|  | "context" | 
|  | "flag" | 
|  | "fmt" | 
|  | "io" | 
|  | "io/ioutil" | 
|  | "os" | 
|  | "path/filepath" | 
|  | "regexp" | 
|  | "runtime" | 
|  | "sort" | 
|  | "strings" | 
|  |  | 
|  | "go.skia.org/infra/go/skerr" | 
|  |  | 
|  | "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/gerrit/rubberstamper" | 
|  | "go.skia.org/infra/go/git" | 
|  | "go.skia.org/infra/go/kube/clusterconfig" | 
|  | "go.skia.org/infra/go/sklog" | 
|  | "go.skia.org/infra/go/util" | 
|  | ) | 
|  |  | 
|  | const ( | 
|  | // containerRegistryProject is the GCP project in which we store our Docker | 
|  | // images via Google Cloud Container Registry. | 
|  | containerRegistryProject = "skia-public" | 
|  |  | 
|  | // Max number of revisions of an image to print when using --list. | 
|  | maxListSize = 10 | 
|  |  | 
|  | // All dirty images are tagged with this suffix. | 
|  | dirtyImageTagSuffix = "-dirty" | 
|  | ) | 
|  |  | 
|  | 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. Searches through the checked in kubernetes yaml files to determine which use the image(s). | 
|  | 2. Modifies the kubernetes yaml files with the new version of the image(s). | 
|  | 3. Applies the changes with kubectl. | 
|  | 4. Commits the changes to the config repo via AutoSubmit, with approval by Rubber Stamper. | 
|  |  | 
|  | The config is stored in a separate repo that will automaticaly be checked out | 
|  | under /tmp by default, or the value of the PUSHK_GITDIR environment variable if set. | 
|  |  | 
|  | The command applies the changes by default, or just changes the local yaml files | 
|  | if --dry-run is supplied. | 
|  |  | 
|  | Examples: | 
|  | # Push 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 --only-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. | 
|  | # Note that the YAML file(s) will be updated, but not committed or pushed. | 
|  | pushk --dry-run docserver | 
|  |  | 
|  | ENV: | 
|  |  | 
|  | The config repo is checked out by default into '/tmp'. This can be | 
|  | changed by setting the environment variable PUSHK_GITDIR. | 
|  | `) | 
|  | flag.PrintDefaults() | 
|  | } | 
|  | } | 
|  |  | 
|  | // flags | 
|  | var ( | 
|  | onlyCluster             = flag.String("only-cluster", "", "If set then only push to the specified cluster.") | 
|  | configFile              = flag.String("config-file", "", "Absolute filename of the config.json file.") | 
|  | 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.") | 
|  | list                    = flag.Bool("list", false, "List the last few versions of the given image.") | 
|  | 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.") | 
|  | runningInK8s            = flag.Bool("running-in-k8s", false, "If true, then does not use flags that do not work in the k8s environment. Eg: '--cluster' when doing 'kubectl apply'.") | 
|  | doNotOverrideDirtyImage = flag.Bool("do-not-override-dirty-image", false, "If true, then do not push if the latest checkedin image is dirty. Caveat: This only checks the k8s-config repository to determine if image is dirty, it does not check the live running k8s containers.") | 
|  | overrideSHA256Digests   = flag.Bool("override-sha256-digests", false, "If true, then do not change images that reference sha256 digests. These are managed by continuous deployment and should usually be ignored.") | 
|  | useTempCheckout         = flag.Bool("use-temp-checkout", false, "If true, checks out the config repo into a temporary directory and pushes from there.") | 
|  | verbose                 = flag.Bool("verbose", false, "Verbose runtime diagnostics.") | 
|  | ) | 
|  |  | 
|  | 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, skerr.Fmt("Not enough tags returned.\n Saw %s", tags) | 
|  | } | 
|  | 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 "", skerr.Fmt("Supplying a fully qualified image name and the --rollback flag are mutually exclusive.") | 
|  | } | 
|  | if *list { | 
|  | return "", skerr.Fmt("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 "", skerr.Wrapf(err, "providing tags for image %s. Does this image exist?", imageName) | 
|  | } | 
|  |  | 
|  | // Filter the tags | 
|  | tags, err = filter(tags) | 
|  | if err != nil { | 
|  | return "", skerr.Wrapf(err, "filtering tags for image %s", imageName) | 
|  | } | 
|  |  | 
|  | 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 "", skerr.Fmt("No version of %s to rollback to.", imageName) | 
|  | } | 
|  | 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 | 
|  | } | 
|  |  | 
|  | // byClusterFromChanged returns a map from cluster name to the list of modified | 
|  | // files in that cluster. | 
|  | func byClusterFromChanged(gitDir string, changed util.StringSet) (map[string][]string, error) { | 
|  | // Find all the directory names, which are really cluster names. | 
|  | // filenames will be absolute directory names, e.g. | 
|  | // /tmp/k8s-config/skia-public/task-scheduler-be-staging.yaml | 
|  | byCluster := map[string][]string{} | 
|  |  | 
|  | // The first part of that is | 
|  | for _, filename := range changed.Keys() { | 
|  | // /tmp/k8s-config/skia-public/task-scheduler-be-staging.yaml => skia-public/task-scheduler-be-staging.yaml | 
|  | rel, err := filepath.Rel(gitDir, filename) | 
|  | if err != nil { | 
|  | return nil, err | 
|  | } | 
|  | // skia-public/task-scheduler-be-staging.yaml => skia-public | 
|  | cluster := filepath.Dir(rel) | 
|  | arr, ok := byCluster[cluster] | 
|  | if !ok { | 
|  | arr = []string{filename} | 
|  | } else { | 
|  | arr = append(arr, filename) | 
|  | } | 
|  | byCluster[cluster] = arr | 
|  | } | 
|  | return byCluster, nil | 
|  | } | 
|  |  | 
|  | func main() { | 
|  | common.Init() | 
|  |  | 
|  | ctx := context.Background() | 
|  | cfg, checkout, err := clusterconfig.NewWithCheckout(ctx, *configFile) | 
|  | if err != nil { | 
|  | sklog.Fatal(err) | 
|  | } | 
|  | if *useTempCheckout { | 
|  | tmp, err := git.NewTempCheckout(ctx, cfg.Repo) | 
|  | if err != nil { | 
|  | sklog.Fatal(err) | 
|  | } | 
|  | defer tmp.Delete() | 
|  | checkout = tmp.Checkout | 
|  | } | 
|  |  | 
|  | 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", checkout.Dir(), output) | 
|  | } | 
|  | } else { | 
|  | if err := checkout.UpdateBranch(ctx, git.MainBranch); err != nil { | 
|  | sklog.Fatal(err) | 
|  | } | 
|  | } | 
|  |  | 
|  | dirMatch := "*" | 
|  | if *onlyCluster != "" { | 
|  | dirMatch = *onlyCluster | 
|  | } | 
|  | glob := fmt.Sprintf("/%s/*.yaml", dirMatch) | 
|  | // Get all the yaml files. | 
|  | filenames, err := filepath.Glob(filepath.Join(checkout.Dir(), glob)) | 
|  | if err != nil { | 
|  | sklog.Fatal(err) | 
|  | } | 
|  |  | 
|  | tokenSource := auth.NewGCloudTokenSource(containerRegistryProject) | 
|  | imageNames := flag.Args() | 
|  | if len(imageNames) == 0 { | 
|  | fmt.Println("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) { | 
|  | tagsResp, err := gcr.NewClient(tokenSource, containerRegistryProject, imageName).Tags(ctx) | 
|  | if err != nil { | 
|  | return nil, err | 
|  | } | 
|  | return tagsResp.Tags, nil | 
|  | } | 
|  |  | 
|  | // Search through the yaml files looking for those that use the provided image names. | 
|  | 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) != 4 { | 
|  | continue | 
|  | } | 
|  | if *doNotOverrideDirtyImage && strings.HasSuffix(matches[3], dirtyImageTagSuffix) { | 
|  | sklog.Infof("%s is dirty. Not pushing to it since --do-not-override-dirty-image is set.", matches[3]) | 
|  | continue | 
|  | } | 
|  | if !*overrideSHA256Digests && strings.Contains(matches[3], "sha256:") { | 
|  | sklog.Infof("%s refers to an sha256 digest. Not pushing to it since --do-not-override-sha256-digests is set.", matches[3]) | 
|  | continue | 
|  | } | 
|  |  | 
|  | if *verbose { | 
|  | fmt.Printf("Changed file: %s to image: %s\n", filename, image) | 
|  | } | 
|  |  | 
|  | 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 { | 
|  | byCluster, err := byClusterFromChanged(checkout.Dir(), changed) | 
|  | if err != nil { | 
|  | sklog.Fatal(err) | 
|  | } | 
|  |  | 
|  | // Find the location of the attach.sh shell script. | 
|  | _, filename, _, _ := runtime.Caller(0) | 
|  | attachFilename := filepath.Join(filepath.Dir(filename), "../../attach.sh") | 
|  |  | 
|  | // Then loop over cluster names and apply all changed files for that | 
|  | // cluster. | 
|  | for cluster, files := range byCluster { | 
|  | if *verbose { | 
|  | fmt.Printf("Starting to apply changes to cluster: %s\n", cluster) | 
|  | } | 
|  |  | 
|  | filenameFlag := fmt.Sprintf("--filename=%s\n", strings.Join(files, ",")) | 
|  |  | 
|  | // By default run everything through infra/kube/attach.sh. | 
|  | name := attachFilename | 
|  | kubectlArgs := []string{cluster, "kubectl", "apply", filenameFlag} | 
|  | // But not if we are running in k8s. | 
|  | if *runningInK8s { | 
|  | name = "kubectl" | 
|  | kubectlArgs = []string{"apply", filenameFlag} | 
|  | } | 
|  | fmt.Printf("\n%s %s\n", name, strings.Join(kubectlArgs, " ")) | 
|  |  | 
|  | if !*dryRun { | 
|  | for filename := range changed { | 
|  | // /tmp/k8s-config/skia-public/task-scheduler-be-staging.yaml => skia-public/task-scheduler-be-staging.yaml | 
|  | rel, err := filepath.Rel(checkout.Dir(), filename) | 
|  | if err != nil { | 
|  | sklog.Fatal(err) | 
|  | } | 
|  | msg, err := checkout.Git(ctx, "add", rel) | 
|  | if err != nil { | 
|  | sklog.Fatalf("Failed to stage changes to the config repo: %s: %q", err, msg) | 
|  | } | 
|  | } | 
|  |  | 
|  | if err := exec.Run(context.Background(), &exec.Command{ | 
|  | Name:      name, | 
|  | Args:      kubectlArgs, | 
|  | LogStderr: true, | 
|  | LogStdout: true, | 
|  | }); err != nil { | 
|  | sklog.Errorf("Failed to run: %s", err) | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | // Once everything is pushed, then commit and push the changes. | 
|  | 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 | 
|  | } | 
|  |  | 
|  | if *message == "" { | 
|  | *message = "Push" | 
|  | } | 
|  |  | 
|  | messageWithBody := *message + "\n\n" + rubberstamper.RandomChangeID() | 
|  | msg, err = checkout.Git(ctx, "commit", "-m", messageWithBody) | 
|  | if err != nil { | 
|  | sklog.Fatalf("Failed to commit to the config repo: %s: %q", err, msg) | 
|  | } | 
|  |  | 
|  | msg, err = checkout.Git(ctx, "push", git.DefaultRemote, rubberstamper.PushRequestAutoSubmit) | 
|  | if err != nil { | 
|  | sklog.Fatalf("Failed to push the config repo: %s: %q", err, msg) | 
|  | } | 
|  | } else { | 
|  | fmt.Println("Nothing to do.") | 
|  | } | 
|  | } |