Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a0e37c5
Move Deployment Previews into Python Docs Samples
glasnt Nov 16, 2020
d4889a2
Update docs
glasnt Nov 16, 2020
93bdc29
blacken
glasnt Nov 16, 2020
9c4a412
ensure licences on all files
glasnt Nov 16, 2020
da18388
correct region tags
glasnt Nov 16, 2020
3435bd2
missing region tag
glasnt Nov 16, 2020
3314a0c
nox -s lint
glasnt Nov 16, 2020
67c6fdc
region tags cannot have hyphens
glasnt Nov 16, 2020
73bec6d
Merge branch 'master' into deployment-previews
glasnt Nov 17, 2020
e9c5fcc
Add typehinting
glasnt Nov 18, 2020
05f2cce
Merge branch 'deployment-previews' of github.com:glasnt/python-docs-s…
glasnt Nov 18, 2020
c54b590
fix import order
glasnt Nov 18, 2020
45c8f92
Merge branch 'master' into deployment-previews
glasnt Nov 23, 2020
ee48f03
code review updates
glasnt Nov 24, 2020
f53a707
Merge branch 'deployment-previews' of github.com:glasnt/python-docs-s…
glasnt Nov 24, 2020
fa08dcc
Add title to configuration README
glasnt Nov 24, 2020
0b362dd
Merge branch 'master' into deployment-previews
glasnt Nov 24, 2020
820fb55
Merge branch 'deployment-previews' of github.com:glasnt/python-docs-s…
glasnt Nov 24, 2020
33e15fe
improve inline documentation
glasnt Nov 24, 2020
393d172
Merge branch 'master' into deployment-previews
glasnt Nov 24, 2020
58ebecb
Pin upper bound
glasnt Nov 30, 2020
127156d
Merge branch 'deployment-previews' of github.com:glasnt/python-docs-s…
glasnt Nov 30, 2020
e786b62
Merge branch 'master' into deployment-previews
glasnt Dec 3, 2020
dd290e7
Add comments to Dockerfile
glasnt Dec 4, 2020
50acd2c
Merge branch 'master' into deployment-previews
glasnt Dec 4, 2020
1cdd334
Merge branch 'master' into deployment-previews
glasnt Dec 6, 2020
5782d5d
Merge branch 'master' into deployment-previews
glasnt Dec 7, 2020
4276014
Merge branch 'master' into deployment-previews
glasnt Dec 7, 2020
7d355c4
Merge branch 'master' into deployment-previews
leahecole Dec 7, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions run/deployment-previews/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# [START cloudrun_deployment_preview_dockerfile]
# Use the Cloud SDK docker container, which includes Python 3.7
# https://github.com/GoogleCloudPlatform/cloud-sdk-docker
FROM gcr.io/google.com/cloudsdktool/cloud-sdk:slim
Comment thread
glasnt marked this conversation as resolved.

# Copy local code into the container image
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . .

# Install dependencies
RUN pip3 install --upgrade pip
RUN pip3 install -r requirements.txt

# Run the CLI
CMD exec python3 app/check-status.py
# [END cloudrun_deployment_preview_dockerfile]
25 changes: 25 additions & 0 deletions run/deployment-previews/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Cloud Run Deployment Previews

This code creates a [Cloud Builder](https://cloud.google.com/cloud-build/docs/cloud-builders)
to implement deployment previews in Cloud Run. It is designed to work with the Cloud Build
configurations in `cloudbuild-configurations/`.

Use it with the [deployment previews tutorial](https://cloud.google.com/run/tutorials/configure-deployment-previews).

## Build

```
docker build --tag deployment-previews:python .
```

## Run Locally

```
docker run --rm deployment-previews
```

## Test

```
pytest
```
271 changes: 271 additions & 0 deletions run/deployment-previews/check_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
#!/usr/bin/python
#
# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import re
import subprocess
import sys
from typing import Callable, List

import click
import github
from github.GithubException import GithubException
from google.api_core.exceptions import NotFound
from google.cloud import secretmanager
from googleapiclient import discovery
from googleapiclient.errors import HttpError

# cloud run tags much be lowercase
TAG_PREFIX = "pr-"
Comment thread
glasnt marked this conversation as resolved.


def make_tag(pr: str) -> str:
return f"{TAG_PREFIX}{pr}"


def get_pr(tag: str) -> int:
return int(tag.replace(TAG_PREFIX, ""))


_default_options = [
click.option(
"--dry-run",
help="Dry-run mode. No tag changes made",
default=False,
is_flag=True,
),
]

_cloudrun_options = [
click.option("--project-id", required=True, help="Google Cloud Project ID"),
click.option(
"--region", required=True, help="Google Cloud Region", default="us-central1"
),
click.option("--service", required=True, help="Google Cloud Run service name"),
]

_github_options = [
click.option(
"--repo-name", required=True, help="GitHub repo name (user/repo, or org/repo)"
),
click.option(
"--ghtoken-secretname",
default="github_token",
help="Google Secret Manager secret name",
),
]


def add_options(options: List[dict]) -> Callable:
def _add_options(func: Callable) -> Callable:
for option in reversed(options):
func = option(func)
return func

return _add_options


def error(msg: str, context: str = None) -> None:
click.secho(f"Error {context}: ", fg="red", bold=True, nl=False)
click.echo(msg)
sys.exit(1)


def get_service(project_id: str, region: str, service_name: str) -> dict:
"""Get the Cloud Run service object"""
api = discovery.build("run", "v1")
fqname = f"projects/{project_id}/locations/{region}/services/{service_name}"
try:
service = api.projects().locations().services().get(name=fqname).execute()
except HttpError as e:
error(re.search('"(.*)"', str(e)).group(0), context="finding service")
return service


def get_revision_url(service_obj: dict, tag: str) -> str:
"""Get the revision URL for the tag specified on the service"""
for revision in service_obj["status"]["traffic"]:
if revision.get("tag", None) == tag:
return revision["url"]

error(
f"Tag on service {service_obj['metadata']['name']} does not exist.",
context=f"finding revision tagged {tag}",
)


def get_revision_tags(service: dict) -> List[str]:
"""
Get all tags associated to a service
"""
revs = []

for revision in service["status"]["traffic"]:
if revision.get("tag", None):
revs.append(revision)
return revs


def github_token(project_id: str, ghtoken_secretname: str) -> str:
"""Retrieve GitHub developer token from Secret Manager"""
client = secretmanager.SecretManagerServiceClient()
name = f"projects/{project_id}/secrets/{ghtoken_secretname}/versions/latest"
try:
response = client.access_secret_version(name=name)
except NotFound as e:
error(e, context=f"finding secret {ghtoken_secretname}")

# The secret was encoded for you as part of the secret creation, so decode it now.
github_token = response.payload.data.decode("UTF-8")
Comment thread
glasnt marked this conversation as resolved.
return github_token


@click.group()
def cli() -> None:
"""
Tool for setting GitHub Status Checks to Cloud Run Revision URLs
"""
pass


@cli.command()
@add_options(_default_options)
@add_options(_cloudrun_options)
@add_options(_github_options)
def cleanup(dry_run: str, project_id: str, region: str, service: str, repo_name: str, ghtoken_secretname: str) -> None:
"""
Cleanup any revision URLs against closed pull requests
"""
service_obj = get_service(project_id, region, service)
revs = get_revision_tags(service_obj)

if not revs:
click.echo("No revision tags found, nothing to clean up")
sys.exit(0)

ghtoken = github_token(project_id, ghtoken_secretname)

try:
repo = github.Github(ghtoken).get_repo(repo_name)
except GithubException as e:
error(e.data["message"], context=f"finding repo {repo_name}")

tags_to_delete = []

for rev in revs:
tag = rev["tag"]
pr = get_pr(tag)
pull_request = repo.get_pull(pr)
if pull_request.state == "closed":
if dry_run:
click.secho("Dry-run: ", fg="blue", bold=True, nl=False)
click.echo(
f"PR {pr} is closed, so would remove tag {tag} on service {service}"
)
else:
tags_to_delete.append(tag)

if tags_to_delete:
tags = ",".join(tags_to_delete)

# Fork out to the gcloud CLI to programatically delete tags from closed PRs
click.echo(f"Forking out to gcloud to remove tags: {tags}")
subprocess.run(
[
"gcloud",
"beta",
"run",
"services",
"update-traffic",
service,
"--platform",
"managed",
"--region",
region,
"--project",
project_id,
"--remove-tags",
tags,
],
check=True,
)

else:
click.echo("Did not identify any tags to delete.")


@cli.command()
@add_options(_default_options)
@add_options(_cloudrun_options)
@add_options(_github_options)
@click.option("--pull-request", required=True, help="GitHub Pull Request ID", type=int)
@click.option("--commit-sha", required=True, help="GitHub commit (SHORT_SHA)")
# [START cloudrun_deployment_preview_setstatus]
def set(
dry_run: str,
project_id: str,
region: str,
service: str,
repo_name: str,
ghtoken_secretname: str,
commit_sha: str,
pull_request: str,
) -> None:
"""
Set a status on a GitHub commit to a specific revision URL
"""
service_obj = get_service(project_id, region, service)
revision_url = get_revision_url(service_obj, tag=make_tag(pull_request))

ghtoken = github_token(project_id, ghtoken_secretname)

try:
repo = github.Github(ghtoken).get_repo(repo_name)
except GithubException as e:
error(e.data["message"], context=f"finding repo {repo_name}")

try:
commit = repo.get_commit(sha=commit_sha)
except GithubException as e:
error(e.data["message"], context=f"finding commit {commit_sha}")

if dry_run:
click.secho("Dry-run: ", fg="blue", bold=True, nl=False)
click.echo(
(
f"Status would have been created on {repo.repo_name}, "
f"commit {commit.sha[:7]}, linking to {revision_url} "
f"on service {service_obj['metadata']['name']}"
)
)

else:
commit.create_status(
state="success",
target_url=revision_url,
context="Deployment Preview",
description="Your preview is now available.",
)
click.secho("Success: ", fg="green", bold=True, nl=False)
click.echo(
f"Status created on {repo.repo_name}, commit {commit.sha[:7]}, "
f"linking to {revision_url} on service {service_obj['metadata']['name']}"
)


# [END cloudrun_deployment_preview_setstatus]

if __name__ == "__main__":
cli()
7 changes: 7 additions & 0 deletions run/deployment-previews/cloudbuild-configurations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Deployment Preview Cloud Build Configuration

These configuations aren't used by this repo itself, but are configurations required to use the code in this repo.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add title

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which title, sorry?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a title to the ReadMe maybe # Deployment Preview Cloud Build Configuration. (we use "nit" to describe likes but not must haves)


* [cloudbuild.yaml](cloudbuild.yaml) - for main branch pushes
* [cloudbuild-preview.yaml](cloudbuild-preview.yaml) - for GitHub Pull Requests
* [cloudbuild-cleanup.yaml](cloudbuild-cleanup.yaml) - for main branch pushes, including cleanup of old tags
Loading