├── .gitignore ├── tests ├── empty-appjson │ ├── app.json │ ├── Dockerfile │ └── README.md ├── options-http2 │ ├── index.html │ ├── app.json │ ├── Dockerfile │ ├── README.md │ └── h2o.conf ├── buildpacks-builder │ ├── index.php │ ├── app.json │ └── README.md ├── requirements.txt ├── options-require-auth │ ├── app.json │ ├── Dockerfile │ └── README.md ├── envvars-generated │ ├── app.json │ ├── Dockerfile │ └── README.md ├── options │ ├── Dockerfile │ ├── app.json │ └── README.md ├── hooks-prepostcreate-external │ ├── Dockerfile │ ├── README.md │ ├── app.json │ ├── postcreate.sh │ └── precreate.sh ├── hooks-prepostcreate-inline │ ├── Dockerfile │ ├── README.md │ └── app.json ├── README.md └── run_integration_test.py ├── cmd ├── redirector │ ├── go.mod │ ├── gitlab.go │ ├── go.sum │ ├── Dockerfile │ ├── extractors.go │ ├── github.go │ ├── referer.go │ ├── gitlab_test.go │ ├── referer_test.go │ ├── main_test.go │ ├── github_test.go │ └── main.go ├── cloudshell_open │ ├── pack.go │ ├── docker.go │ ├── scripts.go │ ├── billing.go │ ├── cloudrun_test.go │ ├── jib.go │ ├── artifactregistry.go │ ├── api.go │ ├── project.go │ ├── clone.go │ ├── clone_test.go │ ├── main_test.go │ ├── cloudrun.go │ ├── appfile.go │ ├── appfile_test.go │ ├── deploy.go │ └── main.go ├── instrumentless_test │ └── main.go └── instrumentless │ └── instrumentless.go ├── assets └── cloud-run-button.png ├── .cloudshellcustomimagerepo.json ├── Dockerfile ├── renovate.json ├── cloudbuild-redirector.yaml ├── hack ├── trigger-baseimage.json └── setup-build-triggers.sh ├── cloudbuild.yaml ├── .github └── workflows │ └── ci.yml ├── go.mod ├── integration.cloudbuild.yaml ├── CONTRIBUTING.md ├── README.md ├── LICENSE └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | -------------------------------------------------------------------------------- /tests/empty-appjson/app.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /tests/options-http2/index.html: -------------------------------------------------------------------------------- 1 | hello, world 2 | -------------------------------------------------------------------------------- /tests/buildpacks-builder/index.php: -------------------------------------------------------------------------------- 1 | hello, world 2 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | 2 | google-api-python-client 3 | click -------------------------------------------------------------------------------- /tests/options-http2/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "http2": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /cmd/redirector/go.mod: -------------------------------------------------------------------------------- 1 | module redirector 2 | 3 | go 1.12 4 | 5 | require github.com/pkg/errors v0.8.1 6 | -------------------------------------------------------------------------------- /tests/options-require-auth/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "allow-unauthenticated": false 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/options-http2/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM lkwg82/h2o-http2-server 2 | 3 | COPY h2o.conf h2o.conf 4 | COPY index.html /www/ 5 | -------------------------------------------------------------------------------- /assets/cloud-run-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-run-button/HEAD/assets/cloud-run-button.png -------------------------------------------------------------------------------- /tests/envvars-generated/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "FOO": { 4 | "generator": "secret" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cmd/redirector/gitlab.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var extractGitLabURL = extractGitHubURL // as they currently use the same URL schema 4 | -------------------------------------------------------------------------------- /tests/envvars-generated/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT while :; do nc -k -l -p $PORT -e sh -c 'echo -e "HTTP/1.1 200 OK\n\n$FOO"'; done 4 | -------------------------------------------------------------------------------- /tests/options/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT while :; do nc -k -l -p $PORT -e sh -c 'echo -e "HTTP/1.1 200 OK\n\nhello, world"'; done 4 | -------------------------------------------------------------------------------- /tests/buildpacks-builder/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "buildpacks": { 4 | "builder": "paketobuildpacks/builder:full" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/empty-appjson/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT while :; do nc -k -l -p $PORT -e sh -c 'echo -e "HTTP/1.1 200 OK\n\nhello, world"'; done 4 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-external/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT while :; do nc -k -l -p $PORT -e sh -c 'echo -e "HTTP/1.1 200 OK\n\n$GEN"'; done 4 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-inline/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT while :; do nc -k -l -p $PORT -e sh -c 'echo -e "HTTP/1.1 200 OK\n\n$A$B"'; done 4 | -------------------------------------------------------------------------------- /tests/options-require-auth/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ENTRYPOINT while :; do nc -k -l -p $PORT -e sh -c 'echo -e "HTTP/1.1 200 OK\n\nhello, world"'; done 4 | -------------------------------------------------------------------------------- /tests/options-http2/README.md: -------------------------------------------------------------------------------- 1 | # HTTP2 Options 2 | 3 | Deploy with HTTP2 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/options/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "memory": "512Mi", 4 | "cpu": "2", 5 | "max-instances": 10, 6 | "concurrency": 10 7 | } 8 | } -------------------------------------------------------------------------------- /tests/empty-appjson/README.md: -------------------------------------------------------------------------------- 1 | # empty config 2 | 3 | This folder has an empty config. 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /cmd/redirector/go.sum: -------------------------------------------------------------------------------- 1 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 2 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 3 | -------------------------------------------------------------------------------- /tests/options-require-auth/README.md: -------------------------------------------------------------------------------- 1 | # Authenticated check 2 | 3 | Deploy as an authenticated service 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/options/README.md: -------------------------------------------------------------------------------- 1 | # Memory and CPU options 2 | 3 | Deploy with custom memory and CPU options 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/options-http2/h2o.conf: -------------------------------------------------------------------------------- 1 | hosts: 2 | default: 3 | listen: 4 | port: 8080 5 | paths: 6 | /: 7 | file.dir: /www 8 | access-log: /dev/stdout 9 | error-log: /dev/stderr 10 | -------------------------------------------------------------------------------- /.cloudshellcustomimagerepo.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "description": "Marker file indicating this is a repository that defines a Cloud Shell custom image.", 4 | "projectId": "cloudrun", 5 | "repoId": "cloud-run-button" 6 | } 7 | -------------------------------------------------------------------------------- /tests/buildpacks-builder/README.md: -------------------------------------------------------------------------------- 1 | # buildpacks builder test 2 | 3 | Use a custom buildpacks builder to make the image. 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-inline/README.md: -------------------------------------------------------------------------------- 1 | # Inline Hooks test 2 | 3 | Run the shell scripts inline in pre and post create steps 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-external/README.md: -------------------------------------------------------------------------------- 1 | # External Hooks test 2 | 3 | Run the shell scripts as in this folder in pre and post create steps 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/envvars-generated/README.md: -------------------------------------------------------------------------------- 1 | # Generated envvars 2 | 3 | This service generates it's own envvars. Re-deploy to ensure they don't get overridden again. 4 | 5 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 6 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-external/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "precreate": { 4 | "commands": [ 5 | "./precreate.sh" 6 | ] 7 | }, 8 | "postcreate": { 9 | "commands": [ 10 | "./postcreate.sh" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /cmd/redirector/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1-alpine AS build 2 | RUN apk add --no-cache git 3 | WORKDIR /src/app 4 | COPY go.mod go.sum ./ 5 | RUN go mod download 6 | COPY . . 7 | RUN CGO_ENABLED=0 go build -o /server -ldflags="-w -s" . # rundev 8 | 9 | FROM scratch 10 | COPY --from=build /server /server 11 | ENTRYPOINT ["/server"] 12 | -------------------------------------------------------------------------------- /cmd/redirector/extractors.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net/url" 4 | 5 | type repoRef interface { 6 | GitURL() string 7 | Dir() string 8 | Ref() string 9 | } 10 | 11 | var ( 12 | availableExtractors = map[string]extractor{ 13 | "github.com": extractGitHubURL, 14 | "gitlab.com": extractGitLabURL, 15 | } 16 | ) 17 | 18 | type extractor func(*url.URL) (repoRef, error) 19 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-external/postcreate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | GEN=$(gcloud run services describe --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --format='value(status.observedGeneration)' $K_SERVICE) 4 | gcloud run services update --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --update-env-vars=GEN=$GEN $K_SERVICE 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine AS build 2 | RUN apk add --no-cache git 3 | WORKDIR /src 4 | COPY go.mod go.sum ./ 5 | RUN go mod download 6 | COPY . . 7 | RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' \ 8 | -o /bin/a.out ./cmd/cloudshell_open 9 | 10 | FROM gcr.io/cloudshell-images/cloudshell:latest 11 | COPY --from=build /bin/a.out /bin/cloudshell_open 12 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-external/precreate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | gcloud run deploy --image=gcr.io/cloudrun/hello --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --no-allow-unauthenticated $K_SERVICE 4 | GEN=$(gcloud run services describe --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --format='value(status.observedGeneration)' $K_SERVICE) 5 | gcloud run services update --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --update-env-vars=GEN=$GEN $K_SERVICE 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "schedule:weekly", 6 | ":semanticCommits" 7 | ], 8 | "postUpdateOptions": [ 9 | "gomodTidy" 10 | ], 11 | "commitMessagePrefix": "chore(all): ", 12 | "commitMessageAction": "update", 13 | "groupName": "all", 14 | "packageRules": [ 15 | { 16 | "matchUpdateTypes": [ 17 | "major" 18 | ], 19 | "enabled": false 20 | } 21 | ], 22 | "constraints": { 23 | "go": "1.23" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cloudbuild-redirector.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | args: ['build', '--tag', 'gcr.io/$PROJECT_ID/button-redirector:$SHORT_SHA', 'cmd/redirector'] 4 | - name: 'gcr.io/cloud-builders/docker' 5 | args: ['push', 'gcr.io/$PROJECT_ID/button-redirector:$SHORT_SHA'] 6 | - name: 'gcr.io/cloud-builders/gcloud' 7 | args: 8 | - run 9 | - deploy 10 | - cloud-run-button-deploy 11 | - --image=gcr.io/$PROJECT_ID/button-redirector:$SHORT_SHA 12 | - --region=us-central1 13 | - --platform=managed 14 | - --quiet 15 | env: 16 | - CLOUDSDK_CORE_DISABLE_PROMPTS=1 17 | -------------------------------------------------------------------------------- /tests/hooks-prepostcreate-inline/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "precreate": { 4 | "commands": [ 5 | "gcloud run deploy --image=gcr.io/cloudrun/hello --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --no-allow-unauthenticated $K_SERVICE", 6 | "gcloud run services update --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --update-env-vars=A=A $K_SERVICE" 7 | ] 8 | }, 9 | "postcreate": { 10 | "commands": [ 11 | "gcloud run services update --platform=managed --project=$GOOGLE_CLOUD_PROJECT --region=$GOOGLE_CLOUD_REGION --update-env-vars=B=B $K_SERVICE" 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | 3 | The service account that runs these tests (either the default Cloud Build Service Account, or a custom account) requires the same permissions as in the [CONTRIBUTING](../CONTRIBUTING.md) guide. 4 | 5 | Run `../integration.cloudbuild.yaml` to test variations on deployments. Open any test folder in GitHub in the browser and click the Run on Google Cloud to test in isolation. 6 | 7 | When adding new features, you should: 8 | 9 | * create a new folder in `test/` 10 | * append the test to the `run tests` step in `../integration.cloudbuild.yaml` 11 | 12 | Note: new tests must be run separately, as this harness might use the PR for the configuration, but each tests pulls from the main branch. 13 | -------------------------------------------------------------------------------- /hack/trigger-baseimage.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "GCR_REPO_URL-baseimage", 3 | "description": "Triggers on changes to gcr.io/cloudshell-images/cloudshell:latest", 4 | "base_image": { 5 | "imageName": "gcr.io/cloudshell-images/cloudshell:latest" 6 | }, 7 | "build": { 8 | "source": { 9 | "repo_source": { 10 | "repoName": "CSR_REPO_NAME", 11 | "branchName": "master" 12 | } 13 | }, 14 | "timeout": "1200s", 15 | "images": [ 16 | "GCR_REPO_URL" 17 | ], 18 | "steps": [ 19 | { 20 | "name": "gcr.io/cloud-builders/docker", 21 | "args": [ 22 | "build", 23 | "-t", 24 | "GCR_REPO_URL", 25 | "." 26 | ] 27 | }, 28 | { 29 | "name": "gcr.io/cloudshell-images/custom-image-validation", 30 | "args": [ 31 | "image_test.py", 32 | "--image", 33 | "GCR_REPO_URL" 34 | ] 35 | } 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/pack.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os/exec" 20 | ) 21 | 22 | func packBuild(dir, image, builderImage string) error { 23 | cmd := exec.Command("pack", "build", "--quiet", "--builder", builderImage, "--path", dir, image) 24 | b, err := cmd.CombinedOutput() 25 | if err != nil { 26 | return fmt.Errorf("pack build failed: %v, output:\n%s", err, string(b)) 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /cmd/instrumentless_test/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "github.com/GoogleCloudPlatform/cloud-run-button/cmd/instrumentless" 22 | ) 23 | 24 | func main() { 25 | 26 | if len(os.Args) < 3 { 27 | fmt.Fprintln(os.Stderr, "Not enough args") 28 | os.Exit(1) 29 | } 30 | 31 | event := os.Args[1] 32 | token := os.Args[2] 33 | 34 | coupon, err := instrumentless.GetCoupon(event, token) 35 | if err != nil { 36 | fmt.Fprintln(os.Stderr, err) 37 | os.Exit(1) 38 | } 39 | 40 | fmt.Printf("Got coupon: %s\n", coupon.URL) 41 | 42 | } 43 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Default cloudbuild.yaml for Cloud Shell custom images. 16 | # This file is used by the Cloud Build trigger that builds this image. 17 | 18 | steps: 19 | - name: "gcr.io/cloud-builders/docker" 20 | args: 21 | [ 22 | "build", 23 | "-t", 24 | "gcr.io/$PROJECT_ID/button", 25 | "-t", 26 | "gcr.io/$PROJECT_ID/button:public-image-${COMMIT_SHA}", 27 | ".", 28 | ] 29 | - name: "gcr.io/cloudshell-images/custom-image-validation" 30 | args: ["image_test.py", "--image", "gcr.io/$PROJECT_ID/button"] 31 | images: 32 | - "gcr.io/$PROJECT_ID/button" 33 | - "gcr.io/$PROJECT_ID/button:public-image-${COMMIT_SHA}" 34 | timeout: "3600s" 35 | options: 36 | machineType: "N1_HIGHCPU_8" 37 | -------------------------------------------------------------------------------- /cmd/redirector/github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type gitHubRepoRef struct { 12 | user string 13 | repo string 14 | ref string 15 | dir string 16 | } 17 | 18 | func (g gitHubRepoRef) GitURL() string { return "https://github.com/" + g.user + "/" + g.repo + ".git" } 19 | func (g gitHubRepoRef) Dir() string { return g.dir } 20 | func (g gitHubRepoRef) Ref() string { return g.ref } 21 | 22 | var ( 23 | // ghSubpages matches tree/REF[/SUBPATH] or blob/REF/SUBPATH paths on GitHub. 24 | ghSubpages = regexp.MustCompile(`(?U)^(tree|blob)\/(.*)?(\/.*)?$`) 25 | ) 26 | 27 | func extractGitHubURL(u *url.URL) (repoRef, error) { 28 | var rr gitHubRepoRef 29 | path := cleanupPath(u.Path) 30 | parts := strings.SplitN(path, "/", 3) 31 | 32 | if len(parts) < 2 { 33 | return rr, errors.New("url is not sufficient to infer the repository name") 34 | } 35 | rr.user, rr.repo = parts[0], parts[1] 36 | 37 | if len(parts) > 2 { 38 | subPath := parts[2] 39 | group := ghSubpages.FindStringSubmatch(subPath) 40 | if len(group) == 0 { 41 | return rr, errors.New("only tree/ and blob/ urls on the repositories are supported") 42 | } 43 | if group[2] != "" { 44 | rr.ref = group[2] 45 | } 46 | rr.dir = strings.TrimLeft(group[3], "/") 47 | } 48 | return rr, nil 49 | } 50 | 51 | // cleanupPath removes the leading or trailing slashes, or the README.md from the path. 52 | func cleanupPath(path string) string { 53 | path = strings.TrimSuffix(path, "README.md") 54 | path = strings.Trim(path, "/") 55 | return path 56 | } 57 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/docker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "os/exec" 21 | "path/filepath" 22 | ) 23 | 24 | func dockerBuild(dir, image string) error { 25 | cmd := exec.Command("docker", "build", "--quiet", "--tag", image, dir) 26 | b, err := cmd.CombinedOutput() 27 | if err != nil { 28 | return fmt.Errorf("docker build failed: %v, output:\n%s", err, string(b)) 29 | } 30 | return nil 31 | } 32 | 33 | func dockerPush(image string) error { 34 | cmd := exec.Command("docker", "push", image) 35 | b, err := cmd.CombinedOutput() 36 | if err != nil { 37 | return fmt.Errorf("docker push failed: %v, output:\n%s", err, string(b)) 38 | } 39 | return nil 40 | } 41 | 42 | func dockerFileExists(dir string) (bool, error) { 43 | if _, err := os.Stat(filepath.Join(dir, "Dockerfile")); err != nil { 44 | if os.IsNotExist(err) { 45 | return false, nil 46 | } 47 | return false, fmt.Errorf("failed to check for Dockerfile in the repo: %v", err) 48 | } 49 | 50 | return true, nil 51 | } 52 | -------------------------------------------------------------------------------- /cmd/instrumentless/instrumentless.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package instrumentless 16 | 17 | import ( 18 | "encoding/json" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | ) 23 | 24 | type Coupon struct { 25 | URL string `json:"url"` 26 | } 27 | 28 | func GetCoupon(event string, bearerToken string) (*Coupon, error) { 29 | url := fmt.Sprintf("https://api.gcpcredits.com/%s", event) 30 | 31 | req, err := http.NewRequest(http.MethodGet, url, nil) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearerToken)) 37 | 38 | res, err := http.DefaultClient.Do(req) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | if res.StatusCode != 200 { 44 | return nil, errors.New(res.Status) 45 | } 46 | 47 | if res.Body != nil { 48 | defer res.Body.Close() 49 | } 50 | 51 | coupon := Coupon{} 52 | err = json.NewDecoder(res.Body).Decode(&coupon) 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to read and parse the response: %v", err) 55 | } 56 | 57 | return &coupon, nil 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | cloudshell_open: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Free disk space 8 | run: | 9 | docker system prune -a -f 10 | sudo rm -rf /usr/local/lib/android 11 | df /var/ 12 | df -h 13 | - uses: actions/checkout@master 14 | - uses: actions/setup-go@v5 15 | with: 16 | go-version: 1.23 17 | - run: git config --global init.defaultBranch main # TODO remove later as git updates to a newer version 18 | - run: go mod download 19 | - name: Set up git 20 | run: | 21 | git config --global user.email "you@example.com" 22 | git config --global user.name "Your Name" 23 | - name: Validate formatting with go fmt 24 | run: diff -u <(echo -n) <(gofmt -d -s .) 25 | working-directory: ./cmd/cloudshell_open 26 | - name: Go tests 27 | run: go test -v ./... 28 | working-directory: ./cmd/cloudshell_open 29 | - name: Build docker image 30 | run: docker build -t runbutton . 31 | 32 | redirector: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@master 36 | - uses: actions/setup-go@v5 37 | with: 38 | go-version: 1.23 39 | - run: go mod download 40 | working-directory: ./cmd/redirector 41 | - name: Validate formatting with go fmt 42 | run: diff -u <(echo -n) <(gofmt -d -s .) 43 | working-directory: ./cmd/redirector 44 | - name: Go tests 45 | run: go test -v ./... 46 | working-directory: ./cmd/redirector 47 | - name: Build docker image 48 | run: docker build -t redirector . 49 | working-directory: ./cmd/redirector 50 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/scripts.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "io" 20 | "os" 21 | "os/exec" 22 | 23 | "github.com/fatih/color" 24 | ) 25 | 26 | type myWriter struct { 27 | out io.Writer 28 | color color.Attribute 29 | } 30 | 31 | func (m myWriter) Write(p []byte) (int, error) { 32 | return color.New(m.color).Fprintf(m.out, string(p)) 33 | } 34 | 35 | func runScript(dir, command string, envs []string) error { 36 | fmt.Println(infoPrefix + " Running command: " + color.BlueString(command)) 37 | 38 | cmd := exec.Command("/bin/bash", "-c", "set -euo pipefail; set -x; "+command) 39 | cmd.Env = envs 40 | cmd.Dir = dir 41 | cmd.Stdout = myWriter{os.Stdout, color.FgHiBlack} 42 | cmd.Stderr = myWriter{os.Stderr, color.FgHiBlack} 43 | cmd.Stdin = os.Stdin 44 | return cmd.Run() 45 | } 46 | 47 | func runScripts(dir string, commands, envs []string) error { 48 | for _, command := range commands { 49 | err := runScript(dir, command, envs) 50 | if err != nil { 51 | return fmt.Errorf("failed to execute command[%s]: %v", command, err) 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/billing.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | 21 | "google.golang.org/api/cloudbilling/v1" 22 | ) 23 | 24 | // checkBillingEnabled checks if there's a billing account associated to the 25 | // GCP project ID. 26 | func checkBillingEnabled(projectID string) (bool, error) { 27 | client, err := cloudbilling.NewService(context.TODO()) 28 | if err != nil { 29 | return false, fmt.Errorf("failed to initialize cloud billing client: %w", err) 30 | } 31 | bo, err := client.Projects.GetBillingInfo("projects/" + projectID).Context(context.TODO()).Do() 32 | if err != nil { 33 | return false, fmt.Errorf("failed to query project billing info: %w", err) 34 | } 35 | return bo.BillingEnabled, nil 36 | } 37 | 38 | func billingAccounts() ([]cloudbilling.BillingAccount, error) { 39 | var out []cloudbilling.BillingAccount 40 | 41 | client, err := cloudbilling.NewService(context.TODO()) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to initialize cloud billing client: %w", err) 44 | } 45 | billingAccounts, err := client.BillingAccounts.List().Context(context.TODO()).Do() 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to query billing accounts: %w", err) 48 | } 49 | 50 | for _, p := range billingAccounts.BillingAccounts { 51 | if p.Open { 52 | out = append(out, *p) 53 | } 54 | } 55 | 56 | return out, nil 57 | } 58 | -------------------------------------------------------------------------------- /cmd/redirector/referer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | const ( 10 | paramDir = "dir" 11 | paramRev = "revision" 12 | paramRepo = "git_repo" 13 | ) 14 | 15 | func parseReferer(v string, extractors map[string]extractor) (repoRef, error) { 16 | u, err := url.Parse(v) 17 | if err != nil { 18 | return nil, errors.Errorf("could not parse %s as url", v) 19 | } 20 | fn, ok := extractors[u.Hostname()] 21 | if !ok { 22 | return nil, errors.Errorf("hostname %s not supported", u.Hostname()) 23 | } 24 | 25 | out, err := fn(u) 26 | return out, errors.Wrap(err, "failed to extract URL components") 27 | } 28 | 29 | func prepURL(r repoRef, overrides url.Values) string { 30 | u := &url.URL{ 31 | Scheme: "https", 32 | Host: "console.cloud.google.com", 33 | Path: "cloudshell/editor", 34 | } 35 | q := make(url.Values) 36 | // not an officially documented param: 37 | // https://cloud.google.com/shell/docs/open-in-cloud-shell 38 | q.Set("shellonly", "true") 39 | 40 | q.Set("cloudshell_image", "gcr.io/cloudrun/button") 41 | q.Set("cloudshell_git_repo", r.GitURL()) 42 | if v := r.Ref(); v != "" { 43 | q.Set("cloudshell_git_branch", v) 44 | } 45 | if v := r.Dir(); v != "" { 46 | q.Set("cloudshell_working_dir", v) 47 | } 48 | 49 | // overrides 50 | if v := overrides.Get(paramRepo); v != "" { 51 | q.Set("cloudshell_git_repo", v) 52 | } 53 | if v := overrides.Get(paramDir); v != "" { 54 | q.Set("cloudshell_working_dir", v) 55 | } 56 | if v := overrides.Get(paramRev); v != "" { 57 | q.Set("cloudshell_git_branch", v) 58 | } 59 | 60 | // pass-through query parameters 61 | for k := range overrides { 62 | if strings.HasPrefix(k, "cloudshell_") { 63 | q.Set(k, overrides.Get(k)) 64 | } 65 | } 66 | u.RawQuery = q.Encode() 67 | return u.String() 68 | } 69 | 70 | type customRepoRef struct{ v url.Values } 71 | 72 | func (c customRepoRef) GitURL() string { return c.v.Get(paramRepo) } 73 | func (c customRepoRef) Dir() string { return c.v.Get(paramDir) } 74 | func (c customRepoRef) Ref() string { return c.v.Get(paramRev) } 75 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/cloudrun_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "testing" 19 | ) 20 | 21 | func Test_tryFixServiceName(t *testing.T) { 22 | tests := []struct { 23 | name string 24 | in string 25 | want string 26 | }{ 27 | {"no change", "foo", "foo"}, 28 | {"no change - empty", "", ""}, 29 | {"no leading letter - digit", "0abcdef", "svc-0abcdef"}, 30 | {"no leading letter - sign", "-abcdef", "svc-abcdef"}, 31 | {"trailing dash", "abc-def-", "abc-def"}, 32 | {"trailing dashes", "abc-def---", "abc-def"}, 33 | {"only dashes, trimmed and prefixed", "---", "svc"}, 34 | {"upper to lower", "Foo", "foo"}, 35 | {"truncate to 63 chars", 36 | "A123456789012345678901234567890123456789012345678901234567890123456789", 37 | "a12345678901234567890123456789012345678901234567890123456789012"}, 38 | {"already at max length - no truncate", 39 | "A12345678901234567890123456789012345678901234567890123456789012", 40 | "a12345678901234567890123456789012345678901234567890123456789012", 41 | }, 42 | {"leading digit + trunc", 43 | "0123456789012345678901234567890123456789012345678901234567890123456789", 44 | "svc-01234567890123456789012345678901234567890123456789012345678"}, 45 | {"invalid chars", "+hello, world", "svc-hello-world"}, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | if got, _ := tryFixServiceName(tt.in); got != tt.want { 50 | t.Errorf("tryFixServiceName(%s) = %v, want %v", tt.in, got, tt.want) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/jib.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "io/ioutil" 20 | "os" 21 | "os/exec" 22 | "path/filepath" 23 | "strings" 24 | ) 25 | 26 | func jibMavenBuild(dir string, image string) error { 27 | cmd := createMavenCommand(dir, "--batch-mode", "-Dmaven.test.skip=true", 28 | "package", "jib:build", "-Dimage="+image, "-Djib.to.auth.credHelper=gcloud") 29 | if b, err := cmd.CombinedOutput(); err != nil { 30 | return fmt.Errorf("Jib Maven build failed: %v, output:\n%s", err, string(b)) 31 | } 32 | return nil 33 | } 34 | 35 | func jibMavenConfigured(dir string) (bool, error) { 36 | content, err := ioutil.ReadFile(filepath.Join(dir, "pom.xml")) 37 | if err != nil { 38 | if os.IsNotExist(err) { 39 | return false, nil 40 | } 41 | return false, fmt.Errorf("failed to check for pom.xml in the repo: %v", err) 42 | } 43 | 44 | if strings.Contains(string(content), "jib-maven-plugin") { 45 | cmd := createMavenCommand(dir, "--batch-mode", 46 | "jib:_skaffold-fail-if-jib-out-of-date", "-Djib.requiredVersion=1.4.0") 47 | if _, err := cmd.CombinedOutput(); err == nil { 48 | return true, nil 49 | } 50 | } 51 | 52 | return false, nil 53 | } 54 | 55 | func createMavenCommand(dir string, args ...string) *exec.Cmd { 56 | executable := "mvn" 57 | 58 | if stat, err := os.Stat(filepath.Join(dir, "mvnw")); err == nil { 59 | if (stat.Mode() & 0111) != 0 { 60 | if wrapper, err := filepath.Abs(filepath.Join(dir, "mvnw")); err == nil { 61 | executable = wrapper 62 | } 63 | } 64 | } 65 | 66 | cmd := exec.Command(executable, args...) 67 | cmd.Dir = dir 68 | return cmd 69 | } 70 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/GoogleCloudPlatform/cloud-run-button 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | cloud.google.com/go/artifactregistry v1.16.3 9 | cloud.google.com/go/compute/metadata v0.6.0 10 | github.com/AlecAivazis/survey/v2 v2.3.7 11 | github.com/briandowns/spinner v1.23.2 12 | github.com/fatih/color v1.18.0 13 | google.golang.org/api v0.228.0 14 | google.golang.org/grpc v1.71.0 15 | ) 16 | 17 | require ( 18 | cloud.google.com/go v0.120.0 // indirect 19 | cloud.google.com/go/auth v0.15.0 // indirect 20 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 21 | cloud.google.com/go/iam v1.4.2 // indirect 22 | cloud.google.com/go/longrunning v0.6.6 // indirect 23 | github.com/felixge/httpsnoop v1.0.4 // indirect 24 | github.com/go-logr/logr v1.4.2 // indirect 25 | github.com/go-logr/stdr v1.2.2 // indirect 26 | github.com/google/s2a-go v0.1.9 // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 29 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 30 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 31 | github.com/mattn/go-colorable v0.1.14 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 34 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 35 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 36 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 37 | go.opentelemetry.io/otel v1.35.0 // indirect 38 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 39 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 40 | golang.org/x/crypto v0.36.0 // indirect 41 | golang.org/x/net v0.38.0 // indirect 42 | golang.org/x/oauth2 v0.28.0 // indirect 43 | golang.org/x/sync v0.12.0 // indirect 44 | golang.org/x/sys v0.31.0 // indirect 45 | golang.org/x/term v0.30.0 // indirect 46 | golang.org/x/text v0.23.0 // indirect 47 | golang.org/x/time v0.11.0 // indirect 48 | google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 // indirect 49 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect 50 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect 51 | google.golang.org/protobuf v1.36.6 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /cmd/redirector/gitlab_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestExtractGitLabURL(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | in string 12 | want gitHubRepoRef 13 | wantErr bool 14 | }{ 15 | { 16 | name: "insufficient parts", 17 | in: "https://gitlab.com", 18 | wantErr: true, 19 | }, 20 | { 21 | name: "insufficient parts only username", 22 | in: "https://gitlab.com/gitlab-org", 23 | wantErr: true, 24 | }, 25 | { 26 | name: "repository home", 27 | in: "https://gitlab.com/gitlab-org/gitlab-runner", 28 | want: gitHubRepoRef{ 29 | user: "gitlab-org", 30 | repo: "gitlab-runner", 31 | }, 32 | }, 33 | { 34 | name: "unsupported repo subpath", 35 | in: "https://gitlab.com/gitlab-org/gitlab-runner/commits/master", 36 | wantErr: true, 37 | }, 38 | { 39 | name: "repository tree with ref", 40 | in: "https://gitlab.com/gitlab-org/gitlab-runner/tree/master", 41 | want: gitHubRepoRef{ 42 | user: "gitlab-org", 43 | repo: "gitlab-runner", 44 | ref: "master", 45 | }, 46 | }, 47 | { 48 | name: "repository tree sub-dir README", 49 | in: "https://gitlab.com/gitlab-org/gitlab-runner/tree/v1/sub/dir/README.md", 50 | want: gitHubRepoRef{ 51 | user: "gitlab-org", 52 | repo: "gitlab-runner", 53 | ref: "v1", 54 | dir: "sub/dir", 55 | }, 56 | }, 57 | { 58 | name: "repository blob root README", 59 | in: "https://gitlab.com/gitlab-org/gitlab-runner/blob/v1/README.md", 60 | want: gitHubRepoRef{ 61 | user: "gitlab-org", 62 | repo: "gitlab-runner", 63 | ref: "v1", 64 | }, 65 | }, 66 | { 67 | name: "repository blob sub-dir README", 68 | in: "https://gitlab.com/gitlab-org/gitlab-runner/blob/v1/sub/dir/README.md", 69 | want: gitHubRepoRef{ 70 | user: "gitlab-org", 71 | repo: "gitlab-runner", 72 | ref: "v1", 73 | dir: "sub/dir", 74 | }, 75 | }, 76 | } 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | got, err := extractGitLabURL(mustURL(t, tt.in)) 80 | if (err != nil) != tt.wantErr { 81 | t.Errorf("extractGitLabURL(%s) error = %v, wantErr %v", tt.in, err, tt.wantErr) 82 | return 83 | } 84 | if err == nil && !reflect.DeepEqual(got, tt.want) { 85 | t.Errorf("extractGitLabURL(%s) got = %#v, want %#v", tt.in, got, tt.want) 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/artifactregistry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | 22 | artifactregistry "cloud.google.com/go/artifactregistry/apiv1" 23 | artifactregistrypb "cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb" 24 | codes "google.golang.org/grpc/codes" 25 | status "google.golang.org/grpc/status" 26 | ) 27 | 28 | // Create a "Cloud Run Source Deploy" repository in Artifact Registry (if it doesn't already exist) 29 | func createArtifactRegistry(project string, region string, repoName string) error { 30 | 31 | repoPrefix := fmt.Sprintf("projects/%s/locations/%s", project, region) 32 | repoFull := fmt.Sprintf("%s/repositories/%s", repoPrefix, repoName) 33 | 34 | ctx := context.Background() 35 | 36 | client, err := artifactregistry.NewClient(ctx) 37 | if err != nil { 38 | return fmt.Errorf("failed to create artifact registry client: %w", err) 39 | } 40 | 41 | // Check for existing repo 42 | req := &artifactregistrypb.GetRepositoryRequest{ 43 | Name: repoFull, 44 | } 45 | existingRepo, err := client.GetRepository(ctx, req) 46 | 47 | if err != nil { 48 | // The repo might not already exist, so allow that specific grpc error 49 | notFoundError := status.Error(codes.NotFound, "Requested entity was not found.") 50 | if !(errors.Is(err, notFoundError)) { 51 | return fmt.Errorf("failed to retrieve existing artifact registry client: %w", err) 52 | } 53 | } 54 | 55 | // If the existing repo doesn't exist, create it 56 | if existingRepo == nil { 57 | req := &artifactregistrypb.CreateRepositoryRequest{ 58 | Parent: repoPrefix, 59 | RepositoryId: repoName, 60 | Repository: &artifactregistrypb.Repository{ 61 | Name: repoFull, 62 | Format: artifactregistrypb.Repository_DOCKER, 63 | }, 64 | } 65 | 66 | _, err := client.CreateRepository(context.TODO(), req) 67 | if err != nil { 68 | return fmt.Errorf("failed to create artifact registry: %w", err) 69 | } 70 | } 71 | 72 | return nil 73 | 74 | } 75 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/api.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | 21 | "google.golang.org/api/serviceusage/v1" 22 | ) 23 | 24 | func enableAPIs(project string, apis []string) error { 25 | client, err := serviceusage.NewService(context.TODO()) 26 | if err != nil { 27 | return fmt.Errorf("failed to create resource manager client: %w", err) 28 | } 29 | 30 | enabled, err := enabledAPIs(client, project) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | var needAPIs []string 36 | for _, api := range apis { 37 | need := true 38 | for _, v := range enabled { 39 | if v == api { 40 | need = false 41 | break 42 | } 43 | } 44 | if need { 45 | needAPIs = append(needAPIs, api) 46 | } 47 | } 48 | if len(needAPIs) == 0 { 49 | return nil 50 | } 51 | 52 | op, err := client.Services.BatchEnable("projects/"+project, &serviceusage.BatchEnableServicesRequest{ 53 | ServiceIds: needAPIs, 54 | }).Do() 55 | if err != nil { 56 | return fmt.Errorf("failed to issue enable APIs request: %w", err) 57 | } 58 | 59 | opID := op.Name 60 | for !op.Done { 61 | op, err = client.Operations.Get(opID).Context(context.TODO()).Do() 62 | if err != nil { 63 | return fmt.Errorf("failed to query operation status (%s): %w", opID, err) 64 | } 65 | if op.Error != nil { 66 | return fmt.Errorf("enabling APIs failed (operation=%s, code=%d): %s", op.Name, op.Error.Code, op.Error.Message) 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | func enabledAPIs(client *serviceusage.Service, project string) ([]string, error) { 73 | var out []string 74 | if err := client.Services.List("projects/"+project).PageSize(200).Pages(context.TODO(), 75 | func(resp *serviceusage.ListServicesResponse) error { 76 | for _, p := range resp.Services { 77 | if p.State == "ENABLED" { 78 | out = append(out, p.Config.Name) 79 | } 80 | } 81 | return nil 82 | }); err != nil { 83 | return nil, fmt.Errorf("failed to list APIs on the project: %w", err) 84 | } 85 | return out, nil 86 | } 87 | -------------------------------------------------------------------------------- /cmd/redirector/referer_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | type mockRepo struct{ dir, ref string } 10 | 11 | func (m mockRepo) GitURL() string { return "GIT" } 12 | 13 | func (m mockRepo) Dir() string { return m.dir } 14 | 15 | func (m mockRepo) Ref() string { return m.ref } 16 | 17 | func Test_prepURL(t *testing.T) { 18 | type args struct { 19 | r repoRef 20 | overrides url.Values 21 | } 22 | tests := []struct { 23 | name string 24 | args args 25 | want string 26 | }{ 27 | { 28 | name: "bare repo", 29 | args: args{ 30 | r: mockRepo{}, 31 | overrides: nil, 32 | }, 33 | want: "https://console.cloud.google.com/cloudshell/editor?cloudshell_git_repo=GIT&cloudshell_image=gcr.io%2Fcloudrun%2Fbutton&shellonly=true", 34 | }, 35 | { 36 | name: "repo with dir", 37 | args: args{ 38 | r: mockRepo{dir: "foo"}, 39 | overrides: nil, 40 | }, 41 | want: "https://console.cloud.google.com/cloudshell/editor?cloudshell_git_repo=GIT&cloudshell_image=gcr.io%2Fcloudrun%2Fbutton&shellonly=true&cloudshell_working_dir=foo", 42 | }, 43 | { 44 | name: "repo with ref", 45 | args: args{ 46 | r: mockRepo{ref: "bar"}, 47 | overrides: nil, 48 | }, 49 | want: "https://console.cloud.google.com/cloudshell/editor?cloudshell_git_branch=bar&cloudshell_git_repo=GIT&cloudshell_image=gcr.io%2Fcloudrun%2Fbutton&shellonly=true", 50 | }, 51 | { 52 | name: "repo with slash in ref", 53 | args: args{ 54 | r: mockRepo{ref: "bar/quux"}, 55 | overrides: nil, 56 | }, 57 | want: "https://console.cloud.google.com/cloudshell/editor?cloudshell_git_branch=bar%2Fquux&cloudshell_git_repo=GIT&cloudshell_image=gcr.io%2Fcloudrun%2Fbutton&shellonly=true", 58 | }, 59 | { 60 | name: "passthrough flags", 61 | args: args{ 62 | r: mockRepo{}, 63 | overrides: url.Values{ 64 | "cloudshell_xxx": []string{"yyy"}, 65 | }, 66 | }, 67 | want: "https://console.cloud.google.com/cloudshell/editor?cloudshell_git_repo=GIT&cloudshell_image=gcr.io%2Fcloudrun%2Fbutton&cloudshell_xxx=yyy&shellonly=true", 68 | }, 69 | { 70 | name: "passthrough flags as override", 71 | args: args{ 72 | r: mockRepo{}, 73 | overrides: url.Values{ 74 | "cloudshell_git_repo": []string{"FOO"}, 75 | }, 76 | }, 77 | want: "https://console.cloud.google.com/cloudshell/editor?cloudshell_git_repo=FOO&cloudshell_image=gcr.io%2Fcloudrun%2Fbutton&shellonly=true", 78 | }, 79 | } 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | out := prepURL(tt.args.r, tt.args.overrides) 83 | got, _ := url.Parse(out) 84 | want, _ := url.Parse(tt.want) 85 | if !reflect.DeepEqual(got.Query().Encode(), want.Query().Encode()) { 86 | t.Errorf("query parameter mismatch prepURL()=\n'%s';\nwant=\n'%s'", got.Query(), want.Query()) 87 | } 88 | // clear query and compare the rest 89 | got.RawQuery = "" 90 | want.RawQuery = "" 91 | if !reflect.DeepEqual(got, want) { 92 | t.Errorf("mismatch in rest of url (non-query params) prepURL()=\n'%s';\nwant=\n'%s'", got, want) 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cmd/redirector/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // TODO(ahmetb): add POST back once https://github.community/t/chrome-85-breaks-referer/130039 is fixed 12 | func TestRedirect_unsupportedMethods(t *testing.T) { 13 | methods := []string{"PUT", "DELETE", "OPTIONS", "UNKNOWN"} 14 | for _, m := range methods { 15 | req := httptest.NewRequest(m, "/", nil) 16 | 17 | rr := httptest.NewRecorder() 18 | redirect(rr, req) 19 | 20 | if got, expected := rr.Code, http.StatusMethodNotAllowed; got != expected { 21 | t.Fatalf("for method=%s got=%d expected=%d", m, got, expected) 22 | } 23 | } 24 | } 25 | 26 | func TestRedirect_missingReferer(t *testing.T) { 27 | req := httptest.NewRequest("", "/", nil) 28 | rr := httptest.NewRecorder() 29 | redirect(rr, req) 30 | if expected, status := http.StatusBadRequest, rr.Code; expected != status { 31 | t.Fatalf("status: got=%d expected=%d", status, expected) 32 | } 33 | } 34 | 35 | func TestRedirect_referer(t *testing.T) { 36 | repo := "https://github.com/google/new-project" 37 | req := httptest.NewRequest("", "/", nil) 38 | req.Header.Set("Referer", repo) 39 | 40 | rr := httptest.NewRecorder() 41 | redirect(rr, req) 42 | 43 | if expected, status := http.StatusTemporaryRedirect, rr.Code; expected != status { 44 | t.Fatalf("status: got=%d expected=%d", status, expected) 45 | } 46 | 47 | loc := rr.Header().Get("location") 48 | s := "cloudshell_git_repo=" + url.QueryEscape(repo+".git") 49 | if !strings.Contains(loc, s) { 50 | t.Fatalf("location header doesn't contain %s\nurl='%s'", s, loc) 51 | } 52 | } 53 | 54 | func TestRedirect_referer_passthrough(t *testing.T) { 55 | repo := "https://github.com/google/new-project" 56 | req := httptest.NewRequest("", "/?cloudshell_xxx=yyy", nil) 57 | req.Header.Set("Referer", repo) 58 | 59 | rr := httptest.NewRecorder() 60 | redirect(rr, req) 61 | 62 | loc := rr.Header().Get("location") 63 | s := "cloudshell_xxx=yyy" 64 | if !strings.Contains(loc, s) { 65 | t.Fatalf("location header doesn't contain %s\nurl='%s'", s, loc) 66 | } 67 | } 68 | 69 | func TestRedirect_unknownReferer(t *testing.T) { 70 | req := httptest.NewRequest("", "/", nil) 71 | req.Header.Set("Referer", "http://example.com") 72 | 73 | rr := httptest.NewRecorder() 74 | redirect(rr, req) 75 | 76 | if expected, status := http.StatusBadRequest, rr.Code; expected != status { 77 | t.Fatalf("status: got=%d expected=%d", status, expected) 78 | } 79 | } 80 | 81 | func TestRedirect_queryParams(t *testing.T) { 82 | req := httptest.NewRequest("", "/?git_repo=foo&dir=bar&revision=staging", nil) 83 | req.Header.Set("Referer", "http://example.com") 84 | 85 | rr := httptest.NewRecorder() 86 | redirect(rr, req) 87 | if expected, status := http.StatusTemporaryRedirect, rr.Code; expected != status { 88 | t.Fatalf("status: got=%d expected=%d", status, expected) 89 | } 90 | 91 | loc := rr.Header().Get("location") 92 | fragments := []string{"cloudshell_git_repo=foo", "cloudshell_working_dir=bar", "cloudshell_git_branch=staging"} 93 | for _, s := range fragments { 94 | if !strings.Contains(loc, s) { 95 | t.Fatalf("location header doesn't contain fragment:%s\nurl='%s'", s, loc) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /hack/setup-build-triggers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Use this script to set up build triggers for the Cloud Shell custom image on 4 | # Google Cloud Build. This script currently can be run only on Cloud Shell as it 5 | # relies on GCB base image trigger which isn’t available publicly. 6 | 7 | set -euo pipefail 8 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 9 | 10 | # GCP project ID that has the Cloud Shell image for button and GCB trigger 11 | # For PROD, use PROJECT_ID="cloudrun". 12 | PROJECT_ID="${PROJECT_ID:?PROJECT_ID environment variable is not set}" 13 | 14 | # Cloud Source Repositories (CSR) repo name hosting the source code 15 | # For PROD, point to the CSR repo at https://source.cloud.google.com/cloudrun 16 | # mirroring the source code. 17 | CSR_REPO_NAME="${CSR_REPO_NAME:?CSR_REPO_NAME environment variable is not set}" 18 | 19 | # Image name for the custom Cloud Shell image 20 | IMAGE="${IMAGE:-gcr.io/"${PROJECT_ID}"/button}" 21 | 22 | self_trigger() { 23 | cat < "${tempfile}" < /dev/stdin 45 | 46 | local access_token 47 | access_token="$(gcloud auth print-access-token -q)" 48 | 49 | local http_status 50 | http_status="$( 51 | curl --silent --output /dev/stderr --write-out "%{http_code}" \ 52 | -X POST -T "${tempfile}" \ 53 | -H "Authorization: Bearer ${access_token}" \ 54 | "https://cloudbuild.googleapis.com/v1/projects/${PROJECT_ID}/triggers" 55 | )" 56 | rm -rf "$tempfile" 57 | 58 | if [[ "$http_status" -eq 409 ]]; then 59 | echo >&2 "$(tput setaf 3)WARNING: Build trigger already exists, not updating it.$(tput sgr0)" 60 | elif [[ "$http_status" -ne 200 ]]; then 61 | echo >&2 "$(tput setaf 1)Failed to create build trigger, returned HTTP ${http_status}.$(tput sgr0)" 62 | false 63 | fi 64 | echo >&2 "$(tput setaf 2)Build trigger created.$(tput sgr0)" 65 | } 66 | 67 | main() { 68 | # TODO(ahmetb): Since we use baseimage trigger on GCB which is currently a 69 | # private API, this can be only invoked from a Cloud Shell VM with a 70 | # @google.com account. Once it's public API, this check would be obsolete. 71 | if ! command -v cloudshell >/dev/null 2>&1; then 72 | echo >&2 "error: this script should be executed from a Google Cloud Shell." 73 | exit 1 74 | fi 75 | 76 | echo >&2 "Setting up GCB triggers for image ${IMAGE} on project ${PROJECT_ID}" 77 | 78 | echo >&2 "$(tput setaf 3)Creating trigger for source code updates.$(tput sgr0)" 79 | self_trigger | create_trigger 80 | 81 | echo >&2 "$(tput setaf 3)Creating trigger for Cloud Shell base image updates.$(tput sgr0)" 82 | base_image_trigger | create_trigger 83 | } 84 | 85 | main "$@" 86 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/project.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "sort" 22 | 23 | "github.com/AlecAivazis/survey/v2" 24 | "github.com/fatih/color" 25 | "google.golang.org/api/cloudresourcemanager/v1" 26 | ) 27 | 28 | var ( 29 | surveyIconOpts = survey.WithIcons(func(icons *survey.IconSet) { 30 | icons.Question = survey.Icon{Text: questionPrefix} 31 | icons.Error = survey.Icon{Text: errorPrefix} 32 | icons.SelectFocus = survey.Icon{Text: questionSelectFocusIcon} 33 | icons.HelpInput = survey.Icon{Text: "Arrows to navigate"} 34 | }) 35 | ) 36 | 37 | func listProjects() ([]string, error) { 38 | client, err := cloudresourcemanager.NewService(context.TODO()) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to initialize cloudresourcemanager client: %w", err) 41 | } 42 | var out []string 43 | if err := client.Projects.List().PageSize(1000).Pages(context.TODO(), func(resp *cloudresourcemanager.ListProjectsResponse) error { 44 | for _, p := range resp.Projects { 45 | if p.LifecycleState == "ACTIVE" { 46 | out = append(out, p.ProjectId) 47 | } 48 | } 49 | return nil 50 | }); err != nil { 51 | return nil, fmt.Errorf("failed to list GCP projects: %w", err) 52 | } 53 | sort.Strings(out) 54 | return out, nil 55 | } 56 | 57 | func promptProject(projects []string) (string, error) { 58 | if len(projects) == 0 { 59 | return "", errors.New("cannot prompt with an empty list of projects") 60 | } else if len(projects) == 1 { 61 | ok, err := confirmProject(projects[0]) 62 | if err != nil { 63 | return "", err 64 | } else if !ok { 65 | return "", fmt.Errorf("not allowed to use project %s", projects[0]) 66 | } 67 | return projects[0], nil 68 | } 69 | return promptMultipleProjects(projects) 70 | } 71 | 72 | func confirmProject(project string) (bool, error) { 73 | var ok bool 74 | projectLabel := color.New(color.Bold, color.FgHiCyan).Sprint(project) 75 | if err := survey.AskOne(&survey.Confirm{ 76 | Default: true, 77 | Message: fmt.Sprintf("Would you like to use existing GCP project %v to deploy this app?", projectLabel), 78 | }, &ok, surveyIconOpts); err != nil { 79 | return false, fmt.Errorf("could not prompt for confirmation using project %s: %+v", project, err) 80 | } 81 | return ok, nil 82 | } 83 | 84 | func promptMultipleProjects(projects []string) (string, error) { 85 | var p string 86 | if err := survey.AskOne(&survey.Select{ 87 | Message: "Choose a project or press ctrl-c to create a new project:", 88 | Options: projects, 89 | }, &p, 90 | surveyIconOpts, 91 | ); err != nil { 92 | return p, fmt.Errorf("could not choose a project: %+v", err) 93 | } 94 | return p, nil 95 | } 96 | -------------------------------------------------------------------------------- /integration.cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - id: build go 3 | name: golang:${_VERSION} 4 | env: 5 | - CGO_ENABLED=0 6 | - GOOS=linux 7 | args: 8 | [ 9 | "go", 10 | "build", 11 | "-o", 12 | "/workspace/cloudshell_open", 13 | "./cmd/cloudshell_open", 14 | ] 15 | 16 | - id: docker build 17 | name: "gcr.io/cloud-builders/docker" 18 | args: ["build", "-t", "gcr.io/$PROJECT_ID/button", "."] 19 | 20 | - id: docker push 21 | name: "gcr.io/cloud-builders/docker" 22 | args: ["push", "gcr.io/$PROJECT_ID/button"] 23 | 24 | - id: image validation 25 | name: "gcr.io/cloudshell-images/custom-image-validation" 26 | args: ["image_test.py", "--image", "gcr.io/$PROJECT_ID/button"] 27 | 28 | - id: run tests 29 | name: gcr.io/$PROJECT_ID/button 30 | env: 31 | - "DEBUG=${_DEBUG}" 32 | - "GOOGLE_CLOUD_PROJECT=$PROJECT_ID" 33 | - "GOOGLE_CLOUD_REGION=${_REGION}" 34 | - "GIT_BRANCH=${BRANCH_NAME}" 35 | - "PYTHONUNBUFFERED=true" 36 | - "TEST_CMD=python3 tests/run_integration_test.py deploy --repo_url $_REPO_URL --repo_branch $_REPO_BRANCH" 37 | script: | 38 | #!/bin/bash -e 39 | 40 | gcloud version 41 | 42 | pip3 install -r tests/requirements.txt --user 43 | 44 | $TEST_CMD --description "no config" --directory empty-appjson --expected_text 'hello, world' 45 | 46 | $TEST_CMD --description "inline hooks" --directory hooks-prepostcreate-inline --expected_text 'AB' 47 | 48 | $TEST_CMD --description "external hooks" --directory hooks-prepostcreate-external --expected_text '3' 49 | $TEST_CMD --description "external hooks, dirty" --directory hooks-prepostcreate-external --expected_text '3' --dirty 50 | 51 | $TEST_CMD --description "generator" --directory envvars-generated 52 | GEN_URL=$(gcloud run services describe envvars-generated --region $GOOGLE_CLOUD_REGION --format "value(status.url)") 53 | OUTPUT=$(curl $GEN_URL --silent) 54 | $TEST_CMD --description "generator, dirty" --directory envvars-generated --dirty --expected_text $OUTPUT 55 | 56 | $TEST_CMD --description "options" --directory options --expected_text 'hello, world' 57 | $TEST_CMD --description "options - http2" --directory options-http2 --expected_text 'hello, world' 58 | $TEST_CMD --description "options - require auth" --directory options-require-auth --expected_status 403 59 | $TEST_CMD --description "custom buildpacks" --directory buildpacks-builder --expected_text 'hello, world' 60 | 61 | 62 | - id: cleanup 63 | name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim' 64 | env: 65 | - "GOOGLE_CLOUD_REGION=${_REGION}" 66 | script: | 67 | #!/bin/bash -e 68 | gcloud run services list --region $GOOGLE_CLOUD_REGION 69 | 70 | for service in $(gcloud run services list --region $GOOGLE_CLOUD_REGION --format "value(name)"); do 71 | echo "🧹 Cleaning up $service" 72 | gcloud run services delete $service --region $GOOGLE_CLOUD_REGION --quiet 73 | done; 74 | 75 | timeout: "3600s" 76 | 77 | substitutions: 78 | _VERSION: "1.23" 79 | _REGION: us-central1 80 | _REPO_URL: https://github.com/GoogleCloudPlatform/cloud-run-button 81 | _REPO_BRANCH: master 82 | _DEBUG: "" 83 | 84 | options: 85 | machineType: "N1_HIGHCPU_32" 86 | dynamicSubstitutions: true 87 | 88 | logsBucket: ${PROJECT_ID}-buildlogs 89 | -------------------------------------------------------------------------------- /cmd/redirector/github_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func Test_cleanupPath(t *testing.T) { 10 | cases := []struct { 11 | name string 12 | path string 13 | want string 14 | }{ 15 | { 16 | name: "empty", 17 | path: "", 18 | want: "", 19 | }, 20 | { 21 | name: "root", 22 | path: "/", 23 | want: "", 24 | }, 25 | { 26 | name: "slashes", 27 | path: "/user/repo/tree/master/", 28 | want: "user/repo/tree/master", 29 | }, 30 | { 31 | name: "readme.md trimming", 32 | path: "/path/README.md", 33 | want: "path", 34 | }, 35 | } 36 | for _, tt := range cases { 37 | t.Run(tt.name, func(t *testing.T) { 38 | if got := cleanupPath(tt.path); got != tt.want { 39 | t.Errorf("cleanupPath(%s) = %v, want %v", tt.path, got, tt.want) 40 | } 41 | }) 42 | } 43 | } 44 | 45 | func TestExtractGitHubURL(t *testing.T) { 46 | tests := []struct { 47 | name string 48 | url string 49 | want gitHubRepoRef 50 | wantErr bool 51 | }{ 52 | { 53 | name: "insufficient parts", 54 | url: "https://github.com", 55 | wantErr: true, 56 | }, 57 | { 58 | name: "insufficient parts only username", 59 | url: "https://github.com/google", 60 | wantErr: true, 61 | }, 62 | { 63 | name: "repository home", 64 | url: "https://github.com/google/new-project", 65 | want: gitHubRepoRef{ 66 | user: "google", 67 | repo: "new-project", 68 | }, 69 | }, 70 | { 71 | name: "unsupported repository subpath", 72 | url: "https://github.com/google/new-project/commits/master", 73 | wantErr: true, 74 | }, 75 | { 76 | name: "repository tree with ref", 77 | url: "https://github.com/google/new-project/tree/master", 78 | want: gitHubRepoRef{ 79 | user: "google", 80 | repo: "new-project", 81 | ref: "master", 82 | }, 83 | }, 84 | { 85 | name: "repository tree sub-dir README", 86 | url: "https://github.com/google/new-project/tree/v1/sub/dir/README.md", 87 | want: gitHubRepoRef{ 88 | user: "google", 89 | repo: "new-project", 90 | ref: "v1", 91 | dir: "sub/dir", 92 | }, 93 | }, 94 | { 95 | name: "repository blob root README", 96 | url: "https://github.com/google/new-project/blob/v1/README.md", 97 | want: gitHubRepoRef{ 98 | user: "google", 99 | repo: "new-project", 100 | ref: "v1", 101 | }, 102 | }, 103 | { 104 | name: "repository blob sub-dir README", 105 | url: "https://github.com/google/new-project/blob/v1/sub/dir/README.md", 106 | want: gitHubRepoRef{ 107 | user: "google", 108 | repo: "new-project", 109 | ref: "v1", 110 | dir: "sub/dir", 111 | }, 112 | }, 113 | } 114 | for _, tt := range tests { 115 | t.Run(tt.name, func(t *testing.T) { 116 | got, err := extractGitHubURL(mustURL(t, tt.url)) 117 | if (err != nil) != tt.wantErr { 118 | t.Errorf("extractGitHubURL(%s) error = %v, wantErr %v", tt.url, err, tt.wantErr) 119 | return 120 | } 121 | if err == nil && !reflect.DeepEqual(got, tt.want) { 122 | t.Errorf("extractGitHubURL(%s) got = %v, want %v", tt.url, got, tt.want) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func mustURL(t *testing.T, u string) *url.URL { 129 | t.Helper() 130 | v, err := url.Parse(u) 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | return v 135 | } 136 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/clone.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "net" 20 | "os/exec" 21 | "regexp" 22 | "strings" 23 | ) 24 | 25 | var ( 26 | repoPattern = regexp.MustCompile(`^(https://)[a-zA-Z0-9/._:-]*$`) 27 | ) 28 | 29 | func validRepoURL(repo string) bool { return repoPattern.MatchString(repo) } 30 | 31 | func handleRepo(repo string) (string, error) { 32 | if !validRepoURL(repo) { 33 | return "", fmt.Errorf("invalid git repo url: %s", repo) 34 | } 35 | dir, err := repoDirName(repo) 36 | if err != nil { 37 | return "", err 38 | } 39 | 40 | if ok, err := hasSubDirsInPATH(dir); err != nil { 41 | return "", fmt.Errorf("failed to determine if clone dir has subdirectories in PATH: %v", err) 42 | } else if ok { 43 | return "", fmt.Errorf("cloning git repo to %s could potentially add executable files to PATH", dir) 44 | } 45 | return dir, clone(repo, dir) 46 | } 47 | 48 | func repoDirName(repo string) (string, error) { 49 | repo = strings.TrimSuffix(repo, ".git") 50 | i := strings.LastIndex(repo, "/") 51 | if i == -1 { 52 | return "", fmt.Errorf("cannot infer directory name from repo %s", repo) 53 | } 54 | dir := repo[i+1:] 55 | if dir == "" { 56 | return "", fmt.Errorf("cannot parse directory name from repo %s", repo) 57 | } 58 | if strings.HasPrefix(dir, ".") { 59 | return "", fmt.Errorf("attempt to clone into hidden directory: %s", dir) 60 | } 61 | return dir, nil 62 | } 63 | 64 | func clone(gitRepo, dir string) error { 65 | cmd := exec.Command("git", "clone", "--", gitRepo, dir) 66 | b, err := cmd.CombinedOutput() 67 | if err != nil { 68 | return fmt.Errorf("git clone failed: %+v, output:\n%s", err, string(b)) 69 | } 70 | return nil 71 | } 72 | 73 | func gitCheckout(dir, rev string) error { 74 | cmd := exec.Command("git", "checkout", "-q", "-f", rev) 75 | cmd.Dir = dir 76 | b, err := cmd.CombinedOutput() 77 | if err != nil { 78 | return fmt.Errorf("git checkout failed: %+v, output:\n%s", err, string(b)) 79 | } 80 | return nil 81 | } 82 | 83 | // signalRepoCloneStatus signals to the cloudshell host that the repo is 84 | // cloned or not (bug/178009327). 85 | func signalRepoCloneStatus(success bool) error { 86 | c, err := net.Dial("tcp", net.JoinHostPort("localhost", "8998")) 87 | if err != nil { 88 | return fmt.Errorf("failed to connect to cloudshell host: %w", err) 89 | } 90 | msgFmt := `[null,null,null,[null,null,null,null,[%d]]]` 91 | var msg string 92 | if success { 93 | msg = fmt.Sprintf(msgFmt, 1) 94 | } else { 95 | msg = fmt.Sprintf(msgFmt, 0) 96 | } 97 | msg = fmt.Sprintf("%d\n%s", len(msg), msg) 98 | if _, err := c.Write([]byte(msg)); err != nil { 99 | return fmt.Errorf("failed to send data to cloudshell host: %w", nil) 100 | } 101 | if err := c.Close(); err != nil { 102 | return fmt.Errorf("failed to close conn to cloudshell host: %w", nil) 103 | } 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/clone_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "io/ioutil" 20 | "os" 21 | "os/exec" 22 | "path/filepath" 23 | "testing" 24 | ) 25 | 26 | func TestValidRepoURL(t *testing.T) { 27 | tests := []struct { 28 | in string 29 | want bool 30 | }{ 31 | {"", false}, 32 | {"http://should-not-be-http", false}, 33 | {"https://github.com/user/bar", true}, 34 | {"https://github.com/user/bar.git", true}, 35 | } 36 | for _, tt := range tests { 37 | if got := validRepoURL(tt.in); got != tt.want { 38 | t.Fatalf("validRepoURL(%s) = %v, want %v", tt.in, got, tt.want) 39 | } 40 | } 41 | } 42 | 43 | func TestRepoDirName(t *testing.T) { 44 | tests := []struct { 45 | in string 46 | want string 47 | wantErr bool 48 | }{ 49 | {"foo-bar", "", true}, // cannot infer repo name after '/' 50 | {"/bar", "bar", false}, 51 | {"https://github.com/foo/bar", "bar", false}, 52 | } 53 | for _, tt := range tests { 54 | got, err := repoDirName(tt.in) 55 | if (err != nil) != tt.wantErr { 56 | t.Fatalf("repoDirName(%s) error = %v, wantErr %v (got=%s)", tt.in, err, tt.wantErr, got) 57 | } else if got != tt.want { 58 | t.Fatalf("repoDirName(%s) = %v, want %v", tt.in, got, tt.want) 59 | } 60 | } 61 | } 62 | 63 | func TestClone(t *testing.T) { 64 | tests := []struct { 65 | name string 66 | gitRepo string 67 | wantErr bool 68 | }{ 69 | {"404", "http://example.com/git/repo", true}, 70 | {"https", "https://github.com/google/new-project", false}, 71 | {"https+.git", "https://github.com/google/new-project.git", false}, 72 | } 73 | testDir, err := ioutil.TempDir(os.TempDir(), "git-clone-test") 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | for i, tt := range tests { 78 | t.Run(tt.name, func(ts *testing.T) { 79 | cloneDir := filepath.Join(testDir, fmt.Sprintf("test-%d", i)) 80 | err := clone(tt.gitRepo, cloneDir) 81 | if (err != nil) != tt.wantErr { 82 | t.Errorf("clone(%s) error = %v, wantErr %v", tt.gitRepo, err, tt.wantErr) 83 | return 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestGitCheckout(t *testing.T) { 90 | tmpDir, err := ioutil.TempDir(os.TempDir(), "checkout-test") 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | defer os.RemoveAll(tmpDir) 95 | 96 | run := func(tt *testing.T, cmd string, args ...string) { 97 | tt.Helper() 98 | c := exec.Command(cmd, args...) 99 | c.Dir = tmpDir 100 | if b, err := c.CombinedOutput(); err != nil { 101 | t.Fatalf("%s %v failed: %+v\n%s", cmd, args, err, string(b)) 102 | } 103 | } 104 | 105 | run(t, "git", "init", ".") 106 | run(t, "git", "commit", "--allow-empty", "--message", "initial commit") 107 | run(t, "git", "branch", "foo") 108 | 109 | if err := gitCheckout(tmpDir, "main"); err != nil { 110 | t.Fatal(err) 111 | } 112 | if err := gitCheckout(tmpDir, "foo"); err != nil { 113 | t.Fatal(err) 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | ## Test Cloud Run Button's Underlying Command Locally with Local Go 4 | 5 | 1. Download Go 1.14 (see [`Dockerfile`](Dockerfile) for the exact version used) 6 | 1. Run the tests: 7 | ``` 8 | go test ./cmd/cloudshell_open 9 | ``` 10 | 1. Build the command: 11 | ``` 12 | go build -o /tmp/cloudshell_open ./cmd/cloudshell_open 13 | ``` 14 | 1. To test the command: 15 | 1. [Enable the cloudresourcemanager API](https://console.developers.google.com/apis/api/cloudresourcemanager.googleapis.com/overview) 16 | 1. [Enable the billing API](https://console.developers.google.com/apis/api/cloudbilling.googleapis.com/overview) 17 | 1. Create a Service Account with the following roles: 18 | * Cloud Run Admin (`roles/run.admin`), 19 | * Service Usage Admin (`roles/serviceusage.serviceUsageAdmin`), 20 | * Service Account User (`roles/iam.serviceAccountUser`), and 21 | * Storage Admin (`roles/storage.admin`). 22 | 1. [Download the Service Account key as a JSON file](https://cloud.google.com/iam/docs/creating-managing-service-account-keys#creating) 23 | 1. Authenticate gcloud as the service account: 24 | ``` 25 | export GOOGLE_APPLICATION_CREDENTIALS=PATH_TO_YOUR_SERVICE_ACCOUNT_KEY_FILE 26 | gcloud auth activate-service-account --key-file=$GOOGLE_APPLICATION_CREDENTIALS 27 | export TRUSTED_ENVIRONMENT=true 28 | export SKIP_CLONE_REPORTING=true 29 | ``` 30 | 1. Run the button: 31 | ``` 32 | (cd /tmp; rm -rf cloud-run-hello; ./cloudshell_open --repo_url=https://github.com/GoogleCloudPlatform/cloud-run-hello.git; rm -rf cloud-run-hello) 33 | ``` 34 | Other `cloudshell_open` flags: `--git_branch`, `--dir`, `--context` 35 | 36 | Optionally, you can set to `GOOGLE_CLOUD_PROJECT` `GOOGLE_CLOUD_REGION` to avoid being prompted for these. 37 | 38 | ## Test Cloud Run Button's Underlying Command Locally in a Container 39 | 40 | ⚠️ This will download very large Docker images to your system. 41 | 42 | 1. [Create a Service Account in a test account](https://console.cloud.google.com/iam-admin/serviceaccounts) 43 | 1. Download the key json file for the new service account 44 | 1. Build the Container 45 | 46 | ``` 47 | docker build -f Dockerfile -t cloud-run-button . 48 | ``` 49 | 50 | 1. Set an env var pointing to the Service Account's JSON file: 51 | 52 | ``` 53 | export KEY_FILE=PATH_TO_YOUR_SERVICE_ACCOUNT_KEY_FILE 54 | ``` 55 | 56 | 1. Run Cloud Run Button via Docker: 57 | ``` 58 | docker run -it -v /var/run/docker.sock:/var/run/docker.sock \ 59 | -v $KEY_FILE:/root/user.json \ 60 | -e GOOGLE_APPLICATION_CREDENTIALS=/root/user.json \ 61 | -e TRUSTED_ENVIRONMENT=true \ 62 | -e SKIP_CLONE_REPORTING=true \ 63 | --entrypoint=/bin/sh cloud-run-button -c \ 64 | "gcloud auth activate-service-account --key-file=/root/user.json \ 65 | --quiet && gcloud auth configure-docker --quiet && \ 66 | /bin/cloudshell_open \ 67 | --repo_url=https://github.com/GoogleCloudPlatform/cloud-run-hello.git" 68 | ``` 69 | 70 | ## Test Instrumentless 71 | 72 | Test getting a coupon from the instrumentless API: 73 | ``` 74 | go run ./cmd/instrumentless_test YOUR_EVENT $(gcloud auth print-access-token) 75 | ``` 76 | 77 | ## Contributor License Agreement 78 | 79 | Contributions to this project must be accompanied by a Contributor License 80 | Agreement. You (or your employer) retain the copyright to your contribution; 81 | this simply gives us permission to use and redistribute your contributions as 82 | part of the project. Head over to to see 83 | your current agreements on file or to sign a new one. 84 | 85 | You generally only need to submit a CLA once, so if you've already submitted one 86 | (even if it was for a different project), you probably don't need to do it 87 | again. 88 | 89 | ## Code reviews 90 | 91 | All submissions, including submissions by project members, require review. We 92 | use GitHub pull requests for this purpose. Consult 93 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 94 | information on using pull requests. 95 | 96 | ## Community Guidelines 97 | 98 | This project follows [Google's Open Source Community 99 | Guidelines](https://opensource.google.com/conduct/). 100 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "regexp" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func Test_isSubPath(t *testing.T) { 15 | type args struct { 16 | a string 17 | b string 18 | } 19 | tests := []struct { 20 | args args 21 | want bool 22 | wantErr bool 23 | }{ 24 | { 25 | args: args{"/foo", "/foo/bar"}, 26 | want: true, 27 | }, 28 | { 29 | args: args{"/foo", "/foo"}, 30 | want: true, 31 | }, 32 | { 33 | args: args{"/foo/bar", "/foo/quux"}, 34 | want: false, 35 | }, 36 | { 37 | args: args{"/foo/bar", "/foo/bar/../bar/quux"}, 38 | want: true, 39 | }, 40 | { 41 | args: args{"/foo/../foo", "/foo/bar"}, 42 | want: true, 43 | }, 44 | } 45 | for _, tt := range tests { 46 | t.Run(fmt.Sprintf("%#v", tt.args), func(t *testing.T) { 47 | got, err := isSubPath(tt.args.a, tt.args.b) 48 | if (err != nil) != tt.wantErr { 49 | t.Errorf("isSubPath(%s,%s) error = %v, wantErr %v", tt.args.a, tt.args.b, err, tt.wantErr) 50 | return 51 | } 52 | if got != tt.want { 53 | t.Errorf("isSubPath(%s,%s) got = %v, want %v", tt.args.a, tt.args.b, got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func Test_hasSubDirsInPATH(t *testing.T) { 60 | type args struct { 61 | path string 62 | dir string 63 | } 64 | tests := []struct { 65 | name string 66 | args args 67 | want bool 68 | wantErr bool 69 | }{ 70 | { 71 | name: "not in path", 72 | args: args{ 73 | path: "/a:/b", 74 | dir: "/c", 75 | }, 76 | want: false, 77 | }, 78 | { 79 | name: "not in path as it is a parent dir", 80 | args: args{ 81 | path: "/usr/bin:/usr/local/bin", 82 | dir: "/usr/foo", 83 | }, 84 | want: false, 85 | }, 86 | { 87 | name: "has subdir in path", 88 | args: args{ 89 | path: "/foo/bar/quux:/b", 90 | dir: "/foo", 91 | }, 92 | want: true, 93 | }, 94 | { 95 | name: "in path, with non-cleaned paths", 96 | args: args{ 97 | path: "/foo/../foo:/quux", 98 | dir: "/foo/bar", 99 | }, 100 | want: true, 101 | }, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | op := os.Getenv("PATH") 106 | os.Setenv("PATH", tt.args.path) 107 | got, err := hasSubDirsInPATH(tt.args.dir) 108 | if (err != nil) != tt.wantErr { 109 | t.Errorf("hasSubDirsInPATH(%s) error = %v, wantErr %v", tt.args.dir, err, tt.wantErr) 110 | return 111 | } 112 | if got != tt.want { 113 | t.Errorf("hasSubDirsInPATH(%s) got = %v, want %v", tt.args.dir, got, tt.want) 114 | } 115 | os.Setenv("PATH", op) 116 | }) 117 | } 118 | } 119 | 120 | func Test_waitCredsAvailable_timeout(t *testing.T) { 121 | calls := 0 122 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 123 | w.WriteHeader(http.StatusOK) 124 | calls++ 125 | })) 126 | defer srv.Close() 127 | host := regexp.MustCompile(`^https?://`).ReplaceAllString(srv.URL, "") 128 | defer withTestEnv("GCE_METADATA_HOST", host)() 129 | defer withTestEnv("SKIP_GCE_CHECK", "1")() // so we don't return early 130 | 131 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*200) 132 | defer cancel() 133 | err := waitCredsAvailable(ctx, time.Millisecond*50) 134 | if err == nil { 135 | t.Fatal("expected err") 136 | } 137 | if calls == 0 { 138 | t.Fatal("no calls were made to http server") 139 | } 140 | } 141 | 142 | func Test_waitCredsAvailable_success(t *testing.T) { 143 | if testing.Short() { 144 | t.Skip("skipping since this test waits 1s") 145 | } 146 | 147 | calls := 0 148 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 149 | calls++ 150 | fmt.Fprintln(w, "hello/\nworld/") 151 | })) 152 | defer srv.Close() 153 | host := regexp.MustCompile(`^https?://`).ReplaceAllString(srv.URL, "") 154 | defer withTestEnv("GCE_METADATA_HOST", host)() 155 | defer withTestEnv("SKIP_GCE_CHECK", "1")() // so we don't return early 156 | 157 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 158 | defer cancel() 159 | err := waitCredsAvailable(ctx, time.Millisecond*50) 160 | if err != nil { 161 | t.Fatalf("got err: %v", err) 162 | } 163 | if calls == 0 { 164 | t.Fatal("no calls were made to http server") 165 | } 166 | } 167 | 168 | func withTestEnv(k, v string) (cleanup func()) { 169 | orig, ok := os.LookupEnv(k) 170 | os.Setenv(k, v) 171 | if !ok { 172 | return func() { 173 | os.Unsetenv(k) 174 | } 175 | } 176 | return func() { 177 | os.Setenv(k, orig) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/cloudrun.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "regexp" 21 | "sort" 22 | "strings" 23 | "unicode" 24 | 25 | "github.com/AlecAivazis/survey/v2" 26 | "google.golang.org/api/option" 27 | runapi "google.golang.org/api/run/v1" 28 | ) 29 | 30 | const ( 31 | defaultRunRegion = "us-central1" 32 | defaultRunMemory = "512Mi" 33 | ) 34 | 35 | func projectRunLocations(ctx context.Context, project string) ([]string, error) { 36 | runSvc, err := runapi.NewService(ctx) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to initialize Run API client: %+v", err) 39 | } 40 | 41 | var locations []string 42 | if err := runapi.NewProjectsLocationsService(runSvc). 43 | List("projects/"+project).Pages(ctx, func(resp *runapi.ListLocationsResponse) error { 44 | for _, v := range resp.Locations { 45 | locations = append(locations, v.LocationId) 46 | } 47 | return nil 48 | }); err != nil { 49 | return nil, fmt.Errorf("request to query Cloud Run locations failed: %+v", err) 50 | } 51 | sort.Strings(locations) 52 | return locations, nil 53 | } 54 | 55 | func promptDeploymentRegion(ctx context.Context, project string) (string, error) { 56 | locations, err := projectRunLocations(ctx, project) 57 | if err != nil { 58 | return "", fmt.Errorf("cannot retrieve Cloud Run locations: %+v", err) 59 | } 60 | 61 | var choice string 62 | if err := survey.AskOne(&survey.Select{ 63 | Message: "Choose a region to deploy this application:", 64 | Options: locations, 65 | Default: defaultRunRegion, 66 | }, &choice, 67 | surveyIconOpts, 68 | survey.WithValidator(survey.Required), 69 | ); err != nil { 70 | return choice, fmt.Errorf("could not choose a region: %+v", err) 71 | } 72 | return choice, nil 73 | } 74 | 75 | func getService(project, name, region string) (*runapi.Service, error) { 76 | client, err := runClient(region) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to initialize Run API client: %w", err) 79 | } 80 | return client.Namespaces.Services.Get(fmt.Sprintf("namespaces/%s/services/%s", project, name)).Do() 81 | } 82 | 83 | func runClient(region string) (*runapi.APIService, error) { 84 | regionalEndpoint := fmt.Sprintf("https://%s-run.googleapis.com/", region) 85 | return runapi.NewService(context.TODO(), option.WithEndpoint(regionalEndpoint)) 86 | } 87 | 88 | func serviceURL(project, name, region string) (string, error) { 89 | service, err := getService(project, name, region) 90 | if err != nil { 91 | return "", fmt.Errorf("failed to get Service: %w", err) 92 | } 93 | return service.Status.Address.Url, nil 94 | } 95 | 96 | func envVars(project, name, region string) (map[string]struct{}, error) { 97 | service, err := getService(project, name, region) 98 | 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | existing := make(map[string]struct{}) 104 | 105 | for _, container := range service.Spec.Template.Spec.Containers { 106 | for _, envVar := range container.Env { 107 | existing[envVar.Name] = struct{}{} 108 | } 109 | } 110 | 111 | return existing, nil 112 | } 113 | 114 | // tryFixServiceName attempts replace the service name with a better one to 115 | // prevent deployment failures due to Cloud Run service naming constraints such 116 | // as: 117 | // 118 | // - names with a leading non-letter (e.g. digit or '-') are prefixed 119 | // - names over 63 characters are truncated 120 | // - names ending with a '-' have the suffix trimmed 121 | func tryFixServiceName(name string) (string, error) { 122 | if name == "" { 123 | return "", fmt.Errorf("service name can't be empty") 124 | } 125 | 126 | name = strings.ToLower(name) 127 | 128 | reg := regexp.MustCompile("[^a-z0-9-]+") 129 | 130 | name = reg.ReplaceAllString(name, "-") 131 | 132 | if name[0] == '-' { 133 | name = fmt.Sprintf("svc%s", name) 134 | } 135 | 136 | if !unicode.IsLetter([]rune(name)[0]) { 137 | name = fmt.Sprintf("svc-%s", name) 138 | } 139 | 140 | if len(name) > 63 { 141 | name = name[:63] 142 | } 143 | 144 | for name[len(name)-1] == '-' { 145 | name = name[:len(name)-1] 146 | } 147 | 148 | return name, nil 149 | } 150 | -------------------------------------------------------------------------------- /cmd/redirector/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | const ( 14 | hdrReferer = "Referer" 15 | ) 16 | 17 | func main() { 18 | log.SetFlags(log.Lmicroseconds) 19 | port := os.Getenv("PORT") 20 | if port == "" { 21 | port = "8080" 22 | } 23 | listenAddr := ":" + port 24 | 25 | // TODO(ahmetb): extract server logic to NewServer() to unit test it 26 | mux := http.NewServeMux() 27 | mux.HandleFunc("/", withLogging(redirect)) 28 | mux.HandleFunc("/button.svg", staticRedirect("https://storage.googleapis.com/cloudrun/button.svg", http.StatusMovedPermanently)) 29 | mux.HandleFunc("/button.png", staticRedirect("https://storage.googleapis.com/cloudrun/button.png", http.StatusMovedPermanently)) 30 | 31 | err := http.ListenAndServe(listenAddr, mux) 32 | if err == http.ErrServerClosed { 33 | log.Printf("server successfully closed") 34 | } else if err != nil { 35 | log.Fatal(err) 36 | } 37 | } 38 | 39 | func withLogging(next http.HandlerFunc) http.HandlerFunc { 40 | return func(w http.ResponseWriter, req *http.Request) { 41 | log.Printf("request: method=%s ip=%s referer=%s params=%s", req.Method, req.Header.Get("x-forwarded-for"), req.Header.Get(hdrReferer), req.URL.RawQuery) 42 | ww := &respRecorder{w: w} 43 | next(ww, req) 44 | log.Printf("response: status=%d location=%s", ww.status, w.Header().Get("location")) 45 | } 46 | } 47 | 48 | func staticRedirect(url string, code int) http.HandlerFunc { 49 | return func(w http.ResponseWriter, req *http.Request) { 50 | w.Header().Set("location", url) 51 | w.WriteHeader(code) 52 | } 53 | } 54 | 55 | func redirect(w http.ResponseWriter, req *http.Request) { 56 | if req.Method == http.MethodPost { 57 | manualRedirect(w, req) 58 | return 59 | } 60 | if req.Method != http.MethodGet && req.Method != http.MethodHead { 61 | w.WriteHeader(http.StatusMethodNotAllowed) 62 | fmt.Fprintf(w, "method %s not allowed", req.Method) 63 | return 64 | } 65 | 66 | repoParam := req.URL.Query().Get(paramRepo) 67 | referer := req.Header.Get(hdrReferer) 68 | 69 | // TODO(ahmetb): remove once https://github.community/t/chrome-85-breaks-referer/130039 is fixed 70 | if referer == "https://github.com/" && repoParam == "" { 71 | showRedirectForm(w, req) 72 | return 73 | } 74 | 75 | var repo repoRef 76 | if repoParam != "" { 77 | repo = customRepoRef{req.URL.Query()} 78 | } else { 79 | if referer == "" { 80 | w.WriteHeader(http.StatusBadRequest) 81 | fmt.Fprintf(w, "Cannot infer which repository to deploy (%s header was not present).\n", hdrReferer) 82 | fmt.Fprintln(w, "Go back, and click the 'Run on Google Cloud' button directly from the repository page.") 83 | return 84 | } 85 | r, err := parseReferer(referer, availableExtractors) 86 | if err != nil { 87 | w.WriteHeader(http.StatusBadRequest) 88 | fmt.Fprintf(w, errors.Wrapf(err, "failed to parse %s header", hdrReferer).Error()) 89 | return 90 | } 91 | repo = r 92 | } 93 | doRedirect(w, repo, req.URL.Query()) 94 | } 95 | 96 | func doRedirect(w http.ResponseWriter, r repoRef, overrides url.Values) { 97 | target := prepURL(r, overrides) 98 | w.Header().Set("location", target) 99 | w.WriteHeader(http.StatusTemporaryRedirect) 100 | } 101 | 102 | // TODO(ahmetb): remove once https://github.community/t/chrome-85-breaks-referer/130039 is fixed 103 | func showRedirectForm(w http.ResponseWriter, r *http.Request) { 104 | fmt.Fprintf(w, ` 105 | 106 | 107 | Cloud Run Button 108 | 123 | 124 | 125 |
126 |

You’re almost there!

127 |

128 | Unfortunately with the new Chrome 85, GitHub temporarily breaks our 129 | ability to determine which GitHub repository you came from. (You can 130 | help us ask GitHub to fix it!) 132 |

133 |

134 | Please provide the URL of the previous page you came from: 135 |

136 |
137 | 139 | 140 | 141 |
142 |
143 | 144 | `, r.URL.Query().Encode()) 145 | } 146 | 147 | // TODO(ahmetb): remove once https://github.community/t/chrome-85-breaks-referer/130039 is fixed 148 | func manualRedirect(w http.ResponseWriter, req *http.Request) { 149 | refURL := req.FormValue("url") 150 | origQuery, err := url.ParseQuery(req.FormValue("orig_query")) 151 | if err != nil { 152 | w.WriteHeader(http.StatusBadRequest) 153 | fmt.Fprintf(w, errors.Wrapf(err, "failed to parse orig_query=%q: %v", origQuery, err).Error()) 154 | return 155 | } 156 | repo, err := parseReferer(refURL, availableExtractors) 157 | if err != nil { 158 | w.WriteHeader(http.StatusBadRequest) 159 | fmt.Fprintf(w, errors.Wrapf(err, "failed to parse url into a github repository: %s", refURL).Error()) 160 | return 161 | } 162 | doRedirect(w, repo, origQuery) 163 | } 164 | 165 | type respRecorder struct { 166 | w http.ResponseWriter 167 | status int 168 | } 169 | 170 | func (rr *respRecorder) Header() http.Header { return rr.w.Header() } 171 | func (rr *respRecorder) Write(p []byte) (int, error) { return rr.w.Write(p) } 172 | func (rr *respRecorder) WriteHeader(statusCode int) { 173 | rr.status = statusCode 174 | rr.w.WriteHeader(statusCode) 175 | } 176 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/appfile.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "crypto/rand" 19 | "encoding/base64" 20 | "encoding/json" 21 | "fmt" 22 | "io" 23 | "os" 24 | "path/filepath" 25 | "sort" 26 | 27 | "github.com/fatih/color" 28 | 29 | "github.com/AlecAivazis/survey/v2" 30 | ) 31 | 32 | type env struct { 33 | Description string `json:"description"` 34 | Value string `json:"value"` 35 | Required *bool `json:"required"` 36 | Generator string `json:"generator"` 37 | Order *int `json:"order"` 38 | } 39 | 40 | type options struct { 41 | AllowUnauthenticated *bool `json:"allow-unauthenticated"` 42 | Memory string `json:"memory"` 43 | CPU string `json:"cpu"` 44 | Port int `json:"port"` 45 | HTTP2 *bool `json:"http2"` 46 | Concurrency int `json:"concurrency"` 47 | MaxInstances int `json:"max-instances"` 48 | } 49 | 50 | type hook struct { 51 | Commands []string `json:"commands"` 52 | } 53 | 54 | type buildpacks struct { 55 | Builder string `json:"builder"` 56 | } 57 | 58 | type build struct { 59 | Skip *bool `json:"skip"` 60 | Buildpacks buildpacks `json:"buildpacks"` 61 | } 62 | 63 | type hooks struct { 64 | PreCreate hook `json:"precreate"` 65 | PostCreate hook `json:"postcreate"` 66 | PreBuild hook `json:"prebuild"` 67 | PostBuild hook `json:"postbuild"` 68 | } 69 | 70 | type appFile struct { 71 | Name string `json:"name"` 72 | Env map[string]env `json:"env"` 73 | Options options `json:"options"` 74 | Build build `json:"build"` 75 | Hooks hooks `json:"hooks"` 76 | 77 | // The following are unused variables that are still silently accepted 78 | // for compatibility with Heroku app.json files. 79 | IgnoredDescription string `json:"description"` 80 | IgnoredKeywords []string `json:"keywords"` 81 | IgnoredLogo string `json:"logo"` 82 | IgnoredRepository string `json:"repository"` 83 | IgnoredWebsite string `json:"website"` 84 | IgnoredStack string `json:"stack"` 85 | IgnoredFormation interface{} `json:"formation"` 86 | } 87 | 88 | const appJSON = `app.json` 89 | 90 | // hasAppFile checks if the directory has an app.json file. 91 | func hasAppFile(dir string) (bool, error) { 92 | path := filepath.Join(dir, appJSON) 93 | fi, err := os.Stat(path) 94 | if err != nil { 95 | if os.IsNotExist(err) { 96 | return false, nil 97 | } 98 | return false, err 99 | } 100 | return fi.Mode().IsRegular(), nil 101 | } 102 | 103 | func parseAppFile(r io.Reader) (*appFile, error) { 104 | var v appFile 105 | d := json.NewDecoder(r) 106 | d.DisallowUnknownFields() 107 | if err := d.Decode(&v); err != nil { 108 | return nil, fmt.Errorf("failed to parse app.json: %+v", err) 109 | } 110 | 111 | // make "required" true by default 112 | for k, env := range v.Env { 113 | if env.Required == nil { 114 | v := true 115 | env.Required = &v 116 | } 117 | v.Env[k] = env 118 | } 119 | 120 | for k, env := range v.Env { 121 | if env.Generator == "secret" && env.Value != "" { 122 | return nil, fmt.Errorf("env var %q can't have both a value and use the secret generator", k) 123 | } 124 | } 125 | 126 | return &v, nil 127 | } 128 | 129 | // getAppFile returns the parsed app.json in the directory if it exists, 130 | // otherwise returns a zero appFile. 131 | func getAppFile(dir string) (appFile, error) { 132 | var v appFile 133 | ok, err := hasAppFile(dir) 134 | if err != nil { 135 | return v, err 136 | } 137 | if !ok { 138 | return v, nil 139 | } 140 | f, err := os.Open(filepath.Join(dir, appJSON)) 141 | if err != nil { 142 | return v, fmt.Errorf("error opening app.json file: %v", err) 143 | } 144 | defer f.Close() 145 | af, err := parseAppFile(f) 146 | if err != nil { 147 | return v, fmt.Errorf("failed to parse app.json file: %v", err) 148 | } 149 | return *af, nil 150 | } 151 | 152 | func rand64String() (string, error) { 153 | b := make([]byte, 64) 154 | if _, err := rand.Read(b); err != nil { 155 | return "", err 156 | } 157 | return base64.StdEncoding.EncodeToString(b), nil 158 | } 159 | 160 | // takes the envs defined in app.json, and the existing envs and returns the new envs that need to be prompted for 161 | func needEnvs(list map[string]env, existing map[string]struct{}) map[string]env { 162 | for k := range list { 163 | _, isPresent := existing[k] 164 | if isPresent { 165 | delete(list, k) 166 | } 167 | } 168 | 169 | return list 170 | } 171 | 172 | func promptOrGenerateEnvs(list map[string]env) ([]string, error) { 173 | var toGenerate []string 174 | var toPrompt = make(map[string]env) 175 | 176 | for k, e := range list { 177 | if e.Generator == "secret" { 178 | toGenerate = append(toGenerate, k) 179 | } else { 180 | toPrompt[k] = e 181 | } 182 | } 183 | 184 | generated, err := generateEnvs(toGenerate) 185 | if err != nil { 186 | return nil, err 187 | } 188 | 189 | prompted, err := promptEnv(toPrompt) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | return append(generated, prompted...), nil 195 | } 196 | 197 | func generateEnvs(keys []string) ([]string, error) { 198 | for i, key := range keys { 199 | resp, err := rand64String() 200 | if err != nil { 201 | return nil, fmt.Errorf("failed to generate secret for %s : %v", key, err) 202 | } 203 | keys[i] = key + "=" + resp 204 | } 205 | 206 | return keys, nil 207 | } 208 | 209 | type envKeyValuePair struct { 210 | k string 211 | v env 212 | } 213 | 214 | type envKeyValuePairs []envKeyValuePair 215 | 216 | func (e envKeyValuePairs) Len() int { return len(e) } 217 | 218 | func (e envKeyValuePairs) Swap(i, j int) { 219 | e[i], e[j] = e[j], e[i] 220 | } 221 | 222 | func (e envKeyValuePairs) Less(i, j int) bool { 223 | // if env.Order is unspecified, it should appear less. 224 | // otherwise, less values show earlier. 225 | if e[i].v.Order == nil { 226 | return false 227 | } 228 | if e[j].v.Order == nil { 229 | return true 230 | } 231 | return *e[i].v.Order < *e[j].v.Order 232 | } 233 | 234 | func sortedEnvs(envs map[string]env) []string { 235 | var v envKeyValuePairs 236 | for key, value := range envs { 237 | v = append(v, envKeyValuePair{key, value}) 238 | } 239 | sort.Sort(v) 240 | var keys []string 241 | for _, vv := range v { 242 | keys = append(keys, vv.k) 243 | } 244 | return keys 245 | } 246 | 247 | func promptEnv(list map[string]env) ([]string, error) { 248 | var out []string 249 | sortedKeys := sortedEnvs(list) 250 | 251 | for _, k := range sortedKeys { 252 | e := list[k] 253 | var resp string 254 | 255 | if err := survey.AskOne(&survey.Input{ 256 | Message: fmt.Sprintf("Value of %s environment variable (%s)", 257 | color.CyanString(k), 258 | color.HiBlackString(e.Description)), 259 | Default: e.Value, 260 | }, &resp, 261 | survey.WithValidator(survey.Required), 262 | surveyIconOpts, 263 | ); err != nil { 264 | return nil, fmt.Errorf("failed to get a response for environment variable %s", k) 265 | } 266 | out = append(out, k+"="+resp) 267 | } 268 | 269 | return out, nil 270 | } 271 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/appfile_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "io/ioutil" 19 | "os" 20 | "path/filepath" 21 | "reflect" 22 | "strings" 23 | "testing" 24 | ) 25 | 26 | var ( 27 | // used as convenience to take their reference in tests 28 | tru = true 29 | fals = false 30 | ) 31 | 32 | func TestHasAppFile(t *testing.T) { 33 | // non existing dir 34 | ok, err := hasAppFile(filepath.Join(os.TempDir(), "non-existing-dir")) 35 | if err != nil { 36 | t.Fatalf("got err for non-existing dir: %v", err) 37 | } else if ok { 38 | t.Fatal("returned true for non-existing dir") 39 | } 40 | 41 | tmpDir, err := ioutil.TempDir(os.TempDir(), "app.json-test") 42 | if err != nil { 43 | t.Fatal(err) 44 | } 45 | defer os.RemoveAll(tmpDir) 46 | 47 | // no file 48 | ok, err = hasAppFile(tmpDir) 49 | if err != nil { 50 | t.Fatalf("failed check for dir %s", tmpDir) 51 | } else if ok { 52 | t.Fatalf("returned false when app.json doesn't exist in dir %s", tmpDir) 53 | } 54 | 55 | // if app.json is a dir, must return false 56 | appJSON := filepath.Join(tmpDir, "app.json") 57 | if err := os.Mkdir(appJSON, 0755); err != nil { 58 | t.Fatal(err) 59 | } 60 | ok, err = hasAppFile(tmpDir) 61 | if err != nil { 62 | t.Fatal(err) 63 | } else if ok { 64 | t.Fatalf("reported true when app.json is a dir") 65 | } 66 | if err := os.RemoveAll(appJSON); err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | // write file 71 | if err := ioutil.WriteFile(appJSON, []byte(`{}`), 0644); err != nil { 72 | t.Fatal(err) 73 | } 74 | ok, err = hasAppFile(tmpDir) 75 | if err != nil { 76 | t.Fatal(err) 77 | } else if !ok { 78 | t.Fatalf("reported false when app.json exists") 79 | } 80 | } 81 | 82 | func Test_parseAppFile(t *testing.T) { 83 | 84 | tests := []struct { 85 | name string 86 | args string 87 | want *appFile 88 | wantErr bool 89 | }{ 90 | {"empty json is EOF", ``, nil, true}, 91 | {"empty object ok", `{}`, &appFile{}, false}, 92 | {"bad object at root", `1`, nil, true}, 93 | {"unknown field", `{"foo":"bar"}`, nil, true}, 94 | {"allow-unauthenticated true", `{"options": {"allow-unauthenticated": true}}`, 95 | &appFile{Options: options{AllowUnauthenticated: &tru}}, false}, 96 | {"wrong env type", `{"env": "foo"}`, nil, true}, 97 | {"wrong env value type", `{"env": {"foo":"bar"}}`, nil, true}, 98 | {"env not array", `{"env": []}`, nil, true}, 99 | {"empty env list is ok", `{"env": {}}`, &appFile{Env: map[string]env{}}, false}, 100 | {"non-string key type in env", `{"env": { 101 | 1: {} 102 | }}`, nil, true}, 103 | {"unknown field in env", `{"env": { 104 | "KEY": {"unknown":"value"} 105 | }}`, nil, true}, 106 | {"required is true by default", `{ 107 | "env": {"KEY":{}}}`, &appFile{Env: map[string]env{ 108 | "KEY": {Required: &tru}}}, false}, 109 | {"required can be set to false", `{ 110 | "env": {"KEY":{"required":false}}}`, &appFile{ 111 | Env: map[string]env{"KEY": {Required: &fals}}}, false}, 112 | {"required has to be bool", `{ 113 | "env": {"KEY":{"required": "false"}}}`, nil, true}, 114 | {"value has to be string", `{ 115 | "env": {"KEY":{"value": 100}}}`, nil, true}, 116 | {"generator secret", `{ 117 | "env": {"KEY":{"generator": "secret"}}}`, &appFile{Env: map[string]env{ 118 | "KEY": {Required: &tru, Generator: "secret"}}}, false}, 119 | {"generator secret and value", `{ 120 | "env": {"KEY":{"generator": "secret", "value": "asdf"}}}`, nil, true}, 121 | {"parses ok", `{ 122 | "name": "foo", 123 | "env": { 124 | "KEY_1": { 125 | "required": false, 126 | "description": "key 1 is cool" 127 | }, 128 | "KEY_2": { 129 | "value": "k2" 130 | } 131 | }}`, 132 | &appFile{ 133 | Name: "foo", 134 | Options: options{}, 135 | Env: map[string]env{ 136 | "KEY_1": { 137 | Required: &fals, 138 | Description: "key 1 is cool", 139 | }, 140 | "KEY_2": { 141 | Value: "k2", 142 | Required: &tru, 143 | }, 144 | }}, false}, 145 | {"precreate", `{ 146 | "hooks": { 147 | "precreate": { 148 | "commands": [ 149 | "echo pre", 150 | "date" 151 | ] 152 | } 153 | }}`, &appFile{Hooks: hooks{PreCreate: hook{Commands: []string{"echo pre", "date"}}}}, false}, 154 | {"postcreate", `{ 155 | "hooks": { 156 | "postcreate": { 157 | "commands": [ 158 | "echo post" 159 | ] 160 | } 161 | }}`, &appFile{Hooks: hooks{PostCreate: hook{Commands: []string{"echo post"}}}}, false}, 162 | } 163 | for _, tt := range tests { 164 | t.Run(tt.name, func(t *testing.T) { 165 | got, err := parseAppFile(strings.NewReader(tt.args)) 166 | if (err != nil) != tt.wantErr { 167 | t.Errorf("parseAppFile() error = %v, wantErr %v", err, tt.wantErr) 168 | return 169 | } 170 | if !reflect.DeepEqual(got, tt.want) { 171 | t.Errorf("parseAppFile() = %v, want %v", got, tt.want) 172 | } 173 | }) 174 | } 175 | } 176 | 177 | func Test_parseAppFile_parsesIgnoredKnownFields(t *testing.T) { 178 | appFile := `{ 179 | "description": "String", 180 | "repository": "URL", 181 | "logo": "URL", 182 | "website": "URL", 183 | "keywords": ["String", "String"] 184 | }` 185 | _, err := parseAppFile(strings.NewReader(appFile)) 186 | if err != nil { 187 | t.Fatalf("app.json with ignored but known fields failed: %v", err) 188 | } 189 | } 190 | 191 | func TestGetAppFile(t *testing.T) { 192 | dir, err := ioutil.TempDir(os.TempDir(), "app.json-test") 193 | if err != nil { 194 | t.Fatal(err) 195 | } 196 | defer os.RemoveAll(dir) 197 | 198 | // non existing file must return zero 199 | v, err := getAppFile(dir) 200 | if err != nil { 201 | t.Fatal(err) 202 | } 203 | var zero appFile 204 | if !reflect.DeepEqual(v, zero) { 205 | t.Fatalf("not zero value: got=%#v, expected=%#v", v, zero) 206 | } 207 | 208 | // captures parse error 209 | if err := ioutil.WriteFile(filepath.Join(dir, "app.json"), []byte(` 210 | {"env": {"KEY": 1 }} 211 | `), 0644); err != nil { 212 | t.Fatal(err) 213 | } 214 | if _, err := getAppFile(dir); err == nil { 215 | t.Fatal("was expected to fail with invalid json") 216 | } 217 | 218 | // parse valid file 219 | if err := ioutil.WriteFile(filepath.Join(dir, "app.json"), []byte(` 220 | {"env": {"KEY": {"value":"bar"} }} 221 | `), 0644); err != nil { 222 | t.Fatal(err) 223 | } 224 | 225 | v, err = getAppFile(dir) 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | expected := appFile{Env: map[string]env{ 230 | "KEY": {Value: "bar", Required: &tru}, 231 | }} 232 | if !reflect.DeepEqual(v, expected) { 233 | t.Fatalf("wrong parsed value: got=%#v, expected=%#v", v, expected) 234 | } 235 | } 236 | 237 | func Test_sortedEnvs(t *testing.T) { 238 | envs := map[string]env{ 239 | "NIL_ORDER": {}, 240 | "ORDER_100": {Order: mkInt(100)}, 241 | "ORDER_0": {Order: mkInt(0)}, 242 | "ORDER_-10": {Order: mkInt(-10)}, 243 | "ORDER_50": {Order: mkInt(50)}, 244 | } 245 | got := sortedEnvs(envs) 246 | expected := []string{ 247 | "ORDER_-10", "ORDER_0", "ORDER_50", "ORDER_100", "NIL_ORDER", 248 | } 249 | 250 | if !reflect.DeepEqual(got, expected) { 251 | t.Fatalf("sorted envs in wrong order: expected:%v\ngot=%v", expected, got) 252 | } 253 | } 254 | 255 | func mkInt(i int) *int { 256 | return &i 257 | } 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloud Run Button 2 | 3 | If you have a public repository, you can add this button to your `README.md` and 4 | let anyone deploy your application to [Google Cloud Run][run] with a single 5 | click. 6 | 7 | [run]: https://cloud.google.com/run 8 | 9 | Try it out with a "hello, world" Go application ([source](https://github.com/GoogleCloudPlatform/cloud-run-hello)): 10 | 11 | [![Run on Google 12 | Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run/?git_repo=https://github.com/GoogleCloudPlatform/cloud-run-hello.git) 13 | 14 | ### Demo 15 | 16 | [![Cloud Run Button Demo](assets/cloud-run-button.png)](https://storage.googleapis.com/cloudrun/cloud-run-button.gif) 17 | 18 | ### Add the Cloud Run Button to Your Repo's README 19 | 20 | 1. Copy & paste this markdown: 21 | 22 | ```text 23 | [![Run on Google Cloud](https://deploy.cloud.run/button.svg)](https://deploy.cloud.run) 24 | ``` 25 | 26 | 1. If the repo contains a `Dockerfile`, it will be built using the `docker build` command. If the repo uses Maven for 27 | the build and it contains the [Jib plugin](https://github.com/GoogleContainerTools/jib/tree/master/jib-maven-plugin), 28 | then the container image will be built with Jib 29 | ([Jib Spring Boot Sample](https://github.com/GoogleContainerTools/jib/tree/master/examples/spring-boot)). Otherwise, 30 | [CNCF Buildpacks](https://buildpacks.io/) (i.e. the `pack build` command) will attempt to build the repo 31 | ([buildpack samples][buildpack-samples]). Alternatively, you can skip these built-in build methods using the 32 | `build.skip` field (see below) and use a `prebuild` or `postbuild` hook to build the container image yourself. 33 | 34 | [buildpack-samples]: https://github.com/GoogleCloudPlatform/buildpack-samples 35 | 36 | ### Customizing source repository parameters 37 | 38 | - When no parameters are passed, the referer is used to detect the git repo and branch 39 | - To specify a git repo, add a `git_repo=URL` query parameter 40 | - To specify a git branch, add a `revision=BRANCH_NAME` query parameter. 41 | - To run the build in a subdirectory of the repo, add a `dir=SUBDIR` query parameter. 42 | 43 | 44 | ### Customizing deployment parameters 45 | 46 | If you include an `app.json` at the root of your repository, it allows you 47 | customize the experience such as defining an alternative service name, or 48 | prompting for additional environment variables. 49 | 50 | For example, a fully populated `app.json` file looks like this: 51 | 52 | ```json 53 | { 54 | "name": "foo-app", 55 | "env": { 56 | "BACKGROUND_COLOR": { 57 | "description": "specify a css color", 58 | "value": "#fefefe", 59 | "required": false 60 | }, 61 | "TITLE": { 62 | "description": "title for your site" 63 | }, 64 | "APP_SECRET": { 65 | "generator": "secret" 66 | }, 67 | "ORDERED_ENV": { 68 | "description": "control the order env variables are prompted", 69 | "order": 100 70 | } 71 | }, 72 | "options": { 73 | "allow-unauthenticated": false, 74 | "memory": "512Mi", 75 | "cpu": "1", 76 | "port": 80, 77 | "http2": false, 78 | "concurrency": 80, 79 | "max-instances": 10 80 | }, 81 | "build": { 82 | "skip": false, 83 | "buildpacks": { 84 | "builder": "some/builderimage" 85 | } 86 | }, 87 | "hooks": { 88 | "prebuild": { 89 | "commands": [ 90 | "./my-custom-prebuild" 91 | ] 92 | }, 93 | "postbuild": { 94 | "commands": [ 95 | "./my-custom-postbuild" 96 | ] 97 | }, 98 | "precreate": { 99 | "commands": [ 100 | "echo 'test'" 101 | ] 102 | }, 103 | "postcreate": { 104 | "commands": [ 105 | "./setup.sh" 106 | ] 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | Reference: 113 | 114 | - `name`: _(optional, default: repo name, or sub-directory name if specified)_ 115 | Name of the Cloud Run service and the built container image. Not validated for 116 | naming restrictions. 117 | - `env`: _(optional)_ Prompt user for environment variables. 118 | - `description`: _(optional)_ short explanation of what the environment 119 | variable does, keep this short to make sure it fits into a line. 120 | - `value`: _(optional)_ default value for the variable, should be a string. 121 | - `required`, _(optional, default: `true`)_ indicates if they user must provide 122 | a value for this variable. 123 | - `generator`, _(optional)_ use a generator for the value, currently only support `secret` 124 | - `order`, _(optional)_ if specified, used to indicate the order in which the 125 | variable is prompted to the user. If some variables specify this and some 126 | don't, then the unspecified ones are prompted last. 127 | - `options`: _(optional)_ Options when deploying the service 128 | - `allow-unauthenticated`: _(optional, default: `true`)_ allow unauthenticated requests 129 | - `memory`: _(optional)_ memory for each instance 130 | - `cpu`: _(optional)_ cpu for each instance 131 | - `port`: _(optional)_ if your application doesn't respect the PORT environment 132 | variable provided by Cloud Run, specify the port number it listens on 133 | - `http2`: _(optional)_ use http2 for the connection 134 | - `concurrency`: _(optional)_ concurrent requests for each instance 135 | - `max-instances`: _(optional)_ autoscaling limit (max 1000) 136 | - `build`: _(optional)_ Build configuration 137 | - `skip`: _(optional, default: `false`)_ skips the built-in build methods (`docker build`, `Maven Jib`, and 138 | `buildpacks`), but still allows for `prebuild` and `postbuild` hooks to be run in order to build the container image 139 | manually 140 | - `buildpacks`: _(optional)_ buildpacks config (Note: Additional Buildpack config can be specified using a `project.toml` file. [See the spec for details](https://buildpacks.io/docs/reference/config/project-descriptor/).) 141 | - `builder`: _(optional, default: `gcr.io/buildpacks/builder:v1`)_ overrides the buildpack builder image 142 | - `hooks`: _(optional)_ Run commands in separate bash shells with the environment variables configured for the 143 | application and environment variables `GOOGLE_CLOUD_PROJECT` (Google Cloud project), `GOOGLE_CLOUD_REGION` 144 | (selected Google Cloud Region), `K_SERVICE` (Cloud Run service name), `IMAGE_URL` (container image URL), `APP_DIR` 145 | (application directory). Command outputs are shown as they are executed. 146 | - `prebuild`: _(optional)_ Runs the specified commands before running the built-in build methods. Use the `IMAGE_URL` 147 | environment variable to determine the container image name you need to build. 148 | - `commands`: _(array of strings)_ The list of commands to run 149 | - `postbuild`: _(optional)_ Runs the specified commands after running the built-in build methods. Use the `IMAGE_URL` 150 | environment variable to determine the container image name you need to build. 151 | - `commands`: _(array of strings)_ The list of commands to run 152 | - `precreate`: _(optional)_ Runs the specified commands before the service has been created 153 | - `commands`: _(array of strings)_ The list of commands to run 154 | - `postcreate`: _(optional)_ Runs the specified commands after the service has been created; the `SERVICE_URL` environment variable provides the URL of the deployed Cloud Run service 155 | - `commands`: _(array of strings)_ The list of commands to run 156 | 157 | ### Notes 158 | 159 | - Disclaimer: This is not an officially supported Google product. 160 | - See [LICENSE](./LICENSE) for the licensing information. 161 | - See [Contribution Guidelines](./CONTRIBUTING.md) on how to contribute. 162 | -------------------------------------------------------------------------------- /tests/run_integration_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import click 3 | import os 4 | import shutil 5 | import subprocess 6 | import time 7 | from urllib import request, error 8 | 9 | from googleapiclient.discovery import build as api 10 | 11 | 12 | GIT_URL = os.environ.get( 13 | "GIT_URL", "https://github.com/GoogleCloudPlatform/cloud-run-button" 14 | ) 15 | GIT_BRANCH = os.environ.get("GIT_BRANCH", "master") 16 | TESTS_DIR = "tests" 17 | 18 | # Keep to Python 3.7 systems (gcloud image currently Python 3.7.3) 19 | GOOGLE_CLOUD_PROJECT = os.environ.get("GOOGLE_CLOUD_PROJECT", None) 20 | if not GOOGLE_CLOUD_PROJECT: 21 | raise Exception("'GOOGLE_CLOUD_PROJECT' env var not found") 22 | 23 | GOOGLE_CLOUD_REGION = os.environ.get("GOOGLE_CLOUD_REGION", None) 24 | if not GOOGLE_CLOUD_REGION: 25 | raise Exception("'GOOGLE_CLOUD_REGION' env var not found") 26 | 27 | WORKING_DIR = os.environ.get("WORKING_DIR", ".") 28 | 29 | DEBUG = os.environ.get("DEBUG", False) 30 | if DEBUG == "": 31 | DEBUG = False 32 | 33 | ############################################################################### 34 | 35 | 36 | def debugging(*args): 37 | c = click.get_current_context() 38 | output = " ".join([str(k) for k in args]) 39 | if DEBUG: 40 | print(f"🐞 {output}") 41 | 42 | 43 | def print_help_msg(command): 44 | with click.Context(command) as ctx: 45 | click.echo(command.get_help(ctx)) 46 | 47 | 48 | def gcloud(*args): 49 | """Invoke the gcloud executable""" 50 | return run_shell( 51 | ["gcloud"] 52 | + list(args) 53 | + [ 54 | "--platform", 55 | "managed", 56 | "--project", 57 | GOOGLE_CLOUD_PROJECT, 58 | "--region", 59 | GOOGLE_CLOUD_REGION, 60 | ] 61 | ) 62 | 63 | 64 | def cloudshell_open(directory, repo_url, git_branch): 65 | """Invoke the cloudshell_open executable.""" 66 | params = [ 67 | f"{WORKING_DIR}/cloudshell_open", 68 | f"--repo_url={repo_url}", 69 | f"--git_branch={git_branch}", 70 | ] 71 | 72 | if directory: 73 | params += [f"--dir={TESTS_DIR}/{directory}"] 74 | return run_shell(params) 75 | 76 | 77 | def run_shell(params): 78 | """Invoke the given subproceess, capturing output status and returning stdout""" 79 | debugging("Running:", " ".join(params)) 80 | 81 | env = {} 82 | env.update(os.environ) 83 | env.update({"TRUSTED_ENVIRONMENT": "true", "SKIP_CLONE_REPORTING": "true"}) 84 | 85 | resp = subprocess.run(params, capture_output=True, env=env) 86 | 87 | output = resp.stdout.decode("utf-8") 88 | error = resp.stderr.decode("utf-8") 89 | 90 | if DEBUG: 91 | debugging("stdout:", output or "") 92 | debugging("stderr:", error or "") 93 | 94 | if resp.returncode != 0: 95 | raise ValueError( 96 | f"Command error.\nCommand '{' '.join(params)}' returned {resp.returncode}.\nError: {error}\nOutput: {output}" 97 | ) 98 | return output 99 | 100 | 101 | def clean_clone(folder_name): 102 | """Remove the cloned code""" 103 | if os.path.isdir(folder_name): 104 | debugging(f"🟨 Removing old {folder_name} code clone") 105 | shutil.rmtree(folder_name) 106 | 107 | 108 | ############################################################################### 109 | 110 | 111 | def deploy_service(directory, repo_url, repo_branch, dirty): 112 | """Deploy a Cloud Run service using the Cloud Run Button""" 113 | 114 | # The repo name is the last part of a github URL, without the org/user. 115 | # The service name will be either the directory name, or the repo name 116 | # The folder name will be the repo name 117 | repo_name = repo_url.split("/")[-1] 118 | service_name = directory or repo_name 119 | folder_name = repo_name 120 | 121 | clean_clone(folder_name) 122 | 123 | if not dirty: 124 | debugging(f"🟨 Removing old service {service_name} (if it exists)") 125 | delete_service(service_name) 126 | else: 127 | print(f"🙈 Keeping the old service {service_name} (if it exists)") 128 | 129 | print("🟦 Pressing the Cloud Run button...") 130 | cloudshell_open(directory=directory, repo_url=repo_url, git_branch=repo_branch) 131 | 132 | run = api("run", "v1") 133 | service_fqdn = f"projects/{GOOGLE_CLOUD_PROJECT}/locations/{GOOGLE_CLOUD_REGION}/services/{service_name}" 134 | service_obj = run.projects().locations().services().get(name=service_fqdn).execute() 135 | 136 | service_url = service_obj["status"]["url"] 137 | 138 | clean_clone(folder_name) 139 | return service_url 140 | 141 | 142 | def delete_service(service_name): 143 | try: 144 | gcloud( 145 | "run", 146 | "services", 147 | "delete", 148 | service_name, 149 | "--quiet", 150 | ) 151 | debugging(f"Service {service_name} deleted.") 152 | except ValueError: 153 | debugging(f"Service {service_name} not deleted, as it does not exist. ") 154 | pass 155 | 156 | 157 | def get_url(service_url, expected_status=200): 158 | """GET a URL, returning the status and body""" 159 | debugging(f"Service: {service_url}") 160 | status, body = "","" 161 | 162 | try: 163 | request.urlcleanup() # reset cache 164 | resp = request.urlopen(service_url) 165 | status = resp.status 166 | body = resp.read().decode("utf-8") 167 | 168 | except error.HTTPError as e: 169 | # Sometimes errors are OK 170 | if e.code == expected_status: 171 | status = e.code 172 | body = e.msg 173 | 174 | debugging(f"Status: {status}") 175 | debugging(f"Body: {body[-100:]}") 176 | return status, body 177 | 178 | 179 | ############################################################################### 180 | 181 | 182 | @click.group() 183 | def cli() -> None: 184 | """Tool for testing Cloud Run Button deployments""" 185 | pass 186 | 187 | 188 | @cli.command() 189 | @click.option("--description", help="Test description") 190 | @click.option("--repo_url", help="Repo URL to deploy") 191 | @click.option("--repo_branch", default=GIT_BRANCH, help="Branch in Repo URL to deploy") 192 | @click.option("--directory", help="Directory in repo to deploy") 193 | @click.option("--expected_status", default=200, help="Status code to expect") 194 | @click.option("--expected_text", help="Text in service to expect") 195 | @click.option("--dirty", is_flag=True, default=False, help="Keep existing service") 196 | def deploy( 197 | description, 198 | directory, 199 | repo_url, 200 | repo_branch, 201 | expected_status, 202 | expected_text, 203 | dirty, 204 | ): 205 | """Run service tests. 206 | 207 | Takes a repo url (defaulting to the button's own repo), and an optional directory. 208 | Deploys the service with the Cloud Run Button, and checks the body and status of the resulting service.""" 209 | 210 | if not directory and not repo_url: 211 | print_help_msg(deploy) 212 | raise ValueError( 213 | f"Must supply either a directory for the default repo ({GIT_URL}) or a custom repo.\n" 214 | ) 215 | 216 | if not repo_url: 217 | repo_url = GIT_URL 218 | 219 | print( 220 | f"\nRunning {description or directory or 'a test'}\nConfig: {directory or 'root'} in {repo_url} on branch {repo_branch}." 221 | ) 222 | service_url = deploy_service(directory, repo_url, repo_branch, dirty) 223 | time.sleep(2) 224 | status, body = get_url(service_url, expected_status) 225 | print(f"⬜ Service deployed to {service_url}.") 226 | 227 | details = { 228 | "Service URL": service_url, 229 | "Expected Status": expected_status, 230 | "Status": status, 231 | "Expected": expected_text, 232 | "Text": body, 233 | } 234 | debugging_details = "\n".join([f"{k}: {v}" for k, v in details.items()]) 235 | 236 | if expected_status == status: 237 | print(f"🟢 Service returned expected status {expected_status}.") 238 | else: 239 | 240 | print( 241 | f"❌ Service did not return expected status (got {status}, expected {expected_status})." 242 | ) 243 | raise ValueError(f"Error: Expected status not found:\n{debugging_details}") 244 | 245 | if expected_text: 246 | if expected_text in body: 247 | print(f'🟢 Service returned expected content ("{expected_text}").') 248 | else: 249 | print( 250 | f"❌ Service did not return expected content ({expected_text} not in body).\nBody: {body}" 251 | ) 252 | raise ValueError(f"Error: Expected value not found.\n{debugging_details}") 253 | print(f"✅ Test successful.") 254 | 255 | time.sleep(60) ## avoid quota issues 256 | 257 | 258 | if __name__ == "__main__": 259 | cli() 260 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/deploy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "google.golang.org/api/googleapi" 12 | runapi "google.golang.org/api/run/v1" 13 | ) 14 | 15 | // parseEnv parses K=V pairs into a map. 16 | func parseEnv(envs []string) map[string]string { 17 | out := make(map[string]string) 18 | for _, v := range envs { 19 | p := strings.SplitN(v, "=", 2) 20 | out[p[0]] = p[1] 21 | } 22 | return out 23 | } 24 | 25 | // deploy reimplements the "gcloud run deploy" command, including setting IAM policy and 26 | // waiting for Service to be Ready. 27 | func deploy(project, name, image, region string, envs []string, options options) (string, error) { 28 | envVars := parseEnv(envs) 29 | 30 | client, err := runClient(region) 31 | if err != nil { 32 | return "", fmt.Errorf("failed to initialize Run API client: %w", err) 33 | } 34 | 35 | svc, err := getService(project, name, region) 36 | if err == nil { 37 | // existing service 38 | svc = patchService(svc, envVars, image, options) 39 | _, err = client.Namespaces.Services.ReplaceService("namespaces/"+project+"/services/"+name, svc).Do() 40 | if err != nil { 41 | if e, ok := err.(*googleapi.Error); ok { 42 | return "", fmt.Errorf("failed to deploy existing Service: code=%d message=%s -- %s", e.Code, e.Message, e.Body) 43 | } 44 | return "", fmt.Errorf("failed to deploy to existing Service: %w", err) 45 | } 46 | } else { 47 | // new service 48 | svc := newService(name, project, image, envVars, options) 49 | _, err = client.Namespaces.Services.Create("namespaces/"+project, svc).Do() 50 | if err != nil { 51 | if e, ok := err.(*googleapi.Error); ok { 52 | return "", fmt.Errorf("failed to deploy a new Service: code=%d message=%s -- %s", e.Code, e.Message, e.Body) 53 | } 54 | return "", fmt.Errorf("failed to deploy a new Service: %w", err) 55 | } 56 | } 57 | 58 | if options.AllowUnauthenticated == nil || *options.AllowUnauthenticated { 59 | if err := allowUnauthenticated(project, name, region); err != nil { 60 | return "", fmt.Errorf("failed to allow unauthenticated requests on the service: %w", err) 61 | } 62 | } 63 | 64 | if err := waitReady(project, name, region); err != nil { 65 | return "", err 66 | } 67 | 68 | out, err := getService(project, name, region) 69 | if err != nil { 70 | return "", fmt.Errorf("failed to get service after deploying: %w", err) 71 | } 72 | return out.Status.Url, nil 73 | } 74 | 75 | func optionsToResourceRequirements(options options) *runapi.ResourceRequirements { 76 | limits := make(map[string]string) 77 | if options.Memory != "" { 78 | limits["memory"] = options.Memory 79 | } 80 | if options.CPU != "" { 81 | limits["cpu"] = options.CPU 82 | } 83 | 84 | return &runapi.ResourceRequirements{Limits: limits} 85 | } 86 | 87 | func optionsToContainerSpec(options options) *runapi.ContainerPort { 88 | var containerPortName = "http1" 89 | if options.HTTP2 != nil && *options.HTTP2 { 90 | containerPortName = "h2c" 91 | } 92 | 93 | var containerPort = 8080 94 | if options.Port > 0 { 95 | containerPort = options.Port 96 | } 97 | return &runapi.ContainerPort{ContainerPort: int64(containerPort), Name: containerPortName} 98 | } 99 | 100 | // newService initializes a new Knative Service object with given properties. 101 | func newService(name, project, image string, envs map[string]string, options options) *runapi.Service { 102 | var envVars []*runapi.EnvVar 103 | for k, v := range envs { 104 | envVars = append(envVars, &runapi.EnvVar{Name: k, Value: v}) 105 | } 106 | 107 | svc := &runapi.Service{ 108 | ApiVersion: "serving.knative.dev/v1", 109 | Kind: "Service", 110 | Metadata: &runapi.ObjectMeta{ 111 | Annotations: make(map[string]string), 112 | Name: name, 113 | Namespace: project, 114 | }, 115 | Spec: &runapi.ServiceSpec{ 116 | Template: &runapi.RevisionTemplate{ 117 | Metadata: &runapi.ObjectMeta{ 118 | Name: generateRevisionName(name, 0), 119 | Annotations: make(map[string]string), 120 | }, 121 | Spec: &runapi.RevisionSpec{ 122 | ContainerConcurrency: int64(options.Concurrency), 123 | Containers: []*runapi.Container{ 124 | { 125 | Image: image, 126 | Env: envVars, 127 | Resources: optionsToResourceRequirements(options), 128 | Ports: []*runapi.ContainerPort{optionsToContainerSpec(options)}, 129 | }, 130 | }, 131 | }, 132 | ForceSendFields: nil, 133 | NullFields: nil, 134 | }, 135 | }, 136 | } 137 | 138 | applyMeta(svc.Metadata, image) 139 | applyMeta(svc.Spec.Template.Metadata, image) 140 | applyScaleMeta(svc.Spec.Template.Metadata, "maxScale", options.MaxInstances) 141 | 142 | return svc 143 | } 144 | 145 | // applyMeta applies optional annotations to the specified Metadata.Annotation fields 146 | func applyMeta(meta *runapi.ObjectMeta, userImage string) { 147 | if meta.Annotations == nil { 148 | meta.Annotations = make(map[string]string) 149 | } 150 | meta.Annotations["client.knative.dev/user-image"] = userImage 151 | meta.Annotations["run.googleapis.com/client-name"] = "cloud-run-button" 152 | } 153 | 154 | // applyScaleMeta optional annotations for scale commands 155 | func applyScaleMeta(meta *runapi.ObjectMeta, scaleType string, scaleValue int) { 156 | if scaleValue > 0 { 157 | meta.Annotations["autoscaling.knative.dev"+scaleType] = strconv.Itoa(scaleValue) 158 | } 159 | } 160 | 161 | // generateRevisionName attempts to generate a random revision name that is alphabetically increasing but also has 162 | // a random suffix. objectGeneration is the current object generation. 163 | func generateRevisionName(name string, objectGeneration int64) string { 164 | num := fmt.Sprintf("%05d", objectGeneration+1) 165 | out := name + "-" + num + "-" 166 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 167 | for i := 0; i < 3; i++ { 168 | out += string(rune(int('a') + r.Intn(26))) 169 | } 170 | return out 171 | } 172 | 173 | // patchService modifies an existing Service with requested changes. 174 | func patchService(svc *runapi.Service, envs map[string]string, image string, options options) *runapi.Service { 175 | // merge env vars 176 | svc.Spec.Template.Spec.Containers[0].Env = mergeEnvs(svc.Spec.Template.Spec.Containers[0].Env, envs) 177 | 178 | // update container image 179 | svc.Spec.Template.Spec.Containers[0].Image = image 180 | 181 | // update container port 182 | svc.Spec.Template.Spec.Containers[0].Ports[0] = optionsToContainerSpec(options) 183 | 184 | // apply metadata annotations 185 | applyMeta(svc.Metadata, image) 186 | applyMeta(svc.Spec.Template.Metadata, image) 187 | 188 | // apply scale metadata annotations 189 | applyScaleMeta(svc.Spec.Template.Metadata, "maxScale", options.MaxInstances) 190 | 191 | // update revision name 192 | svc.Spec.Template.Metadata.Name = generateRevisionName(svc.Metadata.Name, svc.Metadata.Generation) 193 | 194 | return svc 195 | } 196 | 197 | // mergeEnvs updates variables in existing, and adds missing ones. 198 | func mergeEnvs(existing []*runapi.EnvVar, env map[string]string) []*runapi.EnvVar { 199 | for i, ee := range existing { 200 | if v, ok := env[ee.Name]; ok { 201 | existing[i].Value = v 202 | delete(env, ee.Name) 203 | } 204 | } 205 | // add missing ones 206 | for k, v := range env { 207 | existing = append(existing, &runapi.EnvVar{Name: k, Value: v}) 208 | } 209 | return existing 210 | } 211 | 212 | // waitReady waits until the specified service reaches Ready status 213 | func waitReady(project, name, region string) error { 214 | wait := time.Minute * 4 215 | deadline := time.Now().Add(wait) 216 | for time.Now().Before(deadline) { 217 | svc, err := getService(project, name, region) 218 | if err != nil { 219 | return fmt.Errorf("failed to query Service for readiness: %w", err) 220 | } 221 | 222 | for _, cond := range svc.Status.Conditions { 223 | if cond.Type == "Ready" { 224 | if cond.Status == "True" { 225 | return nil 226 | } else if cond.Status == "False" { 227 | return fmt.Errorf("reason=%s message=%s", cond.Reason, cond.Message) 228 | } 229 | } 230 | } 231 | time.Sleep(time.Second * 2) 232 | } 233 | return fmt.Errorf("the service did not become ready in %s, check Cloud Console for logs to see why it failed", wait) 234 | } 235 | 236 | // allowUnauthenticated sets IAM policy on the specified Cloud Run service to give allUsers subject 237 | // roles/run.invoker role. 238 | func allowUnauthenticated(project, name, region string) error { 239 | client, err := runapi.NewService(context.TODO()) 240 | if err != nil { 241 | return fmt.Errorf("failed to initialize Run API client: %w", err) 242 | } 243 | 244 | res := fmt.Sprintf("projects/%s/locations/%s/services/%s", project, region, name) 245 | policy, err := client.Projects.Locations.Services.GetIamPolicy(res).Do() 246 | if err != nil { 247 | return fmt.Errorf("failed to get IAM policy for Cloud Run Service: %w", err) 248 | } 249 | 250 | policy.Bindings = append(policy.Bindings, &runapi.Binding{ 251 | Members: []string{"allUsers"}, 252 | Role: "roles/run.invoker", 253 | }) 254 | 255 | _, err = client.Projects.Locations.Services.SetIamPolicy(res, &runapi.SetIamPolicyRequest{Policy: policy}).Do() 256 | if err != nil { 257 | var extra string 258 | e, ok := err.(*googleapi.Error) 259 | if ok { 260 | extra = fmt.Sprintf("code=%d, message=%s -- %s", e.Code, e.Message, e.Body) 261 | } 262 | return fmt.Errorf("failed to set IAM policy for Cloud Run Service: %w %s", err, extra) 263 | } 264 | return nil 265 | } 266 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= 2 | cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= 3 | cloud.google.com/go/artifactregistry v1.16.3 h1:FPTr8KmB+K29uOg+3IITqdXHd1sP+Y+hxQtMvxW0MfM= 4 | cloud.google.com/go/artifactregistry v1.16.3/go.mod h1:eiLO70Qh5Z9Jbwctl0KdW5VzJ5HncWgNaYN0NdF8lmM= 5 | cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= 6 | cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= 7 | cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= 8 | cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 9 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= 10 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 11 | cloud.google.com/go/iam v1.4.2 h1:4AckGYAYsowXeHzsn/LCKWIwSWLkdb0eGjH8wWkd27Q= 12 | cloud.google.com/go/iam v1.4.2/go.mod h1:REGlrt8vSlh4dfCJfSEcNjLGq75wW75c5aU3FLOYq34= 13 | cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw= 14 | cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw= 15 | github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= 16 | github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 17 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 18 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 19 | github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= 20 | github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 21 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 22 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 23 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 27 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 28 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 29 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 30 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 31 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 32 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 33 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 34 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 35 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 36 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 37 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 38 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 39 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 40 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 41 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 42 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 43 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= 44 | github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= 45 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= 46 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= 47 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 48 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 49 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 50 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 51 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 52 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 53 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 54 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 55 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 56 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 57 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 58 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= 59 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 63 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 64 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 65 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 66 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 67 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 68 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 69 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= 70 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= 71 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= 72 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= 73 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 74 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 75 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 76 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 77 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 78 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 79 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= 80 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 81 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 82 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 83 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 84 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 85 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 86 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 87 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 88 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 89 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 90 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 91 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 92 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 93 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= 94 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 95 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 97 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 98 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 99 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 100 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 101 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 104 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 105 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 106 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 107 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 108 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 109 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 110 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 111 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 112 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 113 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 114 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 115 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 116 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 117 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 118 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 119 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 120 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 121 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 122 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 123 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 124 | google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs= 125 | google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4= 126 | google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 h1:qEFnJI6AnfZk0NNe8YTyXQh5i//Zxi4gBHwRgp76qpw= 127 | google.golang.org/genproto v0.0.0-20250324211829-b45e905df463/go.mod h1:SqIx1NV9hcvqdLHo7uNZDS5lrUJybQ3evo3+z/WBfA0= 128 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= 129 | google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= 130 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= 131 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 132 | google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= 133 | google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 134 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 135 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 136 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 137 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 138 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 139 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 140 | -------------------------------------------------------------------------------- /cmd/cloudshell_open/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // https://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "bufio" 19 | "context" 20 | "errors" 21 | "flag" 22 | "fmt" 23 | "os" 24 | "path/filepath" 25 | "strings" 26 | "time" 27 | 28 | "github.com/AlecAivazis/survey/v2" 29 | "github.com/GoogleCloudPlatform/cloud-run-button/cmd/instrumentless" 30 | "google.golang.org/api/transport" 31 | 32 | "cloud.google.com/go/compute/metadata" 33 | "github.com/briandowns/spinner" 34 | "github.com/fatih/color" 35 | ) 36 | 37 | const ( 38 | flRepoURL = "repo_url" 39 | flGitBranch = "git_branch" 40 | flSubDir = "dir" 41 | flPage = "page" 42 | flForceNewClone = "force_new_clone" 43 | flContext = "context" 44 | 45 | reauthCredentialsWaitTimeout = time.Minute * 2 46 | reauthCredentialsPollingInterval = time.Second 47 | 48 | billingCreateURL = "https://console.cloud.google.com/billing/create" 49 | trygcpURL = "https://console.cloud.google.com/trygcp" 50 | instrumentlessEvent = "crbutton" 51 | artifactRegistry = "cloud-run-source-deploy" 52 | ) 53 | 54 | var ( 55 | linkLabel = color.New(color.Bold, color.Underline) 56 | parameterLabel = color.New(color.FgHiYellow, color.Bold, color.Underline) 57 | errorLabel = color.New(color.FgRed, color.Bold) 58 | warningLabel = color.New(color.Bold, color.FgHiYellow) 59 | successLabel = color.New(color.Bold, color.FgGreen) 60 | successPrefix = fmt.Sprintf("[ %s ]", successLabel.Sprint("✓")) 61 | errorPrefix = fmt.Sprintf("[ %s ]", errorLabel.Sprint("✖")) 62 | infoPrefix = fmt.Sprintf("[ %s ]", warningLabel.Sprint("!")) 63 | // we have to reset the inherited color first from survey.QuestionIcon 64 | // see https://github.com/AlecAivazis/survey/issues/193 65 | questionPrefix = fmt.Sprintf("%s %s ]", 66 | color.New(color.Reset).Sprint("["), 67 | color.New(color.Bold, color.FgYellow).Sprint("?")) 68 | questionSelectFocusIcon = "❯" 69 | 70 | opts runOpts 71 | flags = flag.NewFlagSet("cloudshell_open", flag.ContinueOnError) 72 | ) 73 | 74 | func init() { 75 | flags.StringVar(&opts.repoURL, flRepoURL, "", "url to git repo") 76 | flags.StringVar(&opts.gitBranch, flGitBranch, "", "(optional) branch/revision to use from the git repo") 77 | flags.StringVar(&opts.subDir, flSubDir, "", "(optional) sub-directory to deploy in the repo") 78 | flags.StringVar(&opts.context, flContext, "", "(optional) arbitrary context") 79 | 80 | _ = flags.String(flPage, "", "ignored") 81 | _ = flags.Bool(flForceNewClone, false, "ignored") 82 | } 83 | func main() { 84 | usage := flags.Usage 85 | flags.Usage = func() {} // control when we print usage string 86 | if err := flags.Parse(os.Args[1:]); err != nil { 87 | if err == flag.ErrHelp { 88 | usage() 89 | return 90 | } else { 91 | fmt.Printf("%s flag parsing issue: %+v\n", warningLabel.Sprint("internal warning:"), err) 92 | } 93 | } 94 | 95 | if err := run(opts); err != nil { 96 | fmt.Printf("%s %+v\n", errorLabel.Sprint("Error:"), err) 97 | os.Exit(1) 98 | } 99 | } 100 | 101 | type runOpts struct { 102 | repoURL string 103 | gitBranch string 104 | subDir string 105 | context string 106 | } 107 | 108 | func logProgress(msg, endMsg, errMsg string) func(bool) { 109 | s := spinner.New(spinner.CharSets[9], 300*time.Millisecond) 110 | s.Prefix = "[ " 111 | s.Suffix = " ] " + msg 112 | s.Start() 113 | return func(success bool) { 114 | s.Stop() 115 | if success { 116 | if endMsg != "" { 117 | fmt.Printf("%s %s\n", successPrefix, endMsg) 118 | } 119 | } else { 120 | fmt.Printf("%s %s\n", errorPrefix, errMsg) 121 | } 122 | } 123 | } 124 | 125 | func run(opts runOpts) error { 126 | ctx := context.Background() 127 | highlight := func(s string) string { return color.CyanString(s) } 128 | parameter := func(s string) string { return parameterLabel.Sprint(s) } 129 | cmdColor := color.New(color.FgHiBlue) 130 | 131 | repo := opts.repoURL 132 | if repo == "" { 133 | return fmt.Errorf("--%s not specified", flRepoURL) 134 | } 135 | 136 | trusted := os.Getenv("TRUSTED_ENVIRONMENT") == "true" 137 | if !trusted { 138 | fmt.Printf("%s You launched this custom Cloud Shell image as \"Do not trust\".\n"+ 139 | "In this mode, your credentials are not available and this experience\n"+ 140 | "cannot deploy to Cloud Run. Start over and \"Trust\" the image.\n", errorLabel.Sprint("Error:")) 141 | return errors.New("aborting due to untrusted cloud shell environment") 142 | } 143 | 144 | end := logProgress("Waiting for Cloud Shell authorization...", 145 | "", 146 | "Failed to get GCP credentials. Please authorize Cloud Shell if you're presented with a prompt.", 147 | ) 148 | time.Sleep(time.Second * 2) 149 | waitCtx, cancelWait := context.WithTimeout(ctx, reauthCredentialsWaitTimeout) 150 | err := waitCredsAvailable(waitCtx, reauthCredentialsPollingInterval) 151 | cancelWait() 152 | end(err == nil) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | end = logProgress(fmt.Sprintf("Cloning git repository %s...", highlight(repo)), 158 | fmt.Sprintf("Cloned git repository %s.", highlight(repo)), 159 | fmt.Sprintf("Failed to clone git repository %s", highlight(repo))) 160 | cloneDir, err := handleRepo(repo) 161 | if trusted && os.Getenv("SKIP_CLONE_REPORTING") == "" { 162 | // TODO(ahmetb) had to introduce SKIP_CLONE_REPORTING env var here 163 | // to skip connecting to :8998 while testing locally if this var is set. 164 | if err := signalRepoCloneStatus(err == nil); err != nil { 165 | return err 166 | } 167 | } 168 | end(err == nil) 169 | if err != nil { 170 | return err 171 | } 172 | 173 | if opts.gitBranch != "" { 174 | if err := gitCheckout(cloneDir, opts.gitBranch); err != nil { 175 | return fmt.Errorf("failed to checkout revision %q: %+v", opts.gitBranch, err) 176 | } 177 | } 178 | 179 | appDir := cloneDir 180 | if opts.subDir != "" { 181 | // verify if --dir is valid 182 | appDir = filepath.Join(cloneDir, opts.subDir) 183 | if fi, err := os.Stat(appDir); err != nil { 184 | if os.IsNotExist(err) { 185 | return fmt.Errorf("sub-directory doesn't exist in the cloned repository: %s", appDir) 186 | } 187 | return fmt.Errorf("failed to check sub-directory in the repo: %v", err) 188 | } else if !fi.IsDir() { 189 | return fmt.Errorf("specified sub-directory path %s is not a directory", appDir) 190 | } 191 | } 192 | 193 | appFile, err := getAppFile(appDir) 194 | if err != nil { 195 | return fmt.Errorf("error attempting to read the app.json from the cloned repository: %+v", err) 196 | } 197 | 198 | project := os.Getenv("GOOGLE_CLOUD_PROJECT") 199 | 200 | for project == "" { 201 | var projects []string 202 | 203 | for len(projects) == 0 { 204 | end = logProgress("Retrieving your projects...", 205 | "Queried list of your projects", 206 | "Failed to retrieve your projects.", 207 | ) 208 | projects, err = listProjects() 209 | end(err == nil) 210 | if err != nil { 211 | return err 212 | } 213 | 214 | if len(projects) == 0 { 215 | fmt.Print(errorPrefix + " " + warningLabel.Sprint("You don't have any projects to deploy into.")) 216 | } 217 | } 218 | 219 | if len(projects) > 1 { 220 | fmt.Printf(successPrefix+" Found %s projects in your GCP account.\n", 221 | successLabel.Sprintf("%d", len(projects))) 222 | } 223 | 224 | project, err = promptProject(projects) 225 | if err != nil { 226 | fmt.Println(errorPrefix + " " + warningLabel.Sprint("You need to create a project")) 227 | err := promptInstrumentless() 228 | if err != nil { 229 | return err 230 | } 231 | } 232 | } 233 | 234 | if err := waitForBilling(project, func(p string) error { 235 | projectLabel := color.New(color.Bold, color.FgHiCyan).Sprint(project) 236 | 237 | fmt.Println(fmt.Sprintf(errorPrefix+" Project %s does not have an active billing account!", projectLabel)) 238 | 239 | billingAccounts, err := billingAccounts() 240 | if err != nil { 241 | return fmt.Errorf("could not get billing accounts: %v", err) 242 | } 243 | 244 | useExisting := false 245 | 246 | if len(billingAccounts) > 0 { 247 | useExisting, err = prompUseExistingBillingAccount(project) 248 | if err != nil { 249 | return err 250 | } 251 | } 252 | 253 | if !useExisting { 254 | err := promptInstrumentless() 255 | if err != nil { 256 | return err 257 | } 258 | } 259 | 260 | fmt.Println(infoPrefix + " Link the billing account to the project:" + 261 | "\n " + linkLabel.Sprintf("https://console.cloud.google.com/billing?project=%s", project)) 262 | 263 | fmt.Println(questionPrefix + " " + "Once you're done, press " + parameterLabel.Sprint("Enter") + " to continue: ") 264 | 265 | if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil { 266 | return err 267 | } 268 | 269 | // TODO(jamesward) automatically set billing account on project 270 | 271 | return nil 272 | }); err != nil { 273 | return err 274 | } 275 | 276 | end = logProgress( 277 | fmt.Sprintf("Enabling Cloud Run API on project %s...", highlight(project)), 278 | fmt.Sprintf("Enabled Cloud Run API on project %s.", highlight(project)), 279 | fmt.Sprintf("Failed to enable required APIs on project %s.", highlight(project))) 280 | err = enableAPIs(project, []string{"run.googleapis.com", "artifactregistry.googleapis.com"}) 281 | end(err == nil) 282 | if err != nil { 283 | return err 284 | } 285 | 286 | region := os.Getenv("GOOGLE_CLOUD_REGION") 287 | 288 | if region == "" { 289 | region, err = promptDeploymentRegion(ctx, project) 290 | if err != nil { 291 | return err 292 | } 293 | } 294 | 295 | end = logProgress( 296 | fmt.Sprintf("Setting up %s in region %s (if it doesn't already exist)", highlight(artifactRegistry), highlight(region)), 297 | fmt.Sprintf("Set up %s in region %s (if it doesn't already exist)", highlight(artifactRegistry), highlight(region)), 298 | "Failed to setup artifact registry.") 299 | err = createArtifactRegistry(project, region, artifactRegistry) 300 | end(err == nil) 301 | if err != nil { 302 | return err 303 | } 304 | 305 | repoName := filepath.Base(appDir) 306 | serviceName := repoName 307 | if appFile.Name != "" { 308 | serviceName = appFile.Name 309 | } 310 | serviceName, err = tryFixServiceName(serviceName) 311 | if err != nil { 312 | return err 313 | } 314 | 315 | image := fmt.Sprintf("%s-docker.pkg.dev/%s/%s/%s", region, project, artifactRegistry, serviceName) 316 | 317 | existingEnvVars := make(map[string]struct{}) 318 | // todo(jamesward) actually determine if the service exists instead of assuming it doesn't if we get an error 319 | existingService, err := getService(project, serviceName, region) 320 | if err == nil { 321 | // service exists 322 | existingEnvVars, err = envVars(project, serviceName, region) 323 | } 324 | 325 | neededEnvs := needEnvs(appFile.Env, existingEnvVars) 326 | 327 | envs, err := promptOrGenerateEnvs(neededEnvs) 328 | if err != nil { 329 | return err 330 | } 331 | 332 | projectEnv := fmt.Sprintf("GOOGLE_CLOUD_PROJECT=%s", project) 333 | regionEnv := fmt.Sprintf("GOOGLE_CLOUD_REGION=%s", region) 334 | serviceEnv := fmt.Sprintf("K_SERVICE=%s", serviceName) 335 | imageEnv := fmt.Sprintf("IMAGE_URL=%s", image) 336 | appDirEnv := fmt.Sprintf("APP_DIR=%s", appDir) 337 | inheritedEnv := os.Environ() 338 | 339 | hookEnvs := append([]string{projectEnv, regionEnv, serviceEnv, imageEnv, appDirEnv}, envs...) 340 | for key, value := range existingEnvVars { 341 | hookEnvs = append(hookEnvs, fmt.Sprintf("%s=%s", key, value)) 342 | } 343 | hookEnvs = append(hookEnvs, inheritedEnv...) 344 | 345 | pushImage := true 346 | 347 | if appFile.Hooks.PreBuild.Commands != nil { 348 | err = runScripts(appDir, appFile.Hooks.PreBuild.Commands, hookEnvs) 349 | } 350 | 351 | skipBuild := appFile.Build.Skip != nil && *appFile.Build.Skip == true 352 | 353 | skipDocker := false 354 | skipJib := false 355 | 356 | builderImage := "gcr.io/buildpacks/builder:v1" 357 | 358 | if appFile.Build.Buildpacks.Builder != "" { 359 | skipDocker = true 360 | skipJib = true 361 | builderImage = appFile.Build.Buildpacks.Builder 362 | } 363 | 364 | dockerFileExists, _ := dockerFileExists(appDir) 365 | jibMaven, _ := jibMavenConfigured(appDir) 366 | 367 | if skipBuild { 368 | fmt.Println(infoPrefix + " Skipping built-in build methods") 369 | } else { 370 | end = logProgress(fmt.Sprintf("Building container image %s", highlight(image)), 371 | fmt.Sprintf("Built container image %s", highlight(image)), 372 | "Failed to build container image.") 373 | 374 | if !skipDocker && dockerFileExists { 375 | fmt.Println(infoPrefix + " Attempting to build this application with its Dockerfile...") 376 | fmt.Println(infoPrefix + " FYI, running the following command:") 377 | cmdColor.Printf("\tdocker build -t %s %s\n", parameter(image), parameter(appDir)) 378 | err = dockerBuild(appDir, image) 379 | } else if !skipJib && jibMaven { 380 | pushImage = false 381 | fmt.Println(infoPrefix + " Attempting to build this application with Jib Maven plugin...") 382 | fmt.Println(infoPrefix + " FYI, running the following command:") 383 | cmdColor.Printf("\tmvn package jib:build -Dimage=%s\n", parameter(image)) 384 | err = jibMavenBuild(appDir, image) 385 | } else { 386 | fmt.Println(infoPrefix + " Attempting to build this application with Cloud Native Buildpacks (buildpacks.io)...") 387 | fmt.Println(infoPrefix + " FYI, running the following command:") 388 | cmdColor.Printf("\tpack build %s --path %s --builder %s\n", parameter(image), parameter(appDir), parameter(builderImage)) 389 | err = packBuild(appDir, image, builderImage) 390 | } 391 | 392 | end(err == nil) 393 | if err != nil { 394 | return fmt.Errorf("attempted to build and failed: %s", err) 395 | } 396 | } 397 | 398 | if appFile.Hooks.PostBuild.Commands != nil { 399 | err = runScripts(appDir, appFile.Hooks.PostBuild.Commands, hookEnvs) 400 | } 401 | 402 | if pushImage { 403 | fmt.Println(infoPrefix + " FYI, running the following command:") 404 | cmdColor.Printf("\tdocker push %s\n", parameter(image)) 405 | end = logProgress("Pushing container image...", 406 | "Pushed container image to Google Container Registry.", 407 | "Failed to push container image to Google Container Registry.") 408 | err = dockerPush(image) 409 | end(err == nil) 410 | if err != nil { 411 | return fmt.Errorf("failed to push image to %s: %+v", image, err) 412 | } 413 | } 414 | 415 | if existingService == nil { 416 | err = runScripts(appDir, appFile.Hooks.PreCreate.Commands, hookEnvs) 417 | if err != nil { 418 | return err 419 | } 420 | } 421 | 422 | optionsFlags := optionsToFlags(appFile.Options) 423 | 424 | serviceLabel := highlight(serviceName) 425 | fmt.Println(infoPrefix + " FYI, running the following command:") 426 | cmdColor.Printf("\tgcloud run deploy %s", parameter(serviceName)) 427 | cmdColor.Println("\\") 428 | cmdColor.Printf("\t --project=%s", parameter(project)) 429 | cmdColor.Println("\\") 430 | cmdColor.Printf("\t --platform=%s", parameter("managed")) 431 | cmdColor.Println("\\") 432 | cmdColor.Printf("\t --region=%s", parameter(region)) 433 | cmdColor.Println("\\") 434 | cmdColor.Printf("\t --image=%s", parameter(image)) 435 | if appFile.Options.Port > 0 { 436 | cmdColor.Println("\\") 437 | cmdColor.Printf("\t --port=%s", parameter(fmt.Sprintf("%d", appFile.Options.Port))) 438 | } 439 | if len(envs) > 0 { 440 | cmdColor.Println("\\") 441 | cmdColor.Printf("\t --update-env-vars=%s", parameter(strings.Join(envs, ","))) 442 | } 443 | 444 | for _, optionFlag := range optionsFlags { 445 | cmdColor.Println("\\") 446 | cmdColor.Printf("\t %s", optionFlag) 447 | } 448 | 449 | cmdColor.Println("") 450 | 451 | end = logProgress(fmt.Sprintf("Deploying service %s to Cloud Run...", serviceLabel), 452 | fmt.Sprintf("Successfully deployed service %s to Cloud Run.", serviceLabel), 453 | "Failed deploying the application to Cloud Run.") 454 | url, err := deploy(project, serviceName, image, region, envs, appFile.Options) 455 | end(err == nil) 456 | if err != nil { 457 | return err 458 | } 459 | 460 | hookEnvs = append(hookEnvs, fmt.Sprintf("SERVICE_URL=%s", url)) 461 | 462 | if existingService == nil { 463 | err = runScripts(appDir, appFile.Hooks.PostCreate.Commands, hookEnvs) 464 | if err != nil { 465 | return err 466 | } 467 | } 468 | 469 | fmt.Printf("* This application is billed only when it's handling requests.\n") 470 | fmt.Printf("* Manage this application at Cloud Console:\n\t") 471 | color.New(color.Underline, color.Bold).Printf("https://console.cloud.google.com/run/detail/%s/%s?project=%s\n", region, serviceName, project) 472 | fmt.Printf("* Learn more about Cloud Run:\n\t") 473 | color.New(color.Underline, color.Bold).Println("https://cloud.google.com/run/docs") 474 | fmt.Printf(successPrefix+" %s%s\n", 475 | color.New(color.Bold).Sprint("Your application is now live here:\n\t"), 476 | color.New(color.Bold, color.FgGreen, color.Underline).Sprint(url)) 477 | return nil 478 | } 479 | 480 | func optionsToFlags(options options) []string { 481 | var flags []string 482 | 483 | authSetting := "--allow-unauthenticated" 484 | if options.AllowUnauthenticated != nil && *options.AllowUnauthenticated == false { 485 | authSetting = "--no-allow-unauthenticated" 486 | } 487 | flags = append(flags, authSetting) 488 | 489 | if options.Memory != "" { 490 | memorySetting := fmt.Sprintf("--memory=%s", options.Memory) 491 | flags = append(flags, memorySetting) 492 | } 493 | 494 | if options.CPU != "" { 495 | cpuSetting := fmt.Sprintf("--cpu=%s", options.CPU) 496 | flags = append(flags, cpuSetting) 497 | } 498 | 499 | if options.HTTP2 != nil { 500 | if *options.HTTP2 == false { 501 | flags = append(flags, "--no-use-http2") 502 | } else { 503 | flags = append(flags, "--use-http2") 504 | } 505 | } 506 | 507 | if options.Concurrency > 0 { 508 | concurrencySetting := fmt.Sprintf("--concurrency=%d", options.Concurrency) 509 | flags = append(flags, concurrencySetting) 510 | } 511 | 512 | if options.MaxInstances > 0 { 513 | maxInstancesSetting := fmt.Sprintf("--max-instances=%d", options.MaxInstances) 514 | flags = append(flags, maxInstancesSetting) 515 | } 516 | 517 | return flags 518 | } 519 | 520 | // waitCredsAvailable polls until Cloud Shell VM has available credentials. 521 | // Credentials might be missing in the environment for some GSuite users that 522 | // need to authenticate every N hours. See internal bug 154573156 for details. 523 | func waitCredsAvailable(ctx context.Context, pollInterval time.Duration) error { 524 | if os.Getenv("SKIP_GCE_CHECK") == "" && !metadata.OnGCE() { 525 | return nil 526 | } 527 | 528 | for { 529 | select { 530 | case <-ctx.Done(): 531 | err := ctx.Err() 532 | if err == context.DeadlineExceeded { 533 | return errors.New("credentials were not available in the VM, try re-authenticating if Cloud Shell presents an authentication prompt and click the button again") 534 | } 535 | return err 536 | default: 537 | v, err := metadata.Get("instance/service-accounts/") 538 | if err != nil { 539 | return fmt.Errorf("failed to query metadata service to see if credentials are present: %w", err) 540 | } 541 | if strings.TrimSpace(v) != "" { 542 | return nil 543 | } 544 | time.Sleep(pollInterval) 545 | } 546 | } 547 | } 548 | 549 | func waitForBilling(projectID string, prompt func(string) error) error { 550 | for { 551 | ok, err := checkBillingEnabled(projectID) 552 | if err != nil { 553 | return err 554 | } 555 | if ok { 556 | return nil 557 | } 558 | if err := prompt(projectID); err != nil { 559 | return err 560 | } 561 | } 562 | } 563 | 564 | // hasSubDirsInPATH determines if anything in PATH is a sub-directory of dir. 565 | func hasSubDirsInPATH(dir string) (bool, error) { 566 | path := os.Getenv("PATH") 567 | if path == "" { 568 | return false, errors.New("PATH is empty") 569 | } 570 | 571 | paths := strings.Split(path, string(os.PathListSeparator)) 572 | for _, p := range paths { 573 | ok, err := isSubPath(dir, p) 574 | if err != nil { 575 | return false, fmt.Errorf("failure assessing if paths are the same: %v", err) 576 | } 577 | if ok { 578 | return true, nil 579 | } 580 | } 581 | return false, nil 582 | } 583 | 584 | // isSubPath determines b is under a. Both paths are evaluated by computing their abs paths. 585 | func isSubPath(a, b string) (bool, error) { 586 | a, err := filepath.Abs(a) 587 | if err != nil { 588 | return false, fmt.Errorf("failed to get absolute path for %s: %+v", a, err) 589 | } 590 | b, err = filepath.Abs(b) 591 | if err != nil { 592 | return false, fmt.Errorf("failed to get absolute path for %s: %+v", b, err) 593 | } 594 | v, err := filepath.Rel(a, b) 595 | if err != nil { 596 | return false, fmt.Errorf("failed to calculate relative path: %v", err) 597 | } 598 | return !strings.HasPrefix(v, ".."+string(os.PathSeparator)), nil 599 | } 600 | 601 | func instrumentlessCoupon() (*instrumentless.Coupon, error) { 602 | ctx := context.TODO() 603 | 604 | creds, err := transport.Creds(ctx) 605 | if err != nil { 606 | return nil, fmt.Errorf("could not get user credentials: %v", err) 607 | } 608 | 609 | token, err := creds.TokenSource.Token() 610 | if err != nil { 611 | return nil, fmt.Errorf("could not get an auth token: %v", err) 612 | } 613 | 614 | return instrumentless.GetCoupon(instrumentlessEvent, token.AccessToken) 615 | } 616 | 617 | func promptInstrumentless() error { 618 | coupon, err := instrumentlessCoupon() 619 | 620 | if err != nil || coupon == nil { 621 | fmt.Println(infoPrefix + " Create a new billing account:") 622 | fmt.Println(" " + linkLabel.Sprint(billingCreateURL)) 623 | fmt.Println(questionPrefix + " " + "Once you're done, press " + parameterLabel.Sprint("Enter") + " to continue: ") 624 | 625 | if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil { 626 | return err 627 | } 628 | 629 | return nil 630 | } 631 | 632 | code := "" 633 | parts := strings.Split(coupon.URL, "code=") 634 | if len(parts) == 2 { 635 | code = parts[1] 636 | } else { 637 | return fmt.Errorf("could not get a coupon code") 638 | } 639 | 640 | fmt.Println(infoPrefix + " Open this page:\n " + linkLabel.Sprint(trygcpURL)) 641 | 642 | fmt.Println(infoPrefix + " Use this coupon code:\n " + code) 643 | 644 | fmt.Println(questionPrefix + " Once you're done, press " + parameterLabel.Sprint("Enter") + " to continue: ") 645 | 646 | if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil { 647 | return err 648 | } 649 | 650 | return nil 651 | } 652 | 653 | func prompUseExistingBillingAccount(project string) (bool, error) { 654 | useExisting := false 655 | 656 | projectLabel := color.New(color.Bold, color.FgHiCyan).Sprint(project) 657 | 658 | if err := survey.AskOne(&survey.Confirm{ 659 | Default: false, 660 | Message: fmt.Sprintf("Would you like to use an existing billing account with project %s?", projectLabel), 661 | }, &useExisting, surveyIconOpts); err != nil { 662 | return false, fmt.Errorf("could not prompt for confirmation %+v", err) 663 | } 664 | return useExisting, nil 665 | } 666 | --------------------------------------------------------------------------------