cli_tools/gce_image_publish/publish/publish.go (530 lines of code) (raw):

// Copyright 2019 Google Inc. All Rights Reserved. // // 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 publish defines the publish object and utilities to create daisy workflows // from a publish object. package publish import ( "bytes" "context" "encoding/json" "errors" "fmt" "io/ioutil" "path" "regexp" "sort" "strconv" "strings" "text/template" "time" "cloud.google.com/go/compute/metadata" daisy "github.com/GoogleCloudPlatform/compute-daisy" daisyCompute "github.com/GoogleCloudPlatform/compute-daisy/compute" computeAlpha "google.golang.org/api/compute/v0.alpha" "google.golang.org/api/compute/v1" "google.golang.org/api/option" ) // Publish holds info to create a daisy workflow for gce_image_publish type Publish struct { // Name for this publish workflow, passed to Daisy as workflow name. Name string `json:",omitempty"` // Project to perform the work in, passed to Daisy as workflow project. WorkProject string `json:",omitempty"` // Project to source images from, should not be used with SourceGCSPath. SourceProject string `json:",omitempty"` // GCS path to source images from, should not be used with SourceProject. SourceGCSPath string `json:",omitempty"` // Project to publish images to. PublishProject string `json:",omitempty"` // Optional compute endpoint override ComputeEndpoint string `json:",omitempty"` // Optional period of time to keep images, any images with an create time // older than this period will be deleted. // Format consists of 2 sections, the first must parsable by // https://golang.org/pkg/time/#ParseDuration, the second is a multiplier // separated by '*'. // 24h = 1 day // 24h*7 = 1 week // 24h*7*4 = ~1 month // 24h*365 = ~1 year DeleteAfter string `json:",omitempty"` expiryDate *time.Time // Images to Images []*Image `json:",omitempty"` // Populated from the source_version flag, added to the image prefix to // lookup source image. sourceVersion string // Populated from the publish_version flag, added to the image prefix to // create the publish name. publishVersion string toCreate []string toDelete []string toDeprecate []string toObsolete []string toUndeprecate []string rolloutPolicy []string imagesCache map[string][]*computeAlpha.Image } // Image is a metadata holder for the image to be published/rollback type Image struct { // Prefix for the image, image naming format is '${ImagePrefix}-${ImageVersion}'. // This prefix is used for source image lookup and publish image name. Prefix string `json:",omitempty"` // Image family to set for the image. Family string `json:",omitempty"` // Image description to set for the image. Description string `json:",omitempty"` // Architecture to set for the image. Architecture string `json:",omitempty"` // Licenses to add to the image. Licenses []string `json:",omitempty"` // GuestOsFeatures to add to the image. GuestOsFeatures []string `json:",omitempty"` // Ignores license validation if 403/forbidden returned IgnoreLicenseValidationIfForbidden bool `json:",omitempty"` // Optional DeprecationStatus.Obsolete entry for the image (RFC 3339). ObsoleteDate *time.Time `json:",omitempty"` // Optional ShieldedInstanceInitialState entry for secure-boot feature. ShieldedInstanceInitialState *computeAlpha.InitialStateConfig `json:",omitempty"` // RolloutPolicy entry for the image rollout policy. RolloutPolicy *computeAlpha.RolloutPolicy `json:",omitempty"` // Optional labels to add to the image. Labels map[string]string `json:",omitempty"` } var ( funcMap = template.FuncMap{ "trim": strings.Trim, "trimPrefix": strings.TrimPrefix, "trimSuffix": strings.TrimSuffix, } publishTemplate = template.New("publishTemplate").Option("missingkey=zero").Funcs(funcMap) ) // CreatePublish creates a publish object func CreatePublish(sourceVersion, publishVersion, workProject, publishProject, sourceGCS, sourceProject, ce, path string, varMap map[string]string, imagesCache map[string][]*computeAlpha.Image) (*Publish, error) { b, err := ioutil.ReadFile(path) if err != nil { return nil, fmt.Errorf("%s: %v", path, err) } templateContent := string(b) return createPublish(sourceVersion, publishVersion, workProject, publishProject, sourceGCS, sourceProject, ce, templateContent, varMap, imagesCache) } // CreatePublishWithTemplate creates a publish object without reading a template file func CreatePublishWithTemplate(sourceVersion, publishVersion, workProject, publishProject, sourceGCS, sourceProject, ce, template string, varMap map[string]string, imagesCache map[string][]*computeAlpha.Image) (*Publish, error) { return createPublish(sourceVersion, publishVersion, workProject, publishProject, sourceGCS, sourceProject, ce, template, varMap, imagesCache) } func createPublish(sourceVersion, publishVersion, workProject, publishProject, sourceGCS, sourceProject, ce, template string, varMap map[string]string, imagesCache map[string][]*computeAlpha.Image) (*Publish, error) { p := Publish{ sourceVersion: sourceVersion, publishVersion: publishVersion, } if p.publishVersion == "" { p.publishVersion = sourceVersion } varMap["source_version"] = p.sourceVersion varMap["publish_version"] = p.publishVersion tmpl, err := publishTemplate.Parse(template) if err != nil { return nil, fmt.Errorf("%s: %v", template, err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, varMap); err != nil { return nil, fmt.Errorf("%s: %v", template, err) } if err := json.Unmarshal(buf.Bytes(), &p); err != nil { return nil, daisy.JSONError(template, buf.Bytes(), err) } if err := p.SetExpire(); err != nil { return nil, fmt.Errorf("%s: error SetExpire: %v", template, err) } if workProject != "" { p.WorkProject = workProject } if publishProject != "" { p.PublishProject = publishProject } if sourceGCS != "" { p.SourceGCSPath = sourceGCS } if sourceProject != "" { p.SourceProject = sourceProject } if ce != "" { p.ComputeEndpoint = ce } if imagesCache != nil { p.imagesCache = imagesCache } if p.WorkProject == "" { if metadata.OnGCE() { p.WorkProject, err = metadata.ProjectID() if err != nil { return nil, err } } else { return nil, fmt.Errorf("%s\nWorkProject unspecified", template) } } fmt.Printf("[%q] Created a publish object successfully from %s\n", p.Name, template) return &p, nil } // SetExpire converts p.DeleteAfter into p.expiryDate func (p *Publish) SetExpire() error { expire, err := calculateExpiryDate(p.DeleteAfter) if err != nil { return fmt.Errorf("error parsing DeleteAfter: %v", err) } p.expiryDate = expire return nil } // CreateWorkflows creates a list of daisy workflows from the publish object func (p *Publish) CreateWorkflows(ctx context.Context, varMap map[string]string, regex *regexp.Regexp, rollback, skipDup, replace, noRoot bool, oauth string, rolloutStartTime time.Time, rolloutRate int, clientOptions ...option.ClientOption) ([]*daisy.Workflow, error) { fmt.Printf("[%q] Preparing workflows from template\n", p.Name) var ws []*daisy.Workflow for _, img := range p.Images { if regex != nil && !regex.MatchString(img.Prefix) { continue } w, err := p.createWorkflow(ctx, img, varMap, rollback, skipDup, replace, noRoot, oauth, rolloutStartTime, rolloutRate, clientOptions...) if err != nil { return nil, err } if w == nil { continue } ws = append(ws, w) } if len(ws) == 0 { fmt.Println(" Nothing to do.") return nil, nil } if len(p.toCreate) > 0 { fmt.Printf(" The following images will be created in %q:\n", p.PublishProject) printList(p.toCreate) } if len(p.toDeprecate) > 0 { fmt.Printf(" The following images will be deprecated in %q:\n", p.PublishProject) printList(p.toDeprecate) } if len(p.toObsolete) > 0 { fmt.Printf(" The following images will be obsoleted in %q:\n", p.PublishProject) printList(p.toObsolete) } if len(p.toUndeprecate) > 0 { fmt.Printf(" The following images will be un-deprecated in %q:\n", p.PublishProject) printList(p.toUndeprecate) } if len(p.toDelete) > 0 { fmt.Printf(" The following images will be deleted in %q:\n", p.PublishProject) printList(p.toDelete) } if len(p.rolloutPolicy) > 0 { fmt.Println(" All images will have the following rollout policy:") printList(p.rolloutPolicy) } return ws, nil } // ------------------ private methods ------------------------- const gcsImageObj = "root.tar.gz" func publishImage(p *Publish, img *Image, pubImgs []*computeAlpha.Image, skipDuplicates, rep, noRoot bool) (*daisy.CreateImages, *daisy.DeprecateImages, *daisy.DeleteResources, error) { if skipDuplicates && rep { return nil, nil, nil, errors.New("cannot set both skipDuplicates and replace") } publishName := img.Prefix if p.publishVersion != "" { publishName = fmt.Sprintf("%s-%s", publishName, p.publishVersion) } sourceName := img.Prefix if p.sourceVersion != "" { sourceName = fmt.Sprintf("%s-%s", sourceName, p.sourceVersion) } var ds *computeAlpha.DeprecationStatus if img.ObsoleteDate != nil { ds = &computeAlpha.DeprecationStatus{ State: "ACTIVE", Obsolete: img.ObsoleteDate.Format(time.RFC3339), } } ci := daisy.ImageAlpha{ Image: computeAlpha.Image{ Name: publishName, Description: img.Description, Architecture: img.Architecture, Licenses: img.Licenses, Family: img.Family, Deprecated: ds, ShieldedInstanceInitialState: img.ShieldedInstanceInitialState, RolloutOverride: img.RolloutPolicy, Labels: img.Labels, }, ImageBase: daisy.ImageBase{ Resource: daisy.Resource{ NoCleanup: true, Project: p.PublishProject, RealName: publishName, }, IgnoreLicenseValidationIfForbidden: img.IgnoreLicenseValidationIfForbidden, }, GuestOsFeatures: img.GuestOsFeatures, } var source string if p.SourceProject != "" && p.SourceGCSPath != "" { return nil, nil, nil, errors.New("only one of SourceProject or SourceGCSPath should be set") } if p.SourceProject != "" { source = fmt.Sprintf("projects/%s/global/images/%s", p.SourceProject, sourceName) ci.Image.SourceImage = source } else if p.SourceGCSPath != "" { if noRoot { source = fmt.Sprintf("%s/%s.tar.gz", p.SourceGCSPath, sourceName) } else { source = fmt.Sprintf("%s/%s/%s", p.SourceGCSPath, sourceName, gcsImageObj) } ci.Image.RawDisk = &computeAlpha.ImageRawDisk{Source: source} } else { return nil, nil, nil, errors.New("neither SourceProject or SourceGCSPath was set") } cis := &daisy.CreateImages{ImagesAlpha: []*daisy.ImageAlpha{&ci}} dis := &daisy.DeprecateImages{} drs := &daisy.DeleteResources{} for _, pubImg := range pubImgs { if pubImg.Name == publishName { msg := fmt.Sprintf("%q already exists in project %q", publishName, p.PublishProject) if skipDuplicates { fmt.Printf(" Image %s, skipping image creation\n", msg) cis = nil continue } else if rep { fmt.Printf(" Image %s, replacing\n", msg) (*cis).ImagesAlpha[0].OverWrite = true continue } return nil, nil, nil, errors.New(msg) } if pubImg.Family != img.Family { continue } // Delete all images in the same family with insert date older than p.expiryDate. if p.expiryDate != nil { createTime, err := time.Parse(time.RFC3339, pubImg.CreationTimestamp) if err != nil { continue } if createTime.Before(*p.expiryDate) { drs.Images = append(drs.Images, fmt.Sprintf("projects/%s/global/images/%s", p.PublishProject, pubImg.Name)) continue } } if pubImg.Family == "" { continue } // Deprecate all images in the same family. if pubImg.Deprecated == nil || pubImg.Deprecated.State == "" { *dis = append(*dis, &daisy.DeprecateImage{ Image: pubImg.Name, Project: p.PublishProject, DeprecationStatusAlpha: computeAlpha.DeprecationStatus{ State: "DEPRECATED", Replacement: fmt.Sprintf(fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/global/images/%s", p.PublishProject, publishName)), StateOverride: img.RolloutPolicy, }, }) } } if len(*dis) == 0 { dis = nil } if len(drs.Images) == 0 { drs = nil } return cis, dis, drs, nil } func rollbackImage(p *Publish, img *Image, pubImgs []*computeAlpha.Image) (*daisy.DeleteResources, *daisy.DeprecateImages) { publishName := fmt.Sprintf("%s-%s", img.Prefix, p.publishVersion) dr := &daisy.DeleteResources{} dis := &daisy.DeprecateImages{} for _, pubImg := range pubImgs { if pubImg.Name != publishName || pubImg.Deprecated != nil { continue } dr.Images = []string{fmt.Sprintf("projects/%s/global/images/%s", p.PublishProject, publishName)} } if len(dr.Images) == 0 { fmt.Printf(" %q does not exist in %q, not rolling back\n", publishName, p.PublishProject) return nil, nil } for _, pubImg := range pubImgs { // Un-deprecate the first deprecated image in the family based on insertion time. if pubImg.Family == img.Family && pubImg.Deprecated != nil { *dis = append(*dis, &daisy.DeprecateImage{ Image: pubImg.Name, Project: p.PublishProject, DeprecationStatusAlpha: computeAlpha.DeprecationStatus{ State: "ACTIVE", StateOverride: img.RolloutPolicy, }, }) break } } return dr, dis } func populateSteps(w *daisy.Workflow, prefix string, createImages *daisy.CreateImages, deprecateImages *daisy.DeprecateImages, deleteResources *daisy.DeleteResources) error { var createStep *daisy.Step var deprecateStep *daisy.Step var deleteStep *daisy.Step var err error if createImages != nil { createStep, err = w.NewStep("publish-" + prefix) if err != nil { return err } createStep.CreateImages = createImages // The default of 10m is a bit low, 1h is excessive for most use cases. // TODO(ajackura): Maybe add a timeout field override to the template? createStep.Timeout = "1h" } if deprecateImages != nil { deprecateStep, err = w.NewStep("deprecate-" + prefix) if err != nil { return err } deprecateStep.DeprecateImages = deprecateImages } if deleteResources != nil { deleteStep, err = w.NewStep("delete-" + prefix) if err != nil { return err } deleteStep.DeleteResources = deleteResources } // Create before deprecate on if deprecateStep != nil && createStep != nil { w.AddDependency(deprecateStep, createStep) } // Create before delete on if deleteStep != nil && createStep != nil { w.AddDependency(deleteStep, createStep) } // Un-deprecate before delete on rollback. if deleteStep != nil && deprecateStep != nil { w.AddDependency(deleteStep, deprecateStep) } return nil } func (p *Publish) createPrintOut(createImages *daisy.CreateImages) { if createImages == nil { return } for _, ci := range createImages.ImagesAlpha { p.toCreate = append(p.toCreate, fmt.Sprintf("%s: (%s)", ci.Name, ci.Description)) } return } func (p *Publish) deletePrintOut(deleteResources *daisy.DeleteResources) { if deleteResources == nil { return } for _, img := range deleteResources.Images { p.toDelete = append(p.toDelete, path.Base(img)) } } func (p *Publish) deprecatePrintOut(deprecateImages *daisy.DeprecateImages) { if deprecateImages == nil { return } for _, di := range *deprecateImages { image := path.Base(di.Image) switch di.DeprecationStatusAlpha.State { case "DEPRECATED": p.toDeprecate = append(p.toDeprecate, image) case "OBSOLETE": p.toObsolete = append(p.toObsolete, image) case "ACTIVE", "": p.toUndeprecate = append(p.toUndeprecate, image) } } } func (p *Publish) rolloutPolicyPrintOut(rp *computeAlpha.RolloutPolicy) { p.rolloutPolicy = append(p.rolloutPolicy, fmt.Sprintf("Default rollout time: %s", rp.DefaultRolloutTime)) var zones []string for k := range rp.LocationRolloutPolicies { zones = append(zones, k) } sort.Strings(zones) for _, v := range zones { p.rolloutPolicy = append(p.rolloutPolicy, fmt.Sprintf("Zone %s at %s", v[6:], rp.LocationRolloutPolicies[v])) } } func (p *Publish) populateWorkflow(ctx context.Context, w *daisy.Workflow, pubImgs []*computeAlpha.Image, img *Image, rb, sd, rep, noRoot bool) error { var err error var createImages *daisy.CreateImages var deprecateImages *daisy.DeprecateImages var deleteResources *daisy.DeleteResources if rb { deleteResources, deprecateImages = rollbackImage(p, img, pubImgs) } else { createImages, deprecateImages, deleteResources, err = publishImage(p, img, pubImgs, sd, rep, noRoot) if err != nil { return err } } if err := populateSteps(w, img.Prefix, createImages, deprecateImages, deleteResources); err != nil { return err } p.createPrintOut(createImages) p.deletePrintOut(deleteResources) p.deprecatePrintOut(deprecateImages) p.rolloutPolicyPrintOut(img.RolloutPolicy) return nil } func (p *Publish) createWorkflow(ctx context.Context, img *Image, varMap map[string]string, rb, sd, rep, noRoot bool, oauth string, rolloutStartTime time.Time, rolloutRate int, clientOptions ...option.ClientOption) (*daisy.Workflow, error) { fmt.Printf(" - Creating publish workflow for %q\n", img.Prefix) w := daisy.New() for k, v := range varMap { w.AddVar(k, v) } if oauth != "" { w.OAuthPath = oauth } if p.ComputeEndpoint != "" { w.ComputeEndpoint = p.ComputeEndpoint } if err := w.PopulateClients(ctx, clientOptions...); err != nil { return nil, fmt.Errorf("PopulateClients failed: %s", err) } w.Name = img.Prefix w.Project = p.WorkProject cacheKey := w.ComputeClient.BasePath() + p.PublishProject pubImgs, ok := p.imagesCache[cacheKey] if !ok { var err error pubImgs, err = w.ComputeClient.ListImagesAlpha(p.PublishProject, daisyCompute.OrderBy("creationTimestamp desc")) if err != nil { return nil, fmt.Errorf("computeClient.ListImagesAlpha failed: %s", err) } if p.imagesCache != nil { p.imagesCache[cacheKey] = pubImgs } } zones, err := w.ComputeClient.ListZones(w.Project) if err != nil { return nil, fmt.Errorf("computeClient.GetZone failed: %s", err) } img.RolloutPolicy = createRollOut(zones, rolloutStartTime, rolloutRate) if err := p.populateWorkflow(ctx, w, pubImgs, img, rb, sd, rep, noRoot); err != nil { return nil, fmt.Errorf("populateWorkflow failed: %s", err) } if len(w.Steps) == 0 { return nil, nil } return w, nil } func printList(list []string) { for _, i := range list { fmt.Printf(" - [ %s ]\n", i) } } func calculateExpiryDate(deleteAfter string) (*time.Time, error) { if deleteAfter == "" { return nil, nil } split := strings.Split(deleteAfter, "*") base, err := time.ParseDuration(split[0]) if err != nil { return nil, err } m := 1 for i, s := range split { if i == 0 { continue } nm, err := strconv.Atoi(s) if err != nil { return nil, err } m = m * nm } deleteTime := base * time.Duration(m) expiryDate := time.Now().UTC().Add(-deleteTime) return &expiryDate, nil } func createRollOut(zones []*compute.Zone, rolloutStartTime time.Time, rolloutRate int) *computeAlpha.RolloutPolicy { rp := computeAlpha.RolloutPolicy{} var regions map[string][]string regions = make(map[string][]string) maxRegionLength := 0 // Build a map of all the regions and determine the max number of zones in a region. for _, z := range zones { regions[z.Region] = append(regions[z.Region], z.Name) if len(regions[z.Region]) > maxRegionLength { maxRegionLength = len(regions[z.Region]) } } // Order the list of zones in each region. for _, value := range regions { sort.Strings(value) } // zoneList is the ordered list of zones to apply the rollout policy to. var zoneList []string // zoneList's order should be the first zone from each region, then second zone from each region, third zone from each region, etc. // us-central1-a, us-central2-b, us-central3-c, us-central1-a, us-central2-b, us-central3-c for zoneCount := 0; zoneCount < maxRegionLength; zoneCount++ { for _, regionZones := range regions { // If the region has a zone at the current zoneCount, add that zone to the zoneList. if zoneCount < len(regionZones) { zoneList = append(zoneList, regionZones[zoneCount]) } } } var rolloutPolicy map[string]string rolloutPolicy = make(map[string]string) for i, zone := range zoneList { rolloutPolicy[fmt.Sprintf("zones/%s", zone)] = rolloutStartTime.Add(time.Duration(rolloutRate*i) * time.Minute).Format(time.RFC3339) } // Set the default time to be the same as the last zone. rp.DefaultRolloutTime = rolloutStartTime.Add(time.Duration(rolloutRate*(len(zoneList)-1)) * time.Minute).Format(time.RFC3339) rp.LocationRolloutPolicies = rolloutPolicy return &rp }