| // Copyright 2021 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package main |
| |
| import ( |
| "context" |
| "flag" |
| "fmt" |
| "log" |
| "os" |
| "sort" |
| "time" |
| |
| "google.golang.org/api/appengine/v1" |
| ) |
| |
| var ( |
| dryRun = flag.Bool("dry_run", true, "print but do not run changes") |
| keepDuration = flag.Duration("keep_duration", 24*time.Hour, "keep versions with age < `t`") |
| keepNumber = flag.Int("keep_number", 5, "keep at least `n` versions") |
| project = flag.String("project", "", "GCP project `name` (required)") |
| service = flag.String("service", "", "AppEngine service `name` (required)") |
| ) |
| |
| func usage() { |
| fmt.Fprintf(os.Stderr, "usage: versionprune -project=name -service=name [options]\n") |
| flag.PrintDefaults() |
| os.Exit(2) |
| } |
| |
| func main() { |
| flag.Usage = usage |
| flag.Parse() |
| log.SetPrefix("versionprune: ") |
| log.SetFlags(0) |
| |
| if *project == "" || *service == "" { |
| usage() |
| } |
| if *keepDuration < 0 { |
| log.Fatalf("-keep_duration=%v must be >= 0", *keepDuration) |
| } |
| if *keepNumber < 0 { |
| log.Fatalf("-keep_number=%d must be >= 0", *keepNumber) |
| } |
| |
| if err := run(context.Background()); err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| os.Exit(2) |
| } |
| } |
| |
| // version is an intermediate representation of an AppEngine version. |
| type version struct { |
| AEVersion *appengine.Version |
| // Message is a human-readable message of why a version was bucketed. |
| Message string |
| // Created is a parsed time of the AppEngine Version CreateTime. |
| Created time.Time |
| } |
| |
| // run fetches, processes, and (if DryRun is false) deletes stale AppEngine versions for the specified Service. |
| func run(ctx context.Context) error { |
| aes, err := appengine.NewService(ctx) |
| if err != nil { |
| return fmt.Errorf("creating appengine client: %w", err) |
| } |
| ass := appengine.NewAppsServicesService(aes) |
| asvs := appengine.NewAppsServicesVersionsService(aes) |
| |
| s, err := ass.Get(*project, *service).Do() |
| if err != nil { |
| return fmt.Errorf("fetching service: %w", err) |
| } |
| |
| vs, err := getVersions(ctx, asvs) |
| if err != nil { |
| return fmt.Errorf("fetching versions: %w", err) |
| } |
| |
| bs, err := bucket(s.Split.Allocations, vs, *keepNumber, *keepDuration) |
| if err != nil { |
| return fmt.Errorf("bucketing versions: %w", err) |
| } |
| printIntent(bs) |
| if err := act(asvs, bs, *dryRun); err != nil { |
| return fmt.Errorf("executing: %w", err) |
| } |
| return nil |
| } |
| |
| func getVersions(ctx context.Context, asvs *appengine.AppsServicesVersionsService) ([]*appengine.Version, error) { |
| var versions []*appengine.Version |
| err := asvs.List(*project, *service).Pages(ctx, func(r *appengine.ListVersionsResponse) error { |
| versions = append(versions, r.Versions...) |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| sort.Slice(versions, func(i, j int) bool { |
| // Sort by create time, descending. |
| return versions[i].CreateTime > versions[j].CreateTime |
| }) |
| return versions, nil |
| } |
| |
| type buckets struct { |
| keep []version |
| delete []version |
| } |
| |
| // bucket splits c.versions into intended actions. |
| func bucket(allocs map[string]float64, versions []*appengine.Version, keepNumber int, keepDuration time.Duration) (buckets, error) { |
| var bs buckets |
| for _, av := range versions { |
| v := version{AEVersion: av} |
| created, err := time.Parse(time.RFC3339, av.CreateTime) |
| if err != nil { |
| return bs, fmt.Errorf("failed to parse time %q for version %s: %v", av.CreateTime, av.Id, err) |
| } |
| v.Created = created |
| if s, ok := allocs[av.Id]; ok { |
| v.Message = fmt.Sprintf("version is serving traffic. split: %v%%", s*100) |
| bs.keep = append(bs.keep, v) |
| continue |
| } |
| if len(bs.keep) < keepNumber { |
| v.Message = fmt.Sprintf("keeping the latest %d versions (%d)", keepNumber, len(bs.keep)) |
| bs.keep = append(bs.keep, v) |
| continue |
| } |
| if dur := time.Since(v.Created); dur < keepDuration { |
| v.Message = fmt.Sprintf("keeping recent versions (%s)", dur) |
| bs.keep = append(bs.keep, v) |
| continue |
| } |
| v.Message = "bye" |
| bs.delete = append(bs.delete, v) |
| } |
| return bs, nil |
| } |
| |
| func printIntent(bs buckets) { |
| fmt.Printf("target project:\t[%v]\n", *project) |
| fmt.Printf("target service:\t[%v]\n", *service) |
| fmt.Printf("versions:\t(%v)\n", len(bs.delete)+len(bs.keep)) |
| fmt.Println() |
| |
| fmt.Printf("versions to keep (%v): [\n", len(bs.keep)) |
| for _, v := range bs.keep { |
| fmt.Printf("\t%v: %v\n", v.AEVersion.Id, v.Message) |
| } |
| fmt.Println("]") |
| |
| fmt.Printf("versions to delete (%v): [\n", len(bs.delete)) |
| for _, v := range bs.delete { |
| fmt.Printf("\t%v: %v\n", v.AEVersion.Id, v.Message) |
| } |
| fmt.Println("]") |
| } |
| |
| // act performs delete requests for AppEngine services to be deleted. No deletions are performed if DryRun is true. |
| func act(asvs *appengine.AppsServicesVersionsService, bs buckets, dryRun bool) error { |
| for _, v := range bs.delete { |
| if dryRun { |
| fmt.Printf("dry-run: skipping delete %v: %v\n", v.AEVersion.Id, v.Message) |
| continue |
| } |
| fmt.Printf("deleting %v/%v/%v\n", *project, *service, v.AEVersion.Id) |
| if _, err := asvs.Delete(*project, *service, v.AEVersion.Id).Do(); err != nil { |
| return fmt.Errorf("error deleting %v/%v/%v: %w", *project, *service, v.AEVersion.Id, err) |
| } |
| } |
| return nil |
| } |