custom-targets/git-ops/git-deployer/deploy.go (287 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"
"errors"
"fmt"
"hash/crc32"
"io"
"os"
"path/filepath"
"strings"
"time"
secretmanager "cloud.google.com/go/secretmanager/apiv1"
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
"cloud.google.com/go/storage"
provider "github.com/GoogleCloudPlatform/cloud-deploy-samples/custom-targets/git-ops/git-deployer/providers"
"github.com/GoogleCloudPlatform/cloud-deploy-samples/custom-targets/util/clouddeploy"
)
const (
// Argo Application custom resource type.
argoCRType = "application"
// Argo Synced status.
argoSyncedStatus = "Synced"
// Argo sync interval is how often to poll the Argo Application for the sync status.
argoSyncInterval = 15 * time.Second
)
// deployer implements the requestHandler interface for deploy requests.
type deployer struct {
req *clouddeploy.DeployRequest
params *params
gcsClient *storage.Client
smClient *secretmanager.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: gitDeployerSampleName,
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. Access the configured Secret Manager SecretVersion.
// 2. Clone the Git Repository and check out the configured source branch.
// 3. Copy the rendered manifest into the source branch, commit, and push the changes.
// 4. If a destination branch is configured:
// a. Open a pull request with the changes from the source branch to the destination branch.
// b. If Argo sync polling is enabled then merge the pull request and poll the Argo application
// until the status is Synced.
func (d *deployer) deploy(ctx context.Context) (*clouddeploy.DeployResult, error) {
fmt.Printf("Accessing SecretVersion %s\n", d.params.gitSecret)
s, err := d.accessSecretVersion(ctx, d.params.gitSecret)
if err != nil {
return nil, fmt.Errorf("unable to access git secret: %v", err)
}
fmt.Printf("Accessed SecretVersion %s\n", d.params.gitSecret)
secret := string(s)
repoParts := strings.Split(d.params.gitRepo, "/")
if len(repoParts) != 3 {
return nil, fmt.Errorf("invalid git repository reference: %q", d.params.gitRepo)
}
hostname, owner, repoName := repoParts[0], repoParts[1], repoParts[2]
gitRepo := newGitRepository(hostname, owner, repoName, d.params.gitEmail, d.params.gitUsername)
if err := d.setupGitWorkspace(ctx, secret, gitRepo); err != nil {
return nil, fmt.Errorf("unable to set up git workspace: %v", err)
}
localManifest := "manifest.yaml"
fmt.Printf("Downloaded rendered manifest to %s\n", localManifest)
mURI, err := d.req.DownloadManifest(ctx, d.gcsClient, localManifest)
if err != nil {
return nil, fmt.Errorf("unable to download rendered manifest: %v", err)
}
fmt.Printf("Downloaded rendered manifest from %s\n", mURI)
fmt.Println("Copying rendered manifest into local Git repository")
gitManifestPath, err := copyToLocalGitRepo(localManifest, repoName, d.params.gitPath)
if err != nil {
return nil, fmt.Errorf("unable to copy manifest to local git repository: %v", err)
}
op, err := gitRepo.detectDiff()
if err != nil {
return nil, fmt.Errorf("unable to run git status: %v", err)
}
if len(op) == 0 {
return nil, fmt.Errorf("no diff detected between the rendered manifest and the manifest on branch %s", d.params.gitSourceBranch)
}
fmt.Printf("Committing and pushing changes to branch %s\n", d.params.gitSourceBranch)
if err := d.commitPushGitWorkspace(ctx, gitRepo); err != nil {
return nil, fmt.Errorf("unable to commit and push changes: %v", err)
}
if err := d.handleDestinationBranch(ctx, gitRepo, secret); err != nil {
return nil, err
}
fmt.Println("Uploading rendered manifest as a deploy artifact")
dURI, err := d.req.UploadArtifact(ctx, d.gcsClient, "manifest.yaml", &clouddeploy.GCSUploadContent{LocalPath: gitManifestPath})
if err != nil {
return nil, fmt.Errorf("error uploading deploy artifact: %v", err)
}
fmt.Printf("Uploaded deploy artifact to %s\n", dURI)
return &clouddeploy.DeployResult{
ResultStatus: clouddeploy.DeploySucceeded,
ArtifactFiles: []string{dURI},
Metadata: map[string]string{
clouddeploy.CustomTargetSourceMetadataKey: gitDeployerSampleName,
clouddeploy.CustomTargetSourceSHAMetadataKey: clouddeploy.GitCommit,
},
}, nil
}
// accessSecretVersion downloads the Secret Manager SecretVersion, verifies the data checksum and
// provides the data payload.
func (d *deployer) accessSecretVersion(ctx context.Context, svName string) ([]byte, error) {
res, err := d.smClient.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: svName,
})
if err != nil {
return nil, fmt.Errorf("failed to access secret version %s: %v", svName, err)
}
crc32c := crc32.MakeTable(crc32.Castagnoli)
checksum := int64(crc32.Checksum(res.Payload.Data, crc32c))
if checksum != *res.Payload.DataCrc32C {
return nil, fmt.Errorf("data corruption detected with secret version")
}
return res.Payload.Data, nil
}
// setupGitWorkspace clones the Git repository and checks out the configured source branch.
func (d *deployer) setupGitWorkspace(ctx context.Context, secret string, gitRepo *gitRepository) error {
fmt.Printf("Cloning Git repository %s\n", d.params.gitRepo)
if _, err := gitRepo.cloneRepo(secret); err != nil {
return fmt.Errorf("failed to clone git repository %s: %v", d.params.gitRepo, err)
}
if err := gitRepo.config(); err != nil {
return fmt.Errorf("failed setting up the git config in the git repository: %v", err)
}
fmt.Printf("Checking out branch %s\n", d.params.gitSourceBranch)
if _, err := gitRepo.checkoutBranch(d.params.gitSourceBranch); err != nil {
return fmt.Errorf("unable to checkout branch %s: %v", d.params.gitSourceBranch, err)
}
output, err := gitRepo.checkIfExists(d.params.gitSourceBranch)
if err != nil {
return fmt.Errorf("unable to check if branch %s exists: %v", d.params.gitSourceBranch, err)
}
if output != nil {
if _, err := gitRepo.pull(d.params.gitSourceBranch); err != nil {
return fmt.Errorf("unable to pull branch %s: %v", d.params.gitSourceBranch, err)
}
}
return nil
}
// commitPushGitWorkspace commits and pushes changes in the local Git workspace to the source branch.
func (d *deployer) commitPushGitWorkspace(ctx context.Context, gitRepo *gitRepository) error {
if _, err := gitRepo.add(); err != nil {
return fmt.Errorf("unable to git add changes: %v", err)
}
commitMsg := d.params.gitCommitMessage
if len(commitMsg) == 0 {
commitMsg = fmt.Sprintf("Delivery Pipeline: %s Release: %s Rollout: %s", d.req.Pipeline, d.req.Release, d.req.Rollout)
}
if _, err := gitRepo.commit(commitMsg); err != nil {
return fmt.Errorf("unable to git commit changes: %v", err)
}
if _, err := gitRepo.push(d.params.gitSourceBranch); err != nil {
return fmt.Errorf("unable to git push changes to branch %s: %v", d.params.gitSourceBranch, err)
}
return nil
}
// handleDestinationBranch opens a pull request on the destination branch if provided and will optionally
// merge the PR if configured. Additionally, if Argo sync polling is enabled then the status of the Argo
// Application is polled until it's synced.
func (d *deployer) handleDestinationBranch(ctx context.Context, gitRepo *gitRepository, secret string) error {
// If no destination branch is provided then there is no need to open a pull request.
if len(d.params.gitDestinationBranch) == 0 {
return nil
}
title := d.params.gitPullRequestTitle
if len(title) == 0 {
title = fmt.Sprintf("Cloud Deploy: Release %s, Rollout %s", d.req.Release, d.req.Rollout)
}
body := d.params.gitPullRequestBody
if len(body) == 0 {
body = fmt.Sprintf("Project: %s\nLocation: %s\nDelivery Pipeline: %s\nTarget: %s\nRelease: %s\nRollout: %s",
d.req.Project,
d.req.Location,
d.req.Pipeline,
d.req.Target,
d.req.Release,
d.req.Rollout,
)
}
gitProvider, err := provider.CreateProvider(gitRepo.hostname, gitRepo.repoName, gitRepo.owner, secret)
if err != nil {
return fmt.Errorf("unable to create git provider: %v", err)
}
fmt.Printf("Opening pull request from %s to %s\n", d.params.gitSourceBranch, d.params.gitDestinationBranch)
pr, err := gitProvider.OpenPullRequest(d.params.gitSourceBranch, d.params.gitDestinationBranch, title, body)
if err != nil {
return fmt.Errorf("unable to open pull request from %s to %s: %v", d.params.gitSourceBranch, d.params.gitDestinationBranch, err)
}
if !d.params.enablePullRequestMerge {
return nil
}
fmt.Println("Merging the pull request")
mr, err := gitProvider.MergePullRequest(pr.Number)
if err != nil {
return fmt.Errorf("unable to merge pull request %d: %v", pr.Number, err)
}
if !d.params.enableArgoSyncPoll {
return nil
}
fmt.Printf("Argo sync polling is enabled, setting up cluster credentials for %s\n", d.params.gkeCluster)
if _, err := gcloudClusterCredentials(d.params.gkeCluster); err != nil {
return fmt.Errorf("unable to set up cluster credentials: %v", err)
}
fmt.Printf("Checking for the existence of the Argo Application %s in namespace %s\n", d.params.argoApp, d.params.argoNamespace)
if _, err := verifyResourceExists(argoCRType, d.params.argoApp, d.params.argoNamespace); err != nil {
return fmt.Errorf("argo application custom resource not found: %v", err)
}
fmt.Println("Polling Argo Application until it's synced with the merged changes")
if err := pollSyncStatus(d.params.argoApp, d.params.argoNamespace, mr.Sha, d.params.argoSyncTimeout); err != nil {
return fmt.Errorf("unable to verify argo application is synced: %v", err)
}
fmt.Printf("Argo Application synced with the merged changes\n")
return nil
}
// copyToLocalGitRepo copies a local file to a local Git repository. Returns the path of
// the new file in the local Git repository.
func copyToLocalGitRepo(srcPath, repo, gitPath string) (string, error) {
srcFile, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer srcFile.Close()
var gitManifestPath string
// If git path is not provided then use the name of the local file.
if len(gitPath) == 0 {
_, file := filepath.Split(srcPath)
gitManifestPath = filepath.Join(repo, file)
} else {
gitManifestPath = filepath.Join(repo, gitPath)
}
// Create any directories in the local git repo path if necessary.
if err := os.MkdirAll(filepath.Dir(gitManifestPath), os.ModePerm); err != nil {
return "", err
}
dstFile, err := os.Create(gitManifestPath)
if err != nil {
return "", err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return "", err
}
return gitManifestPath, nil
}
// pollSyncStatus polls the sync status of the Argo application until it's synced or the timeout is reached.
func pollSyncStatus(name string, ns string, rev string, timeout time.Duration) error {
ticker := time.NewTicker(argoSyncInterval)
defer ticker.Stop()
done := make(chan bool)
go func() {
time.Sleep(timeout)
done <- true
}()
for {
select {
case <-done:
return errors.New("timed out checking sync status of application")
case <-ticker.C:
fmt.Println("Tick...Checking the sync status")
if err := checkSyncStatus(name, ns, rev); err != nil {
fmt.Printf("%v\n", err)
continue
}
return nil
}
}
}
// checkSyncStatus checks whether the Argo application is synced.
func checkSyncStatus(name string, ns string, headRev string) error {
syncRev, err := queryPath(argoCRType, name, ns, "{.status.sync.revision}")
if err != nil {
return fmt.Errorf("error getting the application synced revision: %v", err)
}
if string(syncRev) != headRev {
return fmt.Errorf("synced revision: %s does not match repository revision: %s", syncRev, headRev)
}
currentSyncStatus, err := queryPath(argoCRType, name, ns, "{.status.sync.status}")
if err != nil {
return fmt.Errorf("error getting the application synced status: %v", err)
}
if string(currentSyncStatus) != argoSyncedStatus {
return fmt.Errorf("synced status does not match, status got: %s want: %s", string(currentSyncStatus), argoSyncedStatus)
}
return nil
}