| // Copyright 2018 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Package gce contains high-level functionality for manipulating GCE resources. |
| package gce |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "net/http" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
| |
| "cos.googlesource.com/cos/tools.git/src/pkg/config" |
| |
| compute "google.golang.org/api/compute/v1" |
| "google.golang.org/api/googleapi" |
| ) |
| |
| const ( |
| defaultOperationTimeout = time.Duration(600) * time.Second |
| defaultRetryInterval = time.Duration(5) * time.Second |
| gcsURLPrefix = "https://storage.googleapis.com" |
| ) |
| |
| type timePkg struct { |
| Now func() time.Time |
| Sleep func(time.Duration) |
| } |
| |
| var ( |
| // ErrTimeout indicates that an operation timed out. |
| ErrTimeout = errors.New("operation timed out") |
| |
| // ErrImageNotFound indicates that a GCE image could not be found |
| ErrImageNotFound = errors.New("image not found") |
| |
| realTime = &timePkg{time.Now, time.Sleep} |
| |
| // This should match <prefix>-<channel>-<milestone>-<buildnumber>. |
| // This is the format of images in cos-cloud. |
| // Example: cos-dev-72-11172-0-0 |
| imageNameRegex = regexp.MustCompile("[a-z0-9-]+-[a-z]+-([0-9]+)-([0-9]+-[0-9]+-[0-9]+)") |
| ) |
| |
| // buildDeprecationStatus constructs a *compute.DeprecationStatus struct used in a Deprecate GCE API |
| // call. It fills in the structure with the "DEPRECATED" state, the given replacement, and the given |
| // delete time, if provided. |
| func buildDeprecationStatus(replacement string, deleteTime time.Time) *compute.DeprecationStatus { |
| status := &compute.DeprecationStatus{State: "DEPRECATED", Replacement: replacement} |
| if !deleteTime.IsZero() { |
| status.Deleted = deleteTime.Format(time.RFC3339) |
| } |
| return status |
| } |
| |
| func waitForOp(svc *compute.Service, project string, op *compute.Operation, deadline time.Time, t *timePkg) error { |
| if op.Error != nil { |
| return fmt.Errorf("error with operation. name: %s error: %v", op.Name, op.Error) |
| } |
| if op.Status == "DONE" { |
| return nil |
| } |
| for { |
| t.Sleep(defaultRetryInterval) |
| op, err := svc.GlobalOperations.Get(project, op.Name).Do() |
| if err != nil { |
| return err |
| } |
| if op.Error != nil { |
| return fmt.Errorf("error with operation. name: %s error: %v", op.Name, op.Error) |
| } |
| if op.Status == "DONE" { |
| return nil |
| } |
| if t.Now().After(deadline) { |
| return ErrTimeout |
| } |
| } |
| } |
| |
| func waitForOps(svc *compute.Service, project string, ops []*compute.Operation, t *timePkg) error { |
| deadline := t.Now().Add(defaultOperationTimeout) |
| for _, op := range ops { |
| if err := waitForOp(svc, project, op, deadline, t); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| |
| func deprecateInFamily(ctx context.Context, svc *compute.Service, newImage *config.Image, ttl int, t *timePkg) error { |
| if newImage.Family == "" { |
| return fmt.Errorf("input image does not have a family for deprecateInFamily. image: %v", newImage) |
| } |
| filter := fmt.Sprintf("(family = %s) (name != %s)", newImage.Family, newImage.Name) |
| images := []*compute.Image{} |
| err := svc.Images.List(newImage.Project).Filter(filter).Pages(ctx, func(imageList *compute.ImageList) error { |
| images = append(images, imageList.Items...) |
| return nil |
| }) |
| if err != nil { |
| return err |
| } |
| ops := []*compute.Operation{} |
| for _, image := range images { |
| if image.Deprecated != nil { |
| continue |
| } |
| deleteTime := time.Time{} |
| if ttl > 0 { |
| deleteTime = t.Now().Add(time.Duration(ttl) * time.Second) |
| } |
| status := buildDeprecationStatus(newImage.URL(), deleteTime) |
| op, err := svc.Images.Deprecate(newImage.Project, image.Name, status).Do() |
| if err != nil { |
| return err |
| } |
| ops = append(ops, op) |
| } |
| return waitForOps(svc, newImage.Project, ops, t) |
| } |
| |
| // DeprecateInFamily deprecates all of the old images in an image family. |
| // Allows for assigning TTLs (in seconds) to deprecated images. |
| func DeprecateInFamily(ctx context.Context, svc *compute.Service, newImage *config.Image, ttl int) error { |
| return deprecateInFamily(ctx, svc, newImage, ttl, realTime) |
| } |
| |
| // ImageExists checks to see if the given image exists in the given project. |
| func ImageExists(svc *compute.Service, project, name string) (bool, error) { |
| if _, err := svc.Images.Get(project, name).Do(); err != nil { |
| if e, ok := err.(*googleapi.Error); ok && e.Code == http.StatusNotFound { |
| return false, nil |
| } |
| return false, err |
| } |
| return true, nil |
| } |
| |
| // CreateImage creates an image with imageName with the source-url from gcs storage |
| func CreateImage(svc *compute.Service, sourceURL, imageName, imageProject string) error { |
| gcsImageURL := fmt.Sprintf("%s/%s", gcsURLPrefix, sourceURL) |
| image := &compute.Image{ |
| Name: imageName, |
| RawDisk: &compute.ImageRawDisk{ |
| Source: gcsImageURL, |
| }, |
| } |
| createImageOp, err := svc.Images.Insert(imageProject, image).Do() |
| if err != nil { |
| return err |
| } |
| deadline := time.Now().Add(defaultOperationTimeout) |
| return waitForOp(svc, imageProject, createImageOp, deadline, realTime) |
| } |
| |
| type decodedImageName struct { |
| name string |
| milestone int |
| buildNumber string |
| } |
| |
| // newDecodedImageName decodes an image name from cos-cloud and returns |
| // image information encoded in that image name. |
| func newDecodedImageName(name string) (*decodedImageName, error) { |
| match := imageNameRegex.FindStringSubmatch(name) |
| if match == nil { |
| return nil, fmt.Errorf("could not parse name %s", name) |
| } |
| milestone, err := strconv.Atoi(match[1]) |
| if err != nil { |
| return nil, fmt.Errorf("could not convert %s to a milestone: %s", match[1], err) |
| } |
| return &decodedImageName{name, milestone, match[2]}, nil |
| } |
| |
| func imageCompare(first, second *decodedImageName) bool { |
| if first.milestone != second.milestone { |
| return first.milestone < second.milestone |
| } |
| for i := 0; i < 3; i++ { |
| // Because of how decodedImageNames are created (see newDecodedImageName), |
| // these atoi operations are guaranteed to work. |
| firstNum, _ := strconv.Atoi(strings.Split(first.buildNumber, "-")[i]) |
| secondNum, _ := strconv.Atoi(strings.Split(second.buildNumber, "-")[i]) |
| if firstNum != secondNum { |
| return firstNum < secondNum |
| } |
| } |
| return false |
| } |
| |
| // ResolveMilestone gets the name of the latest COS image on the given milestone. |
| // This resolution is done by looking at the image names in cos-cloud. |
| func ResolveMilestone(ctx context.Context, svc *compute.Service, milestone int) (string, error) { |
| var images []*compute.Image |
| err := svc.Images.List("cos-cloud").Pages(ctx, func(imageList *compute.ImageList) error { |
| images = append(images, imageList.Items...) |
| return nil |
| }) |
| if err != nil { |
| return "", err |
| } |
| var inMilestone []*decodedImageName |
| for _, image := range images { |
| decoded, err := newDecodedImageName(image.Name) |
| if err != nil { |
| continue |
| } |
| if decoded.milestone == milestone { |
| inMilestone = append(inMilestone, decoded) |
| } |
| } |
| if len(inMilestone) == 0 { |
| return "", ErrImageNotFound |
| } |
| sort.Slice(inMilestone, func(i, j int) bool { |
| return imageCompare(inMilestone[i], inMilestone[j]) |
| }) |
| return inMilestone[len(inMilestone)-1].name, nil |
| } |