Skip to content

Commit cb47b2e

Browse files
glasntleahecole
andauthored
new tutorial: deployment previews (GoogleCloudPlatform#4979)
* Move Deployment Previews into Python Docs Samples moves https://github.com/GoogleCloudPlatform/cloud-run-deployment-previews into the python-docs-samples, where similar tutorial code lives. * Update docs * blacken * ensure licences on all files * correct region tags * missing region tag * nox -s lint * region tags cannot have hyphens * Add typehinting * fix import order * code review updates * Add title to configuration README * improve inline documentation * Pin upper bound * Add comments to Dockerfile Co-authored-by: Leah E. Cole <6719667+leahecole@users.noreply.github.com>
1 parent aaad55c commit cb47b2e

11 files changed

Lines changed: 775 additions & 0 deletions

File tree

run/deployment-previews/Dockerfile

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright 2020 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+
# http://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+
# [START cloudrun_deployment_preview_dockerfile]
16+
# Use the Cloud SDK docker container, which includes Python 3.7
17+
# https://github.com/GoogleCloudPlatform/cloud-sdk-docker
18+
FROM gcr.io/google.com/cloudsdktool/cloud-sdk:slim
19+
20+
# Copy local code into the container image
21+
ENV APP_HOME /app
22+
WORKDIR $APP_HOME
23+
COPY . .
24+
25+
# Install dependencies
26+
RUN pip3 install --upgrade pip
27+
RUN pip3 install -r requirements.txt
28+
29+
# Run the CLI
30+
CMD exec python3 app/check-status.py
31+
# [END cloudrun_deployment_preview_dockerfile]

run/deployment-previews/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Cloud Run Deployment Previews
2+
3+
This code creates a [Cloud Builder](https://cloud.google.com/cloud-build/docs/cloud-builders)
4+
to implement deployment previews in Cloud Run. It is designed to work with the Cloud Build
5+
configurations in `cloudbuild-configurations/`.
6+
7+
Use it with the [deployment previews tutorial](https://cloud.google.com/run/tutorials/configure-deployment-previews).
8+
9+
## Build
10+
11+
```
12+
docker build --tag deployment-previews:python .
13+
```
14+
15+
## Run Locally
16+
17+
```
18+
docker run --rm deployment-previews
19+
```
20+
21+
## Test
22+
23+
```
24+
pytest
25+
```
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
#!/usr/bin/python
2+
#
3+
# Copyright 2020 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import re
18+
import subprocess
19+
import sys
20+
from typing import Callable, List
21+
22+
import click
23+
import github
24+
from github.GithubException import GithubException
25+
from google.api_core.exceptions import NotFound
26+
from google.cloud import secretmanager
27+
from googleapiclient import discovery
28+
from googleapiclient.errors import HttpError
29+
30+
# cloud run tags much be lowercase
31+
TAG_PREFIX = "pr-"
32+
33+
34+
def make_tag(pr: str) -> str:
35+
return f"{TAG_PREFIX}{pr}"
36+
37+
38+
def get_pr(tag: str) -> int:
39+
return int(tag.replace(TAG_PREFIX, ""))
40+
41+
42+
_default_options = [
43+
click.option(
44+
"--dry-run",
45+
help="Dry-run mode. No tag changes made",
46+
default=False,
47+
is_flag=True,
48+
),
49+
]
50+
51+
_cloudrun_options = [
52+
click.option("--project-id", required=True, help="Google Cloud Project ID"),
53+
click.option(
54+
"--region", required=True, help="Google Cloud Region", default="us-central1"
55+
),
56+
click.option("--service", required=True, help="Google Cloud Run service name"),
57+
]
58+
59+
_github_options = [
60+
click.option(
61+
"--repo-name", required=True, help="GitHub repo name (user/repo, or org/repo)"
62+
),
63+
click.option(
64+
"--ghtoken-secretname",
65+
default="github_token",
66+
help="Google Secret Manager secret name",
67+
),
68+
]
69+
70+
71+
def add_options(options: List[dict]) -> Callable:
72+
def _add_options(func: Callable) -> Callable:
73+
for option in reversed(options):
74+
func = option(func)
75+
return func
76+
77+
return _add_options
78+
79+
80+
def error(msg: str, context: str = None) -> None:
81+
click.secho(f"Error {context}: ", fg="red", bold=True, nl=False)
82+
click.echo(msg)
83+
sys.exit(1)
84+
85+
86+
def get_service(project_id: str, region: str, service_name: str) -> dict:
87+
"""Get the Cloud Run service object"""
88+
api = discovery.build("run", "v1")
89+
fqname = f"projects/{project_id}/locations/{region}/services/{service_name}"
90+
try:
91+
service = api.projects().locations().services().get(name=fqname).execute()
92+
except HttpError as e:
93+
error(re.search('"(.*)"', str(e)).group(0), context="finding service")
94+
return service
95+
96+
97+
def get_revision_url(service_obj: dict, tag: str) -> str:
98+
"""Get the revision URL for the tag specified on the service"""
99+
for revision in service_obj["status"]["traffic"]:
100+
if revision.get("tag", None) == tag:
101+
return revision["url"]
102+
103+
error(
104+
f"Tag on service {service_obj['metadata']['name']} does not exist.",
105+
context=f"finding revision tagged {tag}",
106+
)
107+
108+
109+
def get_revision_tags(service: dict) -> List[str]:
110+
"""
111+
Get all tags associated to a service
112+
"""
113+
revs = []
114+
115+
for revision in service["status"]["traffic"]:
116+
if revision.get("tag", None):
117+
revs.append(revision)
118+
return revs
119+
120+
121+
def github_token(project_id: str, ghtoken_secretname: str) -> str:
122+
"""Retrieve GitHub developer token from Secret Manager"""
123+
client = secretmanager.SecretManagerServiceClient()
124+
name = f"projects/{project_id}/secrets/{ghtoken_secretname}/versions/latest"
125+
try:
126+
response = client.access_secret_version(name=name)
127+
except NotFound as e:
128+
error(e, context=f"finding secret {ghtoken_secretname}")
129+
130+
# The secret was encoded for you as part of the secret creation, so decode it now.
131+
github_token = response.payload.data.decode("UTF-8")
132+
return github_token
133+
134+
135+
@click.group()
136+
def cli() -> None:
137+
"""
138+
Tool for setting GitHub Status Checks to Cloud Run Revision URLs
139+
"""
140+
pass
141+
142+
143+
@cli.command()
144+
@add_options(_default_options)
145+
@add_options(_cloudrun_options)
146+
@add_options(_github_options)
147+
def cleanup(dry_run: str, project_id: str, region: str, service: str, repo_name: str, ghtoken_secretname: str) -> None:
148+
"""
149+
Cleanup any revision URLs against closed pull requests
150+
"""
151+
service_obj = get_service(project_id, region, service)
152+
revs = get_revision_tags(service_obj)
153+
154+
if not revs:
155+
click.echo("No revision tags found, nothing to clean up")
156+
sys.exit(0)
157+
158+
ghtoken = github_token(project_id, ghtoken_secretname)
159+
160+
try:
161+
repo = github.Github(ghtoken).get_repo(repo_name)
162+
except GithubException as e:
163+
error(e.data["message"], context=f"finding repo {repo_name}")
164+
165+
tags_to_delete = []
166+
167+
for rev in revs:
168+
tag = rev["tag"]
169+
pr = get_pr(tag)
170+
pull_request = repo.get_pull(pr)
171+
if pull_request.state == "closed":
172+
if dry_run:
173+
click.secho("Dry-run: ", fg="blue", bold=True, nl=False)
174+
click.echo(
175+
f"PR {pr} is closed, so would remove tag {tag} on service {service}"
176+
)
177+
else:
178+
tags_to_delete.append(tag)
179+
180+
if tags_to_delete:
181+
tags = ",".join(tags_to_delete)
182+
183+
# Fork out to the gcloud CLI to programatically delete tags from closed PRs
184+
click.echo(f"Forking out to gcloud to remove tags: {tags}")
185+
subprocess.run(
186+
[
187+
"gcloud",
188+
"beta",
189+
"run",
190+
"services",
191+
"update-traffic",
192+
service,
193+
"--platform",
194+
"managed",
195+
"--region",
196+
region,
197+
"--project",
198+
project_id,
199+
"--remove-tags",
200+
tags,
201+
],
202+
check=True,
203+
)
204+
205+
else:
206+
click.echo("Did not identify any tags to delete.")
207+
208+
209+
@cli.command()
210+
@add_options(_default_options)
211+
@add_options(_cloudrun_options)
212+
@add_options(_github_options)
213+
@click.option("--pull-request", required=True, help="GitHub Pull Request ID", type=int)
214+
@click.option("--commit-sha", required=True, help="GitHub commit (SHORT_SHA)")
215+
# [START cloudrun_deployment_preview_setstatus]
216+
def set(
217+
dry_run: str,
218+
project_id: str,
219+
region: str,
220+
service: str,
221+
repo_name: str,
222+
ghtoken_secretname: str,
223+
commit_sha: str,
224+
pull_request: str,
225+
) -> None:
226+
"""
227+
Set a status on a GitHub commit to a specific revision URL
228+
"""
229+
service_obj = get_service(project_id, region, service)
230+
revision_url = get_revision_url(service_obj, tag=make_tag(pull_request))
231+
232+
ghtoken = github_token(project_id, ghtoken_secretname)
233+
234+
try:
235+
repo = github.Github(ghtoken).get_repo(repo_name)
236+
except GithubException as e:
237+
error(e.data["message"], context=f"finding repo {repo_name}")
238+
239+
try:
240+
commit = repo.get_commit(sha=commit_sha)
241+
except GithubException as e:
242+
error(e.data["message"], context=f"finding commit {commit_sha}")
243+
244+
if dry_run:
245+
click.secho("Dry-run: ", fg="blue", bold=True, nl=False)
246+
click.echo(
247+
(
248+
f"Status would have been created on {repo.repo_name}, "
249+
f"commit {commit.sha[:7]}, linking to {revision_url} "
250+
f"on service {service_obj['metadata']['name']}"
251+
)
252+
)
253+
254+
else:
255+
commit.create_status(
256+
state="success",
257+
target_url=revision_url,
258+
context="Deployment Preview",
259+
description="Your preview is now available.",
260+
)
261+
click.secho("Success: ", fg="green", bold=True, nl=False)
262+
click.echo(
263+
f"Status created on {repo.repo_name}, commit {commit.sha[:7]}, "
264+
f"linking to {revision_url} on service {service_obj['metadata']['name']}"
265+
)
266+
267+
268+
# [END cloudrun_deployment_preview_setstatus]
269+
270+
if __name__ == "__main__":
271+
cli()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Deployment Preview Cloud Build Configuration
2+
3+
These configuations aren't used by this repo itself, but are configurations required to use the code in this repo.
4+
5+
* [cloudbuild.yaml](cloudbuild.yaml) - for main branch pushes
6+
* [cloudbuild-preview.yaml](cloudbuild-preview.yaml) - for GitHub Pull Requests
7+
* [cloudbuild-cleanup.yaml](cloudbuild-cleanup.yaml) - for main branch pushes, including cleanup of old tags

0 commit comments

Comments
 (0)