custom-targets/infrastructure-manager/im-deployer/render.go (246 lines of code) (raw):

// Copyright 2023 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 // https://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 main import ( "context" "fmt" "io" "os" "path" "sort" "strings" "cloud.google.com/go/config/apiv1/configpb" "cloud.google.com/go/storage" "github.com/GoogleCloudPlatform/cloud-deploy-samples/custom-targets/util/clouddeploy" "github.com/ghodss/yaml" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/mholt/archiver/v3" "github.com/zclconf/go-cty/cty" "google.golang.org/protobuf/encoding/protojson" ) const ( // Path to use when downloading the source input archive file. srcArchivePath = "/workspace/archive.tgz" // Path to use when unarchiving the source input. srcPath = "/workspace/source" // File name to use for the generated variables file. autoTFVarsFileName = "clouddeploy.auto.tfvars" // Name of the file that contains the YAML representation of the Infrastructure Manager Deployment // that is applied at deploy time. renderedDeploymentFileName = "deployment.yaml" // Name of the rendered archive. The rendered archive contains the Terraform configuration after // the rendering has completed. renderedArchiveName = "terraform-archive.zip" ) // renderer implements the requestHandler interface for render requests. type renderer struct { req *clouddeploy.RenderRequest params *params gcsClient *storage.Client } // process processes a render request and uploads succeeded or failed results to GCS for Cloud Deploy. func (r *renderer) process(ctx context.Context) error { fmt.Println("Processing render request") res, err := r.render(ctx) if err != nil { fmt.Printf("Render failed: %v\n", err) rr := &clouddeploy.RenderResult{ ResultStatus: clouddeploy.RenderFailed, FailureMessage: err.Error(), Metadata: map[string]string{ clouddeploy.CustomTargetSourceMetadataKey: imDeployerSampleName, clouddeploy.CustomTargetSourceSHAMetadataKey: clouddeploy.GitCommit, }, } fmt.Println("Uploading failed render results") rURI, err := r.req.UploadResult(ctx, r.gcsClient, rr) if err != nil { return fmt.Errorf("error uploading failed render results: %v", err) } fmt.Printf("Uploaded failed render results to %s\n", rURI) return err } fmt.Println("Uploading render results") rURI, err := r.req.UploadResult(ctx, r.gcsClient, res) if err != nil { return fmt.Errorf("error uploading render results: %v", err) } fmt.Printf("Uploaded render results to %s\n", rURI) return nil } // render performs the following steps: // 1. Generate clouddeploy.auto.tfvars with all the variable values provided via imVar_{name} env vars. // 2. Upload a zip archived version of the Terraform configuration to GCS. // 3. Upload a YAML representation of the Infrastructure Manager Deployment that will be applied at deploy time to GCS. // The Deployment will contain the Terraform configuration zip from (2) as the Terraform Blueprint. This YAML // will also be provided to Cloud Deploy as the Release inspector artifact. // // Returns either the render results or an error if the render failed. func (r *renderer) render(ctx context.Context) (*clouddeploy.RenderResult, error) { fmt.Printf("Downloading render input archive to %s and unarchiving to %s\n", srcArchivePath, srcPath) inURI, err := r.req.DownloadAndUnarchiveInput(ctx, r.gcsClient, srcArchivePath, srcPath) if err != nil { return nil, fmt.Errorf("unable to download and unarchive render input: %v", err) } fmt.Printf("Downloaded render input archive from %s\n", inURI) // Determine the path to the Terraform configuration. terraformConfigPath := path.Join(srcPath, r.params.configPath) autoVarsPath := path.Join(terraformConfigPath, autoTFVarsFileName) fmt.Printf("Generating auto variable definitions file: %s\n", autoVarsPath) if err := generateAutoTFVarsFile(autoVarsPath, r.params); err != nil { return nil, fmt.Errorf("error generating variable definitions file: %v", err) } fmt.Printf("Finished generating auto variable definitions file: %s\n", autoVarsPath) // Archive the Terraform configuration into a zip file since this is one of the accepted formats // by Infrastructure Manager when updating the Deployment resource with Terraform configuration. fmt.Printf("Archiving Terraform configuration in %s into zip file for use at deploy time\n", srcPath) if err = zipArchiveDir(terraformConfigPath, renderedArchiveName); err != nil { return nil, fmt.Errorf("error archiving terraform configuration: %v", err) } fmt.Println("Uploading archived Terraform configuration") tcURI, err := r.req.UploadArtifact(ctx, r.gcsClient, renderedArchiveName, &clouddeploy.GCSUploadContent{LocalPath: renderedArchiveName}) if err != nil { return nil, fmt.Errorf("error uploading archived terraform configuration: %v", err) } fmt.Printf("Uploaded archived Terraform configuration to %s\n", tcURI) fmt.Println("Creating rendered Deployment for use at deploy time") renderedDeploymentYAML, err := r.deploymentYAML(tcURI) if err != nil { return nil, fmt.Errorf("error creating rendered deployment: %v", err) } fmt.Println("Uploading rendered Deployment") dURI, err := r.req.UploadArtifact(ctx, r.gcsClient, renderedDeploymentFileName, &clouddeploy.GCSUploadContent{Data: renderedDeploymentYAML}) if err != nil { return nil, fmt.Errorf("error uploading rendered deployment: %v", err) } fmt.Printf("Uploaded rendered Deployment to %s\n", dURI) renderResult := &clouddeploy.RenderResult{ ResultStatus: clouddeploy.RenderSucceeded, ManifestFile: dURI, Metadata: map[string]string{ clouddeploy.CustomTargetSourceMetadataKey: imDeployerSampleName, clouddeploy.CustomTargetSourceSHAMetadataKey: clouddeploy.GitCommit, }, } return renderResult, nil } // deploymentYAML returns the YAML representation of the Infrastructure Manager Deployment that will be applied // at deploy time based on the Terraform configuration uploaded while rendering, the deploy parameters configured, // and the render request from Cloud Deploy. func (r *renderer) deploymentYAML(gcsSourceURI string) ([]byte, error) { labels := make(map[string]string) if !r.params.disableCloudDeployLabels { labels = map[string]string{ "managed-by": "google-cloud-deploy", "project": r.req.Project, "location": r.req.Location, "delivery-pipeline-id": r.req.Pipeline, "release-id": r.req.Release, "target-id": r.req.Target, } } d := &configpb.Deployment{ Name: r.params.deploymentName(), Labels: labels, Blueprint: &configpb.Deployment_TerraformBlueprint{ TerraformBlueprint: &configpb.TerraformBlueprint{ Source: &configpb.TerraformBlueprint_GcsSource{ GcsSource: gcsSourceURI, }, }, }, ImportExistingResources: &r.params.importExistingResources, } // Use Cloud Deploy workload service account if deploy parameter overwrite wasn't provided. serviceAccount := r.params.imServiceAccount if len(serviceAccount) == 0 { serviceAccount = r.req.WorkloadCBInfo.ServiceAccount } d.ServiceAccount = &serviceAccount // Use Cloud Deploy workload worker pool if present and deploy parameter overwrite wasn't provided. if len(r.params.imWorkerPool) != 0 { d.WorkerPool = &r.params.imWorkerPool } else if len(r.req.WorkloadCBInfo.WorkerPool) != 0 { d.WorkerPool = &r.req.WorkloadCBInfo.WorkerPool } j, err := protojson.Marshal(d) if err != nil { return nil, fmt.Errorf("error marshaling deployment: %v", err) } y, err := yaml.JSONToYAML(j) if err != nil { return nil, fmt.Errorf("error converting deployment json to yaml: %v", err) } return y, nil } // generateAutoTFVarsFile generates a *.auto.tfvars file that contains the variables defined in the // environment with a "imVar_" prefix and the variables defined in the variable file, if provided. // This is done so that the Terraform configuration uploaded at the end of the render has all the // configuration present. func generateAutoTFVarsFile(autoTFVarsPath string, params *params) error { // Check whether clouddeploy.auto.tfvars file exists. If it does then fail the render, otherwise create it. if _, err := os.Stat(autoTFVarsPath); !os.IsNotExist(err) { return fmt.Errorf("cloud deploy auto.tfvars file %q already exists, failing render to avoid overwriting any configuration", autoTFVarsPath) } autoTFVarsFile, err := os.Create(autoTFVarsPath) if err != nil { return fmt.Errorf("error creating cloud deploy auto.tfvars file: %v", err) } defer autoTFVarsFile.Close() if len(params.variablePath) > 0 { varsPath := path.Join(path.Dir(autoTFVarsPath), params.variablePath) fmt.Printf("Attempting to copy contents from %s to %s so the variables are automatically consumed by Terraform\n", varsPath, autoTFVarsPath) varsFile, err := os.Open(varsPath) if err != nil { return fmt.Errorf("unable to open variable file provided at %s: %v", varsPath, err) } defer varsFile.Close() autoTFVarsFile.Write([]byte(fmt.Sprintf("# Sourced from %s.\n", params.variablePath))) if _, err := io.Copy(autoTFVarsFile, varsFile); err != nil { return fmt.Errorf("unable to copy contents from %s to %s: %v", varsPath, autoTFVarsPath, err) } autoTFVarsFile.Write([]byte("\n")) fmt.Printf("Finished copying contents from %s to %s\n", varsPath, autoTFVarsPath) } hclFile := hclwrite.NewEmptyFile() rootBody := hclFile.Body() // Track whether we found any relevant environment variables to determine if we write to the file. found := false var keys []string kv := make(map[string]cty.Value) envVars := os.Environ() for _, rawEV := range envVars { if !strings.HasPrefix(rawEV, imVarEnvKeyPrefix) { continue } found = true fmt.Printf("Found infrastucture manager environment variable %s, will add to corresponding variable to %s\n", rawEV, autoTFVarsPath) // Remove the prefix so we can get the variable name. ev := strings.TrimPrefix(rawEV, imVarEnvKeyPrefix) eqIdx := strings.Index(ev, "=") // Invalid. if eqIdx == -1 { continue } name := ev[:eqIdx] rawVal := ev[eqIdx+1:] val, err := parseCtyValue(rawVal, name) if err != nil { return err } keys = append(keys, name) kv[name] = val } // We sort the entries so the ordering is consistent between Cloud Deploy Releases. sort.Strings(keys) for _, k := range keys { rootBody.SetAttributeValue(k, kv[k]) } if found { autoTFVarsFile.Write([]byte(fmt.Sprintf("# Sourced from %s prefixed deploy parameters.\n", imVarDeployParamKeyPrefix))) if _, err = autoTFVarsFile.Write(hclFile.Bytes()); err != nil { return fmt.Errorf("error writing to cloud deploy auto.tfvars file: %v", err) } } return nil } // parseCtyValue attempts to parse the provided string value into a cty.Value. func parseCtyValue(rawVal string, key string) (cty.Value, error) { expr, diags := hclsyntax.ParseExpression([]byte(rawVal), "", hcl.InitialPos) if diags.HasErrors() { return cty.DynamicVal, fmt.Errorf("error parsing %s for variable %s", rawVal, key) } var val cty.Value var valDiags hcl.Diagnostics val, valDiags = expr.Value(nil) if valDiags.HasErrors() { // If extracting the value from the expression fails then it's possible the value is a string (as // opposed to a number, list, map, etc), which needs to be in quotes to be properly parsed so we // retry with the raw value wrapped in quotes. If this doesn't work then return the initial // value error received. rawWithQuotes := fmt.Sprintf("%q", rawVal) expr, diags := hclsyntax.ParseExpression([]byte(rawWithQuotes), "", hcl.InitialPos) if diags.HasErrors() { return cty.DynamicVal, fmt.Errorf("error parsing %s for variable %s", rawVal, key) } var rValDiags hcl.Diagnostics val, rValDiags = expr.Value(nil) if rValDiags.HasErrors() { return cty.DynamicVal, fmt.Errorf("error parsing %s for variable %s", rawVal, key) } } return val, nil } // zipArchiveDir creates a zip file with the provided name containing all the contents of the provided directory. func zipArchiveDir(dir string, dst string) error { // Determine the sources for the archive, which is all the entries in the directory. de, err := os.ReadDir(dir) if err != nil { return fmt.Errorf("unable to read directory contents %s: %v", dir, err) } var sources []string for _, e := range de { // Name only returns the final element of the path so we need to reconstruct the path. entryPath := path.Join(dir, e.Name()) sources = append(sources, entryPath) } return archiver.NewZip().Archive(sources, dst) }