diff --git a/cancelot/Dockerfile b/cancelot/Dockerfile new file mode 100644 index 000000000..a2ab0fca9 --- /dev/null +++ b/cancelot/Dockerfile @@ -0,0 +1,15 @@ +FROM gcr.io/cloud-builders/go AS build-env + +ADD ./ /go/src/ + +ENV GOBIN=/go/bin +RUN go get -d -v ./... +RUN go install /go/src/main.go + +FROM alpine:latest + +RUN apk add --no-cache ca-certificates + +COPY --from=build-env /go/bin/main /go/bin/main + +ENTRYPOINT [ "/go/bin/main" ] diff --git a/cancelot/README.md b/cancelot/README.md new file mode 100644 index 000000000..9dab342d8 --- /dev/null +++ b/cancelot/README.md @@ -0,0 +1,53 @@ +# Cancelot + +Cancelot (thank you Twitter for the name :D) allows you to cancel previous builds running for the same branch, +when you are using VCS triggered builds. + +# Purpose + +Cancelot was built because there is no out of the box solution by CloudBuild, in order to cancel a previous running +build upon a new commit in the same branch. This can save a lot of build minutes that would be otherwise billed to the +account. + +# Deploying Cancelot + +* Make any changes you need +* Navigate to Cancelot's folder and execute the following: `gcloud builds submit . --config=cloudbuild.yaml` +* Enjoy + +## Using Cancelot + +Add the builder as the first step in your project's `cloudbuild.yaml`: + +```yaml +steps: +- name: 'gcr.io/$PROJECT_ID/cancelot' + args: [ + '--current_build_id', '$BUILD_ID', + '--branch_name', '$BRANCH_NAME' + ] +``` + +Cancelot will be invoked when your build starts and will try to find any running jobs that match the following filter: + +``` +build_id != "[CURRENT_BUILD_ID]" AND +source.repo_source.branch_name = "[BRANCH_NAME]" AND +status = "WORKING" AND +start_time<"[CURRENT_BUILD_START_TIME]" +``` + +After successfully fetching the list with the running builds that match the defined criteria, it loops and cancels +each one. + +### Contributing + +After making any changes to Cancelot, please navigate to `test` folder & deploy the `cloudbuild.yaml`, like this: + +```bash +gcloud builds submit . --config=cloudbuild.yaml --substitutions=BRANCH_NAME="test" +``` + +## Inspiration + +Cancelot is heavily inspired by `slackbot` from CloudBuilders community diff --git a/cancelot/cancelot/cancelpreviousbuild.go b/cancelot/cancelot/cancelpreviousbuild.go new file mode 100644 index 000000000..01f7f642f --- /dev/null +++ b/cancelot/cancelot/cancelpreviousbuild.go @@ -0,0 +1,65 @@ +package cancelot + +import ( + "context" + "fmt" + "google.golang.org/api/cloudbuild/v1" + "log" +) + +// Checks for previous running builds on the same branch, in order to cancel them +func CancelPreviousBuild(ctx context.Context, currentBuildId string, branchName string) { + svc := gcbClient(ctx) + project, err := getProject() + if err != nil { + log.Fatalf("Failed to get project: %v", err) + } + + log.Printf("Going to fetch current build details for: %s", currentBuildId) + + currentBuildResponse, currentBuildError := svc.Projects.Builds.Get(project, currentBuildId).Do() + if currentBuildError != nil { + log.Fatalf("Failed to get build details from Cloud Build. Will retry in one minute.") + } + + log.Printf("Going to check ongoing jobs for branch: %s", branchName) + + onGoingJobFilter := fmt.Sprintf(` + build_id != "%s" AND + source.repo_source.branch_name = "%s" AND + status = "WORKING" AND + start_time<"%s"`, + currentBuildId, + branchName, + currentBuildResponse.StartTime) + + log.Printf("Builds filter created: %s", onGoingJobFilter) + + onGoingBuildsResponse, onGoingBuildsError := svc.Projects.Builds.List(project).Filter(onGoingJobFilter).Do() + + if onGoingBuildsError != nil { + log.Fatalf("Failed to get builds from Cloud Build. Will retry in one minute.") + } + + onGoingBuilds := onGoingBuildsResponse.Builds + numOfOnGoingBuilds := len(onGoingBuilds) + + log.Printf("Ongoing builds for %s has size of: %d", branchName, numOfOnGoingBuilds) + + if numOfOnGoingBuilds == 0 { + return + } + + for _, build := range onGoingBuilds { + log.Printf("Going to cancel build with id: %s", build.Id) + + cancelBuildCall := svc.Projects.Builds.Cancel(project, build.Id, &cloudbuild.CancelBuildRequest{}) + buildCancelResponse, buildCancelError := cancelBuildCall.Do() + + if buildCancelError != nil { + log.Fatalf("Failed to cancel build with id:%s - %v", build.Id, buildCancelError) + } + + log.Printf("Cancelled build with id:%s", buildCancelResponse.Id) + } +} diff --git a/cancelot/cancelot/cloudbuild.go b/cancelot/cancelot/cloudbuild.go new file mode 100644 index 000000000..7acf28ffe --- /dev/null +++ b/cancelot/cancelot/cloudbuild.go @@ -0,0 +1,50 @@ +package cancelot + +import ( + "bytes" + "context" + "log" + "os/exec" + "strings" + + "cloud.google.com/go/compute/metadata" + "golang.org/x/oauth2/google" + cloudbuild "google.golang.org/api/cloudbuild/v1" +) + +// getProject gets the project ID. +func getProject() (string, error) { + // Test if we're running on GCE. + if metadata.OnGCE() { + // Use the GCE Metadata service. + projectID, err := metadata.ProjectID() + if err != nil { + log.Printf("Failed to get project ID from instance metadata") + return "", err + } + return projectID, nil + } + // Shell out to gcloud. + cmd := exec.Command("gcloud", "config", "get-value", "project") + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + if err != nil { + log.Printf("Failed to shell out to gcloud: %+v", err) + return "", err + } + projectID := strings.TrimSuffix(out.String(), "\n") + return projectID, nil +} + +func gcbClient(ctx context.Context) *cloudbuild.Service { + client, err := google.DefaultClient(ctx, cloudbuild.CloudPlatformScope) + if err != nil { + log.Fatalf("Caught error creating client: %v", err) + } + svc, err := cloudbuild.New(client) + if err != nil { + log.Fatalf("Caught error creating service: %v", err) + } + return svc +} diff --git a/cancelot/cloudbuild.yaml b/cancelot/cloudbuild.yaml new file mode 100644 index 000000000..0d7bae7fc --- /dev/null +++ b/cancelot/cloudbuild.yaml @@ -0,0 +1,6 @@ +steps: +- name: 'gcr.io/cloud-builders/docker' + args: [ 'build', '-t', 'gcr.io/$PROJECT_ID/cancelot', '.' ] +images: +- 'gcr.io/$PROJECT_ID/cancelot' +tags: ['cloud-builders-community'] diff --git a/cancelot/main.go b/cancelot/main.go new file mode 100644 index 000000000..68542ee95 --- /dev/null +++ b/cancelot/main.go @@ -0,0 +1,33 @@ +// Post build status results to Slack. + +package main + +import ( + "context" + "flag" + "log" + + "./cancelot" +) + +var ( + currentBuildId = flag.String("current_build_id", "", "The current build id, in order to be excluded") + branchName = flag.String("branch_name", "", "BranchName to cancel previous ongoing jobs on") +) + +func main() { + log.Print("Starting cancelot") + flag.Parse() + ctx := context.Background() + + if *currentBuildId == "" { + log.Fatalf("CurrentBuildId must be provided.") + } + + if *branchName == "" { + log.Fatalf("BranchName must be provided.") + } + + cancelot.CancelPreviousBuild(ctx, *currentBuildId, *branchName) + return +} diff --git a/cancelot/test/cloudbuild.yaml b/cancelot/test/cloudbuild.yaml new file mode 100644 index 000000000..04ebe5eb0 --- /dev/null +++ b/cancelot/test/cloudbuild.yaml @@ -0,0 +1,8 @@ +steps: +- name: 'gcr.io/$PROJECT_ID/cancelot' + args: [ + '--current_build_id', '$BUILD_ID', + '--branch_name', "$BRANCH_NAME" + ] +- name: 'ubuntu' + args: ['echo', 'done']