custom-targets/infrastructure-manager/im-deployer/deploy.go (166 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"
"os"
"path"
config "cloud.google.com/go/config/apiv1"
"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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
)
const (
// Key to use for the deployment name in the metadata results when deploy succeeds.
deploymentMetadataKey = "deployment"
// Key to use for the revision name in the metadata results when deploy succeeds.
revisionMetadataKey = "revision"
)
// deployer implements the requestHandler interface for deploy requests.
type deployer struct {
req *clouddeploy.DeployRequest
params *params
imClient *config.Client
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: imDeployerSampleName,
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. Create or update the Infrastructure Manager Deployment based on the Deployment YAML created at render time.
//
// Returns either the deploy results or an error if the deploy failed.
func (d *deployer) deploy(ctx context.Context) (*clouddeploy.DeployResult, error) {
renderedDeploymentPath := path.Join(srcPath, renderedDeploymentFileName)
fmt.Printf("Downloading rendered Deployment to %s\n", renderedDeploymentPath)
dURI, err := d.req.DownloadInput(ctx, d.gcsClient, renderedDeploymentFileName, renderedDeploymentPath)
if err != nil {
return nil, fmt.Errorf("unable to download rendered deployment with object suffix %s: %v", renderedDeploymentFileName, err)
}
fmt.Printf("Downloaded rendered Deployment from %s\n", dURI)
rd, err := renderedDeployment(renderedDeploymentPath)
if err != nil {
return nil, fmt.Errorf("error parsing rendered deployment: %v", err)
}
deployment, err := d.applyDeployment(ctx, rd)
if err != nil {
return nil, err
}
revName := deployment.LatestRevision
fmt.Printf("Created latest Revision %s\n", revName)
// Ensure the Deployment reached a terminal state after creating/updating it. If for some reason it's still in
// progress then we poll it until it reaches a terminal state. The polling logic checks whether the latest revision
// changes in case the Deployment is updated outside the context of this deployer.
if isInProgressDeployment(deployment.State) {
fmt.Printf("Polling Deployment %s until a terminal state is reached, current state: %s\n", deployment.Name, deployment.State.String())
var err error
deployment, err = pollDeploymentUntilTerminal(ctx, d.imClient, deployment.Name, revName)
if err != nil {
return nil, err
}
fmt.Printf("Finished polling Deployment %s until terminal state, current state: %s\n", deployment.Name, deployment.State.String())
}
fmt.Printf("Retrieving Revision %s\n", revName)
rev, err := getRevision(ctx, d.imClient, revName)
if err != nil {
return nil, fmt.Errorf("error getting revision %s: %v", revName, err)
}
fmt.Printf("Revision %s executed in Cloud Build %s\n", revName, rev.Build)
if isSucceededDeployment(deployment.State) {
fmt.Printf("Deployment Succeeded with latest Revision %s\n", revName)
return processDeploymentSucceeded(ctx, deployment, rev)
}
fmt.Printf("Deployment Failed with latest Revision %s\n", revName)
return nil, processDeploymentFailed(ctx, deployment, rev)
}
// renderedDeployment returns the Infrastructure Manager Deployment created at render time that is defined
// in YAML format at the provided path.
func renderedDeployment(deploymentYAMLPath string) (*configpb.Deployment, error) {
b, err := os.ReadFile(deploymentYAMLPath)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %v", deploymentYAMLPath, err)
}
j, err := yaml.YAMLToJSON(b)
if err != nil {
return nil, fmt.Errorf("error converting deployment yaml to json: %v", err)
}
deployment := &configpb.Deployment{}
if err := protojson.Unmarshal(j, deployment); err != nil {
return nil, fmt.Errorf("failed to unmarshal deployment: %v", err)
}
return deployment, nil
}
// applyDeployment either creates or updates an existing Infrastructure Manager Deployment with the
// provided Deployment configuration.
func (d *deployer) applyDeployment(ctx context.Context, renderedDeployment *configpb.Deployment) (*configpb.Deployment, error) {
deploymentName := renderedDeployment.Name
fmt.Printf("Checking whether Deployment %s exists\n", deploymentName)
if _, err := getDeployment(ctx, d.imClient, deploymentName); status.Code(err) == codes.NotFound {
// Deployment doesn't exist yet.
fmt.Printf("Creating Deployment %s\n", deploymentName)
d, err := createDeployment(ctx, d.imClient, renderedDeployment)
if err != nil {
return nil, fmt.Errorf("error creating deployment %s: %v", deploymentName, err)
}
fmt.Printf("Created Deployment %s, current state: %s\n", deploymentName, d.State.String())
return d, nil
} else if err != nil {
return nil, fmt.Errorf("error getting deployment %s: %v", deploymentName, err)
}
// Deployment already exists so it needs to be updated.
fmt.Printf("Updating Deployment %s\n", deploymentName)
postD, err := updateDeployment(ctx, d.imClient, renderedDeployment)
if err != nil {
return nil, fmt.Errorf("error updating deployment %s: %v", deploymentName, err)
}
fmt.Printf("Updated Deployment %s, current state: %s\n", deploymentName, postD.State.String())
return postD, nil
}
// processDeploymentSucceeded handles a successful Deployment and returns a successful deploy result that includes the
// Infrastructure Manager revision's outputs in the result metadata.
func processDeploymentSucceeded(ctx context.Context, deployment *configpb.Deployment, rev *configpb.Revision) (*clouddeploy.DeployResult, error) {
metadata := map[string]string{
clouddeploy.CustomTargetSourceMetadataKey: imDeployerSampleName,
clouddeploy.CustomTargetSourceSHAMetadataKey: clouddeploy.GitCommit,
deploymentMetadataKey: deployment.Name,
revisionMetadataKey: rev.Name,
}
for k, v := range rev.ApplyResults.Outputs {
mv, err := v.Value.MarshalJSON()
if err != nil {
return nil, fmt.Errorf("unable to marshal revision output %s", k)
}
metadata[k] = string(mv)
}
res := &clouddeploy.DeployResult{
ResultStatus: clouddeploy.DeploySucceeded,
Metadata: metadata,
}
return res, nil
}
// processDeploymentFailed handles a failed Deployment by logging various information from the Infrastructure Manager
// resources to provide context on the failure.
func processDeploymentFailed(ctx context.Context, deployment *configpb.Deployment, rev *configpb.Revision) error {
failureMessage := fmt.Sprintf("Deployment %s had state %s at failure time.", deployment.Name, deployment.State.String())
// If there is an error code present then include it in the failure message for Cloud Deploy.
if deployment.ErrorCode != configpb.Deployment_ERROR_CODE_UNSPECIFIED {
failureMessage = fmt.Sprintf("%s Error code: %s", failureMessage, deployment.ErrorCode)
}
fmt.Printf("%s\n", failureMessage)
fmt.Printf("Revision state: %s, error code: %s\n", rev.State, rev.ErrorCode)
fmt.Printf("Revision state details: %s\n", rev.StateDetail)
for i, tfe := range rev.TfErrors {
if len(tfe.ErrorDescription) != 0 {
fmt.Printf("Revision Terraform error %d: %v\n", i+1, tfe.ErrorDescription)
}
}
return fmt.Errorf(failureMessage)
}