blob: fb2886b90fa25adfac5fde4baa2f9cede5768362 [file] [log] [blame]
// 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
}