custom-targets/terraform/terraform-deployer/deploy.go (110 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"
"encoding/json"
"fmt"
"os"
"path"
"cloud.google.com/go/storage"
"github.com/GoogleCloudPlatform/cloud-deploy-samples/custom-targets/util/clouddeploy"
tfjson "github.com/hashicorp/terraform-json"
"github.com/mholt/archiver/v3"
)
// deployer implements the requestHandler interface for deploy requests.
type deployer struct {
req *clouddeploy.DeployRequest
params *params
gcsClient *storage.Client
}
// process processes a deploy request and uploads succeeded or failed results to GCS for Cloud Deploy.
func (d *deployer) process(ctx context.Context) error {
fmt.Println("Processing deploy request")
res, err := d.deploy(ctx)
if err != nil {
fmt.Printf("Deploy failed: %v\n", err)
dr := &clouddeploy.DeployResult{
ResultStatus: clouddeploy.DeployFailed,
FailureMessage: err.Error(),
Metadata: map[string]string{
clouddeploy.CustomTargetSourceMetadataKey: tfDeployerSampleName,
clouddeploy.CustomTargetSourceSHAMetadataKey: clouddeploy.GitCommit,
},
}
fmt.Println("Uploading failed deploy results")
rURI, err := d.req.UploadResult(ctx, d.gcsClient, dr)
if err != nil {
return fmt.Errorf("error uploading failed deploy results: %v", err)
}
fmt.Printf("Uploaded failed deploy results to %s\n", rURI)
return err
}
fmt.Println("Uploading deploy results")
rURI, err := d.req.UploadResult(ctx, d.gcsClient, res)
if err != nil {
return fmt.Errorf("error uploading deploy results: %v", err)
}
fmt.Printf("Uploaded deploy results to %s\n", rURI)
return nil
}
// deploy performs the following steps:
// 1. Initialize the Terraform configuration only to install providers. Modules and backend were initialized at render time.
// 2. Apply the Terraform configuration.
// 3. Get the Terraform state and upload to GCS as a deploy artifact.
//
// Returns either the deploy results or an error if the deploy failed.
func (d *deployer) deploy(ctx context.Context) (*clouddeploy.DeployResult, error) {
// Download the Terraform configuration uploaded at render time and unarchive it in the same
// directory that was used at render time.
fmt.Printf("Downloading Terraform configuration archive to %s\n", srcArchivePath)
inURI, err := d.req.DownloadInput(ctx, d.gcsClient, renderedArchiveName, srcArchivePath)
if err != nil {
return nil, fmt.Errorf("unable to download deploy input with object suffix %s: %v", renderedArchiveName, err)
}
fmt.Printf("Downloaded Terraform configuration archive from %s\n", inURI)
archiveFile, err := os.Open(srcArchivePath)
if err != nil {
return nil, fmt.Errorf("unable to open archive file %s: %v", srcArchivePath, err)
}
fmt.Printf("Unarchiving Terraform configuration in %s to %s\n", srcArchivePath, srcPath)
if err := archiver.NewTarGz().Unarchive(archiveFile.Name(), srcPath); err != nil {
return nil, fmt.Errorf("unable to unarchive terraform configuration: %v", err)
}
terraformConfigPath := path.Join(srcPath, d.params.configPath)
fmt.Println("Initializing Terraform configuration to install providers")
if _, err := terraformInit(terraformConfigPath, &terraformInitOptions{disableBackendInitialization: true, disableModuleDownloads: true}); err != nil {
return nil, fmt.Errorf("error running terraform init to install providers: %v", err)
}
if _, err := terraformApply(terraformConfigPath, &terraformApplyOptions{applyParallelism: d.params.applyParallelism, lockTimeout: d.params.lockTimeout}); err != nil {
return nil, fmt.Errorf("error running terraform apply: %v", err)
}
fmt.Println("Finished applying Terraform configuration")
fmt.Println("Getting the Terraform state to provide as a deploy artifact")
ts, err := terraformShowState(terraformConfigPath)
if err != nil {
return nil, fmt.Errorf("error getting terraform state after apply: %v", err)
}
fmt.Println("Extracting Terraform output values from the Terraform state")
metadata, err := extractOutputsFromTfState(ts)
if err != nil {
return nil, fmt.Errorf("error extracting terraform outputs from the terraform state: %v", err)
}
fmt.Println("Uploading Terraform state as a deploy artifact")
stateGCSURI, err := d.req.UploadArtifact(ctx, d.gcsClient, "deployed-state.json", &clouddeploy.GCSUploadContent{Data: ts})
if err != nil {
return nil, fmt.Errorf("error uploading terraform state deploy artifact: %v", err)
}
fmt.Printf("Uploaded Terraform state deploy artifact to %s\n", stateGCSURI)
// Metadata consists of the Terraform output values and an indicator that the deploy was handled by the
// cloud deploy terraform sample.
metadata[clouddeploy.CustomTargetSourceMetadataKey] = tfDeployerSampleName
metadata[clouddeploy.CustomTargetSourceSHAMetadataKey] = clouddeploy.GitCommit
deployResult := &clouddeploy.DeployResult{
ResultStatus: clouddeploy.DeploySucceeded,
ArtifactFiles: []string{stateGCSURI},
Metadata: metadata,
}
return deployResult, nil
}
// extractOutputsFromTfState returns a map of the Terraform outputs in the provided JSON Terraform state. The map
// values are the JSON strings of the output values.
func extractOutputsFromTfState(jsonTfState []byte) (map[string]string, error) {
s := &tfjson.State{}
if err := s.UnmarshalJSON(jsonTfState); err != nil {
return nil, fmt.Errorf("unable to unmarshal terraform state: %v", err)
}
res := make(map[string]string)
// Parse each Terraform output from the Terraform state into JSON strings.
for k, v := range s.Values.Outputs {
sv, err := json.Marshal(v.Value)
if err != nil {
return nil, fmt.Errorf("unable to marshal terraform state output for key %s: %v", k, err)
}
res[k] = string(sv)
}
return res, nil
}