├── .github ├── renovate.json ├── snippet-bot.yml └── sync-repo-settings.yaml ├── .gitignore ├── .kokoro ├── common.cfg ├── docker │ └── Dockerfile ├── presubmit.cfg ├── system_tests.cfg ├── system_tests.sh ├── trampoline.sh └── trampoline_v2.sh ├── .trampolinerc ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── authenticating-users ├── app.yaml ├── main.py ├── main_test.py ├── requirements-test.txt └── requirements.txt ├── background ├── README.md ├── app │ ├── app.yaml │ ├── main.py │ ├── main_test.py │ ├── requirements.txt │ └── templates │ │ └── index.html └── function │ ├── main.py │ ├── main_test.py │ └── requirements.txt ├── bookshelf ├── Dockerfile ├── app.yaml ├── firestore.py ├── images │ └── moby-dick.png ├── main.py ├── main_test.py ├── requirements.txt ├── storage.py └── templates │ ├── base.html │ ├── form.html │ ├── list.html │ └── view.html ├── decrypt-secrets.sh ├── encrypt-secrets.sh ├── gce ├── README.md ├── add-google-cloud-ops-agent-repo.sh ├── deploy.sh ├── main.py ├── main_test.py ├── procfile ├── python-app.conf ├── requirements.txt ├── startup-script.sh └── teardown.sh ├── noxfile.py ├── optional-kubernetes-engine ├── .dockerignore ├── Dockerfile ├── Makefile ├── README.md ├── bookshelf-frontend.yaml ├── bookshelf-service.yaml ├── bookshelf-worker.yaml ├── bookshelf │ ├── __init__.py │ ├── crud.py │ ├── model_cloudsql.py │ ├── model_datastore.py │ ├── model_mongodb.py │ ├── storage.py │ ├── tasks.py │ └── templates │ │ ├── base.html │ │ ├── form.html │ │ ├── list.html │ │ └── view.html ├── config.py ├── main.py ├── procfile ├── requirements-dev.txt ├── requirements.txt ├── tests │ ├── conftest.py │ ├── test_auth.py │ ├── test_crud.py │ ├── test_end_to_end.py │ └── test_storage.py └── tox.ini ├── pytest.ini ├── requirements.txt ├── secrets.tar.enc └── sessions ├── app.yaml ├── main.py ├── main_test.py ├── requirements-dev.txt └── requirements.txt /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:all", 5 | ":preserveSemverRanges", 6 | ":disableDependencyDashboard" 7 | ], 8 | "ignorePaths": [ 9 | "optional-kubernetes-engine" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.github/snippet-bot.yml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/sync-repo-settings.yaml: -------------------------------------------------------------------------------- 1 | # Whether or not rebase-merging is enabled on this repository. 2 | # Defaults to `true` 3 | rebaseMergeAllowed: true 4 | 5 | # Whether or not squash-merging is enabled on this repository. 6 | # Defaults to `true` 7 | squashMergeAllowed: true 8 | 9 | # Whether or not PRs are merged with a merge commit on this repository. 10 | # Defaults to `false` 11 | mergeCommitAllowed: false 12 | 13 | # Rules for main branch protection 14 | branchProtectionRules: 15 | # Identifies the protection rule pattern. Name of the branch to be protected. 16 | # Defaults to `main` 17 | - pattern: main 18 | # Can admins overwrite branch protection. 19 | # Defaults to `true` 20 | isAdminEnforced: false 21 | # Number of approving reviews required to update matching branches. 22 | # Defaults to `1` 23 | requiredApprovingReviewCount: 1 24 | # Are reviews from code owners required to update matching branches. 25 | # Defaults to `false` 26 | requiresCodeOwnerReviews: true 27 | # Require up to date branches 28 | requiresStrictStatusChecks: true 29 | # List of required status check contexts that must pass for commits to be accepted to matching branches. 30 | requiredStatusCheckContexts: 31 | - "kokoro" 32 | - "cla/google" 33 | # List of explicit permissions to add (additive only) 34 | permissionRules: 35 | # Team slug to add to repository permissions 36 | - team: yoshi-admins 37 | # Access level required, one of push|pull|admin 38 | permission: admin 39 | - team: python-samples-reviewers 40 | permission: admin 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | env/ 9 | 10 | # Installer logs 11 | pip-log.txt 12 | pip-delete-this-directory.txt 13 | 14 | # Unit test / coverage reports 15 | htmlcov/ 16 | .tox/ 17 | .nox/ 18 | .coverage 19 | .coverage.* 20 | .cache 21 | nosetests.xml 22 | coverage.xml 23 | *_log.xml 24 | *,cover 25 | sponge_log.xml 26 | 27 | *.log 28 | 29 | lib/ 30 | *.internal 31 | /secrets.tar.gz 32 | /secrets.tar 33 | /client-secret.json 34 | /service-account.json 35 | /config.py 36 | -------------------------------------------------------------------------------- /.kokoro/common.cfg: -------------------------------------------------------------------------------- 1 | # Format: //devtools/kokoro/config/proto/build.proto 2 | 3 | # Download trampoline resources. These will be in ${KOKORO_GFILE_DIR} 4 | gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" 5 | 6 | # Download secrets from Cloud Storage. 7 | gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/getting-started-python" 8 | 9 | # All builds use the trampoline script to run in docker. 10 | build_file: "getting-started-python/.kokoro/trampoline_v2.sh" 11 | 12 | # Use the Python worker docker iamge. 13 | env_vars: { 14 | key: "TRAMPOLINE_IMAGE" 15 | value: "gcr.io/cloud-devrel-kokoro-resources/python/getting-started-python" 16 | } 17 | 18 | # Tell the trampoline which build file to use. 19 | env_vars: { 20 | key: "TRAMPOLINE_BUILD_FILE" 21 | value: ".kokoro/system_tests.sh" 22 | } 23 | 24 | # Upload the docker image after successful builds. 25 | env_vars: { 26 | key: "TRAMPOLINE_IMAGE_UPLOAD" 27 | value: "true" 28 | } 29 | -------------------------------------------------------------------------------- /.kokoro/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2018 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 | FROM gcr.io/cloud-devrel-kokoro-resources/python-base:latest 16 | 17 | # Install libraries needed by third-party python packages that we depend on. 18 | RUN apt-get update \ 19 | && apt-get install -y \ 20 | graphviz \ 21 | libcurl4-openssl-dev \ 22 | libffi-dev \ 23 | libjpeg-dev \ 24 | libmagickwand-dev \ 25 | libmemcached-dev \ 26 | libmysqlclient-dev \ 27 | libpng-dev \ 28 | libpq-dev \ 29 | libssl-dev \ 30 | libxml2-dev \ 31 | libxslt1-dev \ 32 | openssl \ 33 | zlib1g-dev \ 34 | && apt-get clean 35 | 36 | 37 | ###################### Check python version 38 | 39 | RUN python3 --version 40 | RUN which python3 41 | 42 | # Setup Cloud SDK 43 | ENV CLOUD_SDK_VERSION 489.0.0 44 | # Use system python for cloud sdk. 45 | ENV CLOUDSDK_PYTHON python3.12 46 | RUN wget https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-$CLOUD_SDK_VERSION-linux-x86_64.tar.gz 47 | RUN tar xzf google-cloud-sdk-$CLOUD_SDK_VERSION-linux-x86_64.tar.gz 48 | RUN /google-cloud-sdk/install.sh 49 | ENV PATH /google-cloud-sdk/bin:$PATH 50 | 51 | # Setup the user profile for pip 52 | ENV PATH ~/.local/bin:/root/.local/bin:$PATH 53 | 54 | # Install the current version of nox. 55 | RUN python3 -m pip install --user --no-cache-dir nox==2022.1.7 56 | 57 | CMD ["nox"] 58 | -------------------------------------------------------------------------------- /.kokoro/presubmit.cfg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/getting-started-python/d9da8db203f7729f5da28b57be66b69084955bde/.kokoro/presubmit.cfg -------------------------------------------------------------------------------- /.kokoro/system_tests.cfg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/getting-started-python/d9da8db203f7729f5da28b57be66b69084955bde/.kokoro/system_tests.cfg -------------------------------------------------------------------------------- /.kokoro/system_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2017 Google Inc. 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 | set -eo pipefail 18 | 19 | export PATH=${PATH}:${HOME}/gcloud/google-cloud-sdk/bin 20 | 21 | cd "${PROJECT_ROOT:-github/getting-started-python}" 22 | 23 | 24 | # Unencrypt and extract secrets 25 | SECRETS_PASSWORD=$(cat "${KOKORO_GFILE_DIR}/secrets-password.txt") 26 | ./decrypt-secrets.sh "${SECRETS_PASSWORD}" 27 | 28 | # Setup environment variables 29 | export GOOGLE_APPLICATION_CREDENTIALS="$(pwd)/service-account.json" 30 | 31 | # This block is executed only with Trampoline V2. 32 | if [[ -n "${TRAMPOLINE_VERSION:-}" ]]; then 33 | # Install nox as a user and add it to the PATH. 34 | python3 -m pip install --user nox 35 | export PATH="${PATH}:${HOME}/.local/bin" 36 | fi 37 | 38 | # Run tests 39 | nox -s lint 40 | nox -s run_tests 41 | 42 | # If this is a nightly build, send the test log to the Flaky Bot. 43 | # See https://github.com/googleapis/repo-automation-bots/tree/HEAD/packages/flakybot. 44 | if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"system_tests"* ]]; then 45 | chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot 46 | $KOKORO_GFILE_DIR/linux_amd64/flakybot 47 | fi 48 | -------------------------------------------------------------------------------- /.kokoro/trampoline.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2017 Google Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -eo pipefail 17 | 18 | # Always run the cleanup script, regardless of the success of bouncing into 19 | # the container. 20 | 21 | function cleanup() { 22 | chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh 23 | ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh 24 | echo "cleanup"; 25 | } 26 | trap cleanup EXIT 27 | 28 | python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" 29 | -------------------------------------------------------------------------------- /.kokoro/trampoline_v2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2020 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # trampoline_v2.sh 17 | # 18 | # If you want to make a change to this file, consider doing so at: 19 | # https://github.com/googlecloudplatform/docker-ci-helper 20 | # 21 | # This script is for running CI builds. For Kokoro builds, we 22 | # set this script to `build_file` field in the Kokoro configuration. 23 | 24 | # This script does 3 things. 25 | # 26 | # 1. Prepare the Docker image for the test 27 | # 2. Run the Docker with appropriate flags to run the test 28 | # 3. Upload the newly built Docker image 29 | # 30 | # in a way that is somewhat compatible with trampoline_v1. 31 | # 32 | # These environment variables are required: 33 | # TRAMPOLINE_IMAGE: The docker image to use. 34 | # TRAMPOLINE_DOCKERFILE: The location of the Dockerfile. 35 | # 36 | # You can optionally change these environment variables: 37 | # TRAMPOLINE_IMAGE_UPLOAD: 38 | # (true|false): Whether to upload the Docker image after the 39 | # successful builds. 40 | # TRAMPOLINE_BUILD_FILE: The script to run in the docker container. 41 | # TRAMPOLINE_WORKSPACE: The workspace path in the docker container. 42 | # Defaults to /workspace. 43 | # Potentially there are some repo specific envvars in .trampolinerc in 44 | # the project root. 45 | # 46 | # Here is an example for running this script. 47 | # TRAMPOLINE_IMAGE=gcr.io/cloud-devrel-kokoro-resources/node:10-user \ 48 | # TRAMPOLINE_BUILD_FILE=.kokoro/system-test.sh \ 49 | # .kokoro/trampoline_v2.sh 50 | 51 | set -euo pipefail 52 | 53 | TRAMPOLINE_VERSION="2.0.10" 54 | 55 | if command -v tput >/dev/null && [[ -n "${TERM:-}" ]]; then 56 | readonly IO_COLOR_RED="$(tput setaf 1)" 57 | readonly IO_COLOR_GREEN="$(tput setaf 2)" 58 | readonly IO_COLOR_YELLOW="$(tput setaf 3)" 59 | readonly IO_COLOR_RESET="$(tput sgr0)" 60 | else 61 | readonly IO_COLOR_RED="" 62 | readonly IO_COLOR_GREEN="" 63 | readonly IO_COLOR_YELLOW="" 64 | readonly IO_COLOR_RESET="" 65 | fi 66 | 67 | function function_exists { 68 | [ $(LC_ALL=C type -t $1)"" == "function" ] 69 | } 70 | 71 | # Logs a message using the given color. The first argument must be one 72 | # of the IO_COLOR_* variables defined above, such as 73 | # "${IO_COLOR_YELLOW}". The remaining arguments will be logged in the 74 | # given color. The log message will also have an RFC-3339 timestamp 75 | # prepended (in UTC). You can disable the color output by setting 76 | # TERM=vt100. 77 | function log_impl() { 78 | local color="$1" 79 | shift 80 | local timestamp="$(date -u "+%Y-%m-%dT%H:%M:%SZ")" 81 | echo "================================================================" 82 | echo "${color}${timestamp}:" "$@" "${IO_COLOR_RESET}" 83 | echo "================================================================" 84 | } 85 | 86 | # Logs the given message with normal coloring and a timestamp. 87 | function log() { 88 | log_impl "${IO_COLOR_RESET}" "$@" 89 | } 90 | 91 | # Logs the given message in green with a timestamp. 92 | function log_green() { 93 | log_impl "${IO_COLOR_GREEN}" "$@" 94 | } 95 | 96 | # Logs the given message in yellow with a timestamp. 97 | function log_yellow() { 98 | log_impl "${IO_COLOR_YELLOW}" "$@" 99 | } 100 | 101 | # Logs the given message in red with a timestamp. 102 | function log_red() { 103 | log_impl "${IO_COLOR_RED}" "$@" 104 | } 105 | 106 | readonly tmpdir=$(mktemp -d -t ci-XXXXXXXX) 107 | readonly tmphome="${tmpdir}/h" 108 | mkdir -p "${tmphome}" 109 | 110 | function cleanup() { 111 | rm -rf "${tmpdir}" 112 | } 113 | trap cleanup EXIT 114 | 115 | RUNNING_IN_CI="${RUNNING_IN_CI:-false}" 116 | 117 | # The workspace in the container, defaults to /workspace. 118 | TRAMPOLINE_WORKSPACE="${TRAMPOLINE_WORKSPACE:-/workspace}" 119 | 120 | pass_down_envvars=( 121 | # TRAMPOLINE_V2 variables. 122 | # Tells scripts whether they are running as part of CI or not. 123 | "RUNNING_IN_CI" 124 | # Indicates which CI system we're in. 125 | "TRAMPOLINE_CI" 126 | # Indicates the version of the script. 127 | "TRAMPOLINE_VERSION" 128 | ) 129 | 130 | log_yellow "Building with Trampoline ${TRAMPOLINE_VERSION}" 131 | 132 | # Detect which CI systems we're in. If we're in any of the CI systems 133 | # we support, `RUNNING_IN_CI` will be true and `TRAMPOLINE_CI` will be 134 | # the name of the CI system. Both envvars will be passing down to the 135 | # container for telling which CI system we're in. 136 | if [[ -n "${KOKORO_BUILD_ID:-}" ]]; then 137 | # descriptive env var for indicating it's on CI. 138 | RUNNING_IN_CI="true" 139 | TRAMPOLINE_CI="kokoro" 140 | if [[ "${TRAMPOLINE_USE_LEGACY_SERVICE_ACCOUNT:-}" == "true" ]]; then 141 | if [[ ! -f "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" ]]; then 142 | log_red "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json does not exist. Did you forget to mount cloud-devrel-kokoro-resources/trampoline? Aborting." 143 | exit 1 144 | fi 145 | # This service account will be activated later. 146 | TRAMPOLINE_SERVICE_ACCOUNT="${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" 147 | else 148 | if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then 149 | gcloud auth list 150 | fi 151 | log_yellow "Configuring Container Registry access" 152 | gcloud auth configure-docker --quiet 153 | fi 154 | pass_down_envvars+=( 155 | # KOKORO dynamic variables. 156 | "KOKORO_BUILD_NUMBER" 157 | "KOKORO_BUILD_ID" 158 | "KOKORO_JOB_NAME" 159 | "KOKORO_GIT_COMMIT" 160 | "KOKORO_GITHUB_COMMIT" 161 | "KOKORO_GITHUB_PULL_REQUEST_NUMBER" 162 | "KOKORO_GITHUB_PULL_REQUEST_COMMIT" 163 | # For Flaky Bot 164 | "KOKORO_GITHUB_COMMIT_URL" 165 | "KOKORO_GITHUB_PULL_REQUEST_URL" 166 | "KOKORO_BUILD_ARTIFACTS_SUBDIR" 167 | ) 168 | elif [[ "${TRAVIS:-}" == "true" ]]; then 169 | RUNNING_IN_CI="true" 170 | TRAMPOLINE_CI="travis" 171 | pass_down_envvars+=( 172 | "TRAVIS_BRANCH" 173 | "TRAVIS_BUILD_ID" 174 | "TRAVIS_BUILD_NUMBER" 175 | "TRAVIS_BUILD_WEB_URL" 176 | "TRAVIS_COMMIT" 177 | "TRAVIS_COMMIT_MESSAGE" 178 | "TRAVIS_COMMIT_RANGE" 179 | "TRAVIS_JOB_NAME" 180 | "TRAVIS_JOB_NUMBER" 181 | "TRAVIS_JOB_WEB_URL" 182 | "TRAVIS_PULL_REQUEST" 183 | "TRAVIS_PULL_REQUEST_BRANCH" 184 | "TRAVIS_PULL_REQUEST_SHA" 185 | "TRAVIS_PULL_REQUEST_SLUG" 186 | "TRAVIS_REPO_SLUG" 187 | "TRAVIS_SECURE_ENV_VARS" 188 | "TRAVIS_TAG" 189 | ) 190 | elif [[ -n "${GITHUB_RUN_ID:-}" ]]; then 191 | RUNNING_IN_CI="true" 192 | TRAMPOLINE_CI="github-workflow" 193 | pass_down_envvars+=( 194 | "GITHUB_WORKFLOW" 195 | "GITHUB_RUN_ID" 196 | "GITHUB_RUN_NUMBER" 197 | "GITHUB_ACTION" 198 | "GITHUB_ACTIONS" 199 | "GITHUB_ACTOR" 200 | "GITHUB_REPOSITORY" 201 | "GITHUB_EVENT_NAME" 202 | "GITHUB_EVENT_PATH" 203 | "GITHUB_SHA" 204 | "GITHUB_REF" 205 | "GITHUB_HEAD_REF" 206 | "GITHUB_BASE_REF" 207 | ) 208 | elif [[ "${CIRCLECI:-}" == "true" ]]; then 209 | RUNNING_IN_CI="true" 210 | TRAMPOLINE_CI="circleci" 211 | pass_down_envvars+=( 212 | "CIRCLE_BRANCH" 213 | "CIRCLE_BUILD_NUM" 214 | "CIRCLE_BUILD_URL" 215 | "CIRCLE_COMPARE_URL" 216 | "CIRCLE_JOB" 217 | "CIRCLE_NODE_INDEX" 218 | "CIRCLE_NODE_TOTAL" 219 | "CIRCLE_PREVIOUS_BUILD_NUM" 220 | "CIRCLE_PROJECT_REPONAME" 221 | "CIRCLE_PROJECT_USERNAME" 222 | "CIRCLE_REPOSITORY_URL" 223 | "CIRCLE_SHA1" 224 | "CIRCLE_STAGE" 225 | "CIRCLE_USERNAME" 226 | "CIRCLE_WORKFLOW_ID" 227 | "CIRCLE_WORKFLOW_JOB_ID" 228 | "CIRCLE_WORKFLOW_UPSTREAM_JOB_IDS" 229 | "CIRCLE_WORKFLOW_WORKSPACE_ID" 230 | ) 231 | fi 232 | 233 | # Configure the service account for pulling the docker image. 234 | function repo_root() { 235 | local dir="$1" 236 | while [[ ! -d "${dir}/.git" ]]; do 237 | dir="$(dirname "$dir")" 238 | done 239 | echo "${dir}" 240 | } 241 | 242 | # Detect the project root. In CI builds, we assume the script is in 243 | # the git tree and traverse from there, otherwise, traverse from `pwd` 244 | # to find `.git` directory. 245 | if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then 246 | PROGRAM_PATH="$(realpath "$0")" 247 | PROGRAM_DIR="$(dirname "${PROGRAM_PATH}")" 248 | PROJECT_ROOT="$(repo_root "${PROGRAM_DIR}")" 249 | else 250 | PROJECT_ROOT="$(repo_root $(pwd))" 251 | fi 252 | 253 | log_yellow "Changing to the project root: ${PROJECT_ROOT}." 254 | cd "${PROJECT_ROOT}" 255 | 256 | # To support relative path for `TRAMPOLINE_SERVICE_ACCOUNT`, we need 257 | # to use this environment variable in `PROJECT_ROOT`. 258 | if [[ -n "${TRAMPOLINE_SERVICE_ACCOUNT:-}" ]]; then 259 | 260 | mkdir -p "${tmpdir}/gcloud" 261 | gcloud_config_dir="${tmpdir}/gcloud" 262 | 263 | log_yellow "Using isolated gcloud config: ${gcloud_config_dir}." 264 | export CLOUDSDK_CONFIG="${gcloud_config_dir}" 265 | 266 | log_yellow "Using ${TRAMPOLINE_SERVICE_ACCOUNT} for authentication." 267 | gcloud auth activate-service-account \ 268 | --key-file "${TRAMPOLINE_SERVICE_ACCOUNT}" 269 | log_yellow "Configuring Container Registry access" 270 | gcloud auth configure-docker --quiet 271 | fi 272 | 273 | required_envvars=( 274 | # The basic trampoline configurations. 275 | "TRAMPOLINE_IMAGE" 276 | "TRAMPOLINE_BUILD_FILE" 277 | ) 278 | 279 | if [[ -f "${PROJECT_ROOT}/.trampolinerc" ]]; then 280 | source "${PROJECT_ROOT}/.trampolinerc" 281 | fi 282 | 283 | log_yellow "Checking environment variables." 284 | for e in "${required_envvars[@]}" 285 | do 286 | if [[ -z "${!e:-}" ]]; then 287 | log "Missing ${e} env var. Aborting." 288 | exit 1 289 | fi 290 | done 291 | 292 | # We want to support legacy style TRAMPOLINE_BUILD_FILE used with V1 293 | # script: e.g. "github/repo-name/.kokoro/run_tests.sh" 294 | TRAMPOLINE_BUILD_FILE="${TRAMPOLINE_BUILD_FILE#github/*/}" 295 | log_yellow "Using TRAMPOLINE_BUILD_FILE: ${TRAMPOLINE_BUILD_FILE}" 296 | 297 | # ignore error on docker operations and test execution 298 | set +e 299 | 300 | log_yellow "Preparing Docker image." 301 | # We only download the docker image in CI builds. 302 | if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then 303 | # Download the docker image specified by `TRAMPOLINE_IMAGE` 304 | 305 | # We may want to add --max-concurrent-downloads flag. 306 | 307 | log_yellow "Start pulling the Docker image: ${TRAMPOLINE_IMAGE}." 308 | if docker pull "${TRAMPOLINE_IMAGE}"; then 309 | log_green "Finished pulling the Docker image: ${TRAMPOLINE_IMAGE}." 310 | has_image="true" 311 | else 312 | log_red "Failed pulling the Docker image: ${TRAMPOLINE_IMAGE}." 313 | has_image="false" 314 | fi 315 | else 316 | # For local run, check if we have the image. 317 | if docker images "${TRAMPOLINE_IMAGE}" | grep "${TRAMPOLINE_IMAGE%:*}"; then 318 | has_image="true" 319 | else 320 | has_image="false" 321 | fi 322 | fi 323 | 324 | 325 | # The default user for a Docker container has uid 0 (root). To avoid 326 | # creating root-owned files in the build directory we tell docker to 327 | # use the current user ID. 328 | user_uid="$(id -u)" 329 | user_gid="$(id -g)" 330 | user_name="$(id -un)" 331 | 332 | # To allow docker in docker, we add the user to the docker group in 333 | # the host os. 334 | docker_gid=$(cut -d: -f3 < <(getent group docker)) 335 | 336 | update_cache="false" 337 | if [[ "${TRAMPOLINE_DOCKERFILE:-none}" != "none" ]]; then 338 | # Build the Docker image from the source. 339 | context_dir=$(dirname "${TRAMPOLINE_DOCKERFILE}") 340 | docker_build_flags=( 341 | "-f" "${TRAMPOLINE_DOCKERFILE}" 342 | "-t" "${TRAMPOLINE_IMAGE}" 343 | "--build-arg" "UID=${user_uid}" 344 | "--build-arg" "USERNAME=${user_name}" 345 | ) 346 | if [[ "${has_image}" == "true" ]]; then 347 | docker_build_flags+=("--cache-from" "${TRAMPOLINE_IMAGE}") 348 | fi 349 | 350 | log_yellow "Start building the docker image." 351 | if [[ "${TRAMPOLINE_VERBOSE:-false}" == "true" ]]; then 352 | echo "docker build" "${docker_build_flags[@]}" "${context_dir}" 353 | fi 354 | 355 | # ON CI systems, we want to suppress docker build logs, only 356 | # output the logs when it fails. 357 | if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then 358 | if docker build "${docker_build_flags[@]}" "${context_dir}" \ 359 | > "${tmpdir}/docker_build.log" 2>&1; then 360 | if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then 361 | cat "${tmpdir}/docker_build.log" 362 | fi 363 | 364 | log_green "Finished building the docker image." 365 | update_cache="true" 366 | else 367 | log_red "Failed to build the Docker image, aborting." 368 | log_yellow "Dumping the build logs:" 369 | cat "${tmpdir}/docker_build.log" 370 | exit 1 371 | fi 372 | else 373 | if docker build "${docker_build_flags[@]}" "${context_dir}"; then 374 | log_green "Finished building the docker image." 375 | update_cache="true" 376 | else 377 | log_red "Failed to build the Docker image, aborting." 378 | exit 1 379 | fi 380 | fi 381 | else 382 | if [[ "${has_image}" != "true" ]]; then 383 | log_red "We do not have ${TRAMPOLINE_IMAGE} locally, aborting." 384 | exit 1 385 | fi 386 | fi 387 | 388 | # We use an array for the flags so they are easier to document. 389 | docker_flags=( 390 | # Remove the container after it exists. 391 | "--rm" 392 | 393 | # Use the host network. 394 | "--network=host" 395 | 396 | # Run in priviledged mode. We are not using docker for sandboxing or 397 | # isolation, just for packaging our dev tools. 398 | "--privileged" 399 | 400 | # Run the docker script with the user id. Because the docker image gets to 401 | # write in ${PWD} you typically want this to be your user id. 402 | # To allow docker in docker, we need to use docker gid on the host. 403 | "--user" "${user_uid}:${docker_gid}" 404 | 405 | # Pass down the USER. 406 | "--env" "USER=${user_name}" 407 | 408 | # Mount the project directory inside the Docker container. 409 | "--volume" "${PROJECT_ROOT}:${TRAMPOLINE_WORKSPACE}" 410 | "--workdir" "${TRAMPOLINE_WORKSPACE}" 411 | "--env" "PROJECT_ROOT=${TRAMPOLINE_WORKSPACE}" 412 | 413 | # Mount the temporary home directory. 414 | "--volume" "${tmphome}:/h" 415 | "--env" "HOME=/h" 416 | 417 | # Allow docker in docker. 418 | "--volume" "/var/run/docker.sock:/var/run/docker.sock" 419 | 420 | # Mount the /tmp so that docker in docker can mount the files 421 | # there correctly. 422 | "--volume" "/tmp:/tmp" 423 | # Pass down the KOKORO_GFILE_DIR and KOKORO_KEYSTORE_DIR 424 | # TODO(tmatsuo): This part is not portable. 425 | "--env" "TRAMPOLINE_SECRET_DIR=/secrets" 426 | "--volume" "${KOKORO_GFILE_DIR:-/dev/shm}:/secrets/gfile" 427 | "--env" "KOKORO_GFILE_DIR=/secrets/gfile" 428 | "--volume" "${KOKORO_KEYSTORE_DIR:-/dev/shm}:/secrets/keystore" 429 | "--env" "KOKORO_KEYSTORE_DIR=/secrets/keystore" 430 | ) 431 | 432 | # Add an option for nicer output if the build gets a tty. 433 | if [[ -t 0 ]]; then 434 | docker_flags+=("-it") 435 | fi 436 | 437 | # Passing down env vars 438 | for e in "${pass_down_envvars[@]}" 439 | do 440 | if [[ -n "${!e:-}" ]]; then 441 | docker_flags+=("--env" "${e}=${!e}") 442 | fi 443 | done 444 | 445 | # If arguments are given, all arguments will become the commands run 446 | # in the container, otherwise run TRAMPOLINE_BUILD_FILE. 447 | if [[ $# -ge 1 ]]; then 448 | log_yellow "Running the given commands '" "${@:1}" "' in the container." 449 | readonly commands=("${@:1}") 450 | if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then 451 | echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" 452 | fi 453 | docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" 454 | else 455 | log_yellow "Running the tests in a Docker container." 456 | docker_flags+=("--entrypoint=${TRAMPOLINE_BUILD_FILE}") 457 | if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then 458 | echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" 459 | fi 460 | docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" 461 | fi 462 | 463 | 464 | test_retval=$? 465 | 466 | if [[ ${test_retval} -eq 0 ]]; then 467 | log_green "Build finished with ${test_retval}" 468 | else 469 | log_red "Build finished with ${test_retval}" 470 | fi 471 | 472 | # Only upload it when the test passes. 473 | if [[ "${update_cache}" == "true" ]] && \ 474 | [[ $test_retval == 0 ]] && \ 475 | [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]]; then 476 | log_yellow "Uploading the Docker image." 477 | if docker push "${TRAMPOLINE_IMAGE}"; then 478 | log_green "Finished uploading the Docker image." 479 | else 480 | log_red "Failed uploading the Docker image." 481 | fi 482 | # Call trampoline_after_upload_hook if it's defined. 483 | if function_exists trampoline_after_upload_hook; then 484 | trampoline_after_upload_hook 485 | fi 486 | 487 | fi 488 | 489 | exit "${test_retval}" 490 | -------------------------------------------------------------------------------- /.trampolinerc: -------------------------------------------------------------------------------- 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 | # Add required env vars here. 16 | required_envvars+=( 17 | ) 18 | 19 | # Add env vars which are passed down into the container here. 20 | pass_down_envvars+=( 21 | # We test this envvar in tests/python/test_envvar.py. 22 | "TEST_ENV" 23 | ) 24 | 25 | # Prevent unintentional override on the default image. 26 | if [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]] && \ 27 | [[ -z "${TRAMPOLINE_IMAGE:-}" ]]; then 28 | echo "Please set TRAMPOLINE_IMAGE if you want to upload the Docker image." 29 | exit 1 30 | fi 31 | 32 | # Define the default value if it makes sense. 33 | if [[ -z "${TRAMPOLINE_IMAGE_UPLOAD:-}" ]]; then 34 | TRAMPOLINE_IMAGE_UPLOAD="" 35 | fi 36 | 37 | if [[ -z "${TRAMPOLINE_IMAGE:-}" ]]; then 38 | TRAMPOLINE_IMAGE="" 39 | fi 40 | 41 | if [[ -z "${TRAMPOLINE_DOCKERFILE:-}" ]]; then 42 | TRAMPOLINE_DOCKERFILE=".kokoro/docker/Dockerfile" 43 | fi 44 | 45 | if [[ -z "${TRAMPOLINE_BUILD_FILE:-}" ]]; then 46 | TRAMPOLINE_BUILD_FILE="" 47 | fi 48 | 49 | # The build will show some commands and docker build logs. 50 | TRAMPOLINE_VERBOSE="true" 51 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Code owners file. 2 | # This file controls who is tagged for review for any given pull request. 3 | # 4 | # For syntax help see: 5 | # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax 6 | 7 | 8 | # The python-samples-owners team is the default owner for anything not 9 | # explicitly taken by someone else. 10 | * @GoogleCloudPlatform/python-samples-reviewers 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to become a contributor and submit your own code 2 | 3 | ## Contributor License Agreements 4 | 5 | We'd love to accept your sample apps and patches! Before we can take them, we 6 | have to jump a couple of legal hurdles. 7 | 8 | Please fill out either the individual or corporate Contributor License Agreement 9 | (CLA): 10 | 11 | * If you are an individual writing original source code and you're sure you 12 | own the intellectual property, then you'll need to sign an [individual CLA](https://developers.google.com/open-source/cla/individual). 13 | * If you work for a company that wants to allow you to contribute your work, 14 | then you'll need to sign a [corporate CLA](https://developers.google.com/open-source/cla/corporate). 15 | 16 | Follow either of the two links above to access the appropriate CLA and 17 | instructions for how to sign and return it. Once we receive it, we'll be able to 18 | accept your pull requests. 19 | 20 | ## Contributing A Patch 21 | 22 | 1. Submit an issue describing your proposed change to the repository in question. 23 | 1. The repository owner will respond to your issue promptly. 24 | 1. If your proposed change is accepted, and you haven't already done so, sign a 25 | CLA (see details above). 26 | 1. Fork the desired repo, then develop and test your code changes. 27 | 1. Ensure that your code adheres to the existing style in the sample to which 28 | you are contributing. Refer to the [Google Python Style Guide](https://github.com/google/styleguide/blob/gh-pages/pyguide.md) and the 29 | [Google Cloud Platform Community Style Guide](https://cloud.google.com/community/tutorials/styleguide) for the 30 | recommended coding standards for this organization. 31 | 1. Ensure that your code has an appropriate set of unit tests which all pass. 32 | 1. Submit a pull request. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting started with Python on Google Cloud Platform 2 | 3 | This repository is the complete sample code for the [Python Getting Started on Google Cloud Platform](https://cloud.google.com/python/docs/) tutorials. Please refer to the tutorials for instructions on configuring, running, and deploying these samples. 4 | 5 | The code for the samples is contained in individual folders in this repository. 6 | 7 | Tutorial | Folder 8 | ---------|------- 9 | [Getting Started](https://cloud.google.com/python/getting-started/) | [bookshelf](https://github.com/GoogleCloudPlatform/getting-started-python/tree/main/bookshelf) 10 | [Background Processing](https://cloud.google.com/python/getting-started/background-processing) | [background](https://github.com/GoogleCloudPlatform/getting-started-python/tree/main/background) 11 | [Deploying to Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/docs/quickstarts/deploying-a-language-specific-app) | [in "kubernetes-engine-samples" repo](https://github.com/GoogleCloudPlatform/kubernetes-engine-samples/tree/main/quickstart/python) 12 | [Deploying to Google Compute Engine](https://cloud.google.com/python/tutorials/getting-started-on-compute-engine) | [gce](https://github.com/GoogleCloudPlatform/getting-started-python/tree/main/gce) 13 | [Handling Sessions with Firestore](https://cloud.google.com/python/getting-started/session-handling-with-firestore) | [sessions](https://github.com/GoogleCloudPlatform/getting-started-python/tree/main/sessions) 14 | [Authenticating Users with IAP](https://cloud.google.com/python/getting-started/authenticate-users) | [authenticating-users](https://github.com/GoogleCloudPlatform/getting-started-python/tree/main/authenticating-users) 15 | 16 | ## Contributing changes 17 | 18 | * See [CONTRIBUTING.md](CONTRIBUTING.md) 19 | 20 | ## Licensing 21 | 22 | * See [LICENSE](LICENSE) 23 | -------------------------------------------------------------------------------- /authenticating-users/app.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 getting_started_app_yaml] 16 | runtime: python37 17 | # [END getting_started_app_yaml] 18 | -------------------------------------------------------------------------------- /authenticating-users/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 getting_started_auth_all] 16 | import sys 17 | 18 | from flask import Flask 19 | app = Flask(__name__) 20 | 21 | CERTS = None 22 | AUDIENCE = None 23 | 24 | 25 | # [START getting_started_auth_certs] 26 | def certs(): 27 | """Returns a dictionary of current Google public key certificates for 28 | validating Google-signed JWTs. Since these change rarely, the result 29 | is cached on first request for faster subsequent responses. 30 | """ 31 | import requests 32 | 33 | global CERTS 34 | if CERTS is None: 35 | response = requests.get( 36 | 'https://www.gstatic.com/iap/verify/public_key' 37 | ) 38 | CERTS = response.json() 39 | return CERTS 40 | # [END getting_started_auth_certs] 41 | 42 | 43 | # [START getting_started_auth_metadata] 44 | def get_metadata(item_name): 45 | """Returns a string with the project metadata value for the item_name. 46 | See https://cloud.google.com/compute/docs/storing-retrieving-metadata for 47 | possible item_name values. 48 | """ 49 | import requests 50 | 51 | endpoint = 'http://metadata.google.internal' 52 | path = '/computeMetadata/v1/project/' 53 | path += item_name 54 | response = requests.get( 55 | '{}{}'.format(endpoint, path), 56 | headers={'Metadata-Flavor': 'Google'} 57 | ) 58 | metadata = response.text 59 | return metadata 60 | # [END getting_started_auth_metadata] 61 | 62 | 63 | # [START getting_started_auth_audience] 64 | def audience(): 65 | """Returns the audience value (the JWT 'aud' property) for the current 66 | running instance. Since this involves a metadata lookup, the result is 67 | cached when first requested for faster future responses. 68 | """ 69 | global AUDIENCE 70 | if AUDIENCE is None: 71 | project_number = get_metadata('numeric-project-id') 72 | project_id = get_metadata('project-id') 73 | AUDIENCE = '/projects/{}/apps/{}'.format( 74 | project_number, project_id 75 | ) 76 | return AUDIENCE 77 | # [END getting_started_auth_audience] 78 | 79 | 80 | # [START getting_started_auth_validate_assertion] 81 | def validate_assertion(assertion): 82 | """Checks that the JWT assertion is valid (properly signed, for the 83 | correct audience) and if so, returns strings for the requesting user's 84 | email and a persistent user ID. If not valid, returns None for each field. 85 | """ 86 | from jose import jwt 87 | 88 | try: 89 | info = jwt.decode( 90 | assertion, 91 | certs(), 92 | algorithms=['ES256'], 93 | audience=audience() 94 | ) 95 | return info['email'], info['sub'] 96 | except Exception as e: 97 | print('Failed to validate assertion: {}'.format(e), file=sys.stderr) 98 | return None, None 99 | # [END getting_started_auth_validate_assertion] 100 | 101 | 102 | # [START getting_started_auth_front_controller] 103 | @app.route('/', methods=['GET']) 104 | def say_hello(): 105 | from flask import request 106 | 107 | assertion = request.headers.get('X-Goog-IAP-JWT-Assertion') 108 | email, id = validate_assertion(assertion) 109 | page = "

Hello {}

".format(email) 110 | return page 111 | # [END getting_started_auth_front_controller] 112 | # [END getting_started_auth_all] 113 | -------------------------------------------------------------------------------- /authenticating-users/main_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 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 | import main 16 | 17 | 18 | def fake_validate(assertion): 19 | if assertion == "Valid": 20 | return "nobody@example.com", "user0001" 21 | else: 22 | return None, None 23 | 24 | 25 | main.validate_assertion = fake_validate 26 | 27 | 28 | def test_home_page(): 29 | client = main.app.test_client() 30 | 31 | # Good request check 32 | r = client.get("/", headers={"X-Goog-IAP-JWT-Assertion": "Valid"}) 33 | assert "nobody@example.com" in r.text 34 | 35 | # Missing header check 36 | r = client.get("/") 37 | assert "None" in r.text 38 | 39 | # Bad header check 40 | r = client.get("/", headers={"X-Goog-IAP-JWT-Assertion": "Not Valid"}) 41 | assert "None" in r.text 42 | -------------------------------------------------------------------------------- /authenticating-users/requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.2 -------------------------------------------------------------------------------- /authenticating-users/requirements.txt: -------------------------------------------------------------------------------- 1 | # [START getting_started_requirements] 2 | Flask==2.2.5 3 | cryptography==41.0.2 4 | python-jose[cryptography]==3.3.0 5 | requests==2.31.0 6 | # [END getting_started_requirements] 7 | -------------------------------------------------------------------------------- /background/README.md: -------------------------------------------------------------------------------- 1 | Background Processing 2 | --------------------- 3 | 4 | This directory contains an example of doing background processing with App 5 | Engine, Cloud Pub/Sub, Cloud Functions, and Firestore. 6 | 7 | Deploy commands: 8 | 9 | From the app directory: 10 | ``` 11 | $ gcloud app deploy 12 | ``` 13 | 14 | From the function directory, after creating the PubSub topic: 15 | ``` 16 | $ gcloud functions deploy --runtime=python37 --trigger-topic=translate Translate --set-env-vars GOOGLE_CLOUD_PROJECT=my-project 17 | ``` 18 | -------------------------------------------------------------------------------- /background/app/app.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 getting_started_background_config] 16 | runtime: python37 17 | # [END getting_started_background_config] 18 | -------------------------------------------------------------------------------- /background/app/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | """ This web app shows translations that have been previously requested, and 16 | provides a form to request a new translation. 17 | """ 18 | 19 | # [START getting_started_background_app_main] 20 | import json 21 | import os 22 | 23 | from flask import Flask, redirect, render_template, request 24 | from google.cloud import firestore 25 | from google.cloud import pubsub 26 | 27 | 28 | app = Flask(__name__) 29 | 30 | # Get client objects to reuse over multiple invocations 31 | db = firestore.Client() 32 | publisher = pubsub.PublisherClient() 33 | 34 | # Keep this list of supported languages up to date 35 | ACCEPTABLE_LANGUAGES = ("de", "en", "es", "fr", "ja", "sw") 36 | # [END getting_started_background_app_main] 37 | 38 | 39 | # [START getting_started_background_app_list] 40 | @app.route("/", methods=["GET"]) 41 | def index(): 42 | """The home page has a list of prior translations and a form to 43 | ask for a new translation. 44 | """ 45 | 46 | doc_list = [] 47 | docs = db.collection("translations").stream() 48 | for doc in docs: 49 | doc_list.append(doc.to_dict()) 50 | 51 | return render_template("index.html", translations=doc_list) 52 | 53 | 54 | # [END getting_started_background_app_list] 55 | 56 | 57 | # [START getting_started_background_app_request] 58 | @app.route("/request-translation", methods=["POST"]) 59 | def translate(): 60 | """Handle a request to translate a string (form field 'v') to a given 61 | language (form field 'lang'), by sending a PubSub message to a topic. 62 | """ 63 | source_string = request.form.get("v", "") 64 | to_language = request.form.get("lang", "") 65 | 66 | if source_string == "": 67 | error_message = "Empty value" 68 | return error_message, 400 69 | 70 | if to_language not in ACCEPTABLE_LANGUAGES: 71 | error_message = "Unsupported language: {}".format(to_language) 72 | return error_message, 400 73 | 74 | message = { 75 | "Original": source_string, 76 | "Language": to_language, 77 | "Translated": "", 78 | "OriginalLanguage": "", 79 | } 80 | 81 | topic_name = "projects/{}/topics/{}".format( 82 | os.getenv("GOOGLE_CLOUD_PROJECT"), "translate" 83 | ) 84 | publisher.publish( 85 | topic=topic_name, data=json.dumps(message).encode("utf8") 86 | ) 87 | return redirect("/") 88 | 89 | 90 | # [END getting_started_background_app_request] 91 | -------------------------------------------------------------------------------- /background/app/main_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | import os 16 | import uuid 17 | 18 | import google.auth 19 | from google.cloud import firestore 20 | from google.cloud import pubsub 21 | import main 22 | import pytest 23 | 24 | 25 | credentials, project_id = google.auth.default() 26 | os.environ["GOOGLE_CLOUD_PROJECT"] = project_id 27 | SUBSCRIPTION_NAME = "projects/{}/subscriptions/{}".format( 28 | project_id, "test-" + str(uuid.uuid4()) 29 | ) 30 | TOPIC_NAME = "projects/{}/topics/{}".format(project_id, "translate") 31 | 32 | 33 | @pytest.fixture 34 | def db(): 35 | def clear_collection(collection): 36 | """Removes every document from the collection, to make it easy to see 37 | what has been added by the current test run. 38 | """ 39 | for doc in collection.stream(): 40 | doc.reference.delete() 41 | 42 | client = firestore.Client() 43 | translations = client.collection("translations") 44 | clear_collection(translations) 45 | translations.add( 46 | { 47 | "Original": "A testing message", 48 | "Language": "fr", 49 | "Translated": '"A testing message", but in French', 50 | "OriginalLanguage": "en", 51 | }, 52 | document_id="test translation", 53 | ) 54 | yield client 55 | 56 | 57 | @pytest.fixture 58 | def publisher(): 59 | client = pubsub.PublisherClient() 60 | yield client 61 | 62 | 63 | @pytest.fixture 64 | def subscriber(): 65 | subscriber = pubsub.SubscriberClient() 66 | subscriber.create_subscription( 67 | request={"name": SUBSCRIPTION_NAME, "topic": TOPIC_NAME} 68 | ) 69 | yield subscriber 70 | subscriber.delete_subscription(request={"subscription": SUBSCRIPTION_NAME}) 71 | 72 | 73 | def test_index(db, publisher): 74 | main.app.testing = True 75 | main.db = db 76 | main.publisher = publisher 77 | client = main.app.test_client() 78 | 79 | r = client.get("/") 80 | assert r.status_code == 200 81 | response_text = r.data.decode("utf-8") 82 | assert "Text to translate" in response_text 83 | assert "but in French" in response_text 84 | 85 | 86 | def test_translate(db, publisher, subscriber): 87 | main.app.testing = True 88 | main.db = db 89 | main.publisher = publisher 90 | client = main.app.test_client() 91 | 92 | r = client.post( 93 | "/request-translation", 94 | data={ 95 | "v": "This is a test", 96 | "lang": "fr", 97 | }, 98 | ) 99 | 100 | assert r.status_code < 400 101 | 102 | response = subscriber.pull( 103 | request={"subscription": SUBSCRIPTION_NAME, "max_messages": 1}, 104 | timeout=10.0, 105 | ) 106 | assert len(response.received_messages) == 1 107 | assert b"This is a test" in response.received_messages[0].message.data 108 | assert b"fr" in response.received_messages[0].message.data 109 | -------------------------------------------------------------------------------- /background/app/requirements.txt: -------------------------------------------------------------------------------- 1 | google-cloud-firestore==2.11.1 2 | google-cloud-pubsub==2.16.1 3 | flask==2.2.5 4 | -------------------------------------------------------------------------------- /background/app/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Translations 20 | 21 | 22 | 23 | 24 | 25 | 61 | 69 | 70 | 71 | 72 | 73 |
74 |
75 |
76 | 77 | Translate with Background Processing 78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | 88 | 89 |
90 | 98 | 100 |
101 |
102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | {% for translation in translations %} 112 | 113 | 119 | 125 | 126 | {% endfor %} 127 | 128 |
OriginalTranslation
114 | 115 | {{ translation['OriginalLanguage'] }} 116 | 117 | {{ translation['Original'] }} 118 | 120 | 121 | {{ translation['Language'] }} 122 | 123 | {{ translation['Translated'] }} 124 |
129 |
130 | 133 |
134 |
135 |
136 |
137 |
138 | 139 |
140 |
141 |
142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /background/function/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | """ This function handles messages posted to a pubsub topic by translating 16 | the data in the message as requested. The message must be a JSON encoded 17 | dictionary with fields: 18 | 19 | Original - the string to translate 20 | Language - the language to translate the string to 21 | 22 | The dictionary may have other fields, which will be ignored. 23 | """ 24 | 25 | # [START getting_started_background_translate_setup] 26 | import base64 27 | import hashlib 28 | import json 29 | 30 | from google.cloud import firestore 31 | from google.cloud import translate_v2 as translate 32 | # [END getting_started_background_translate_setup] 33 | 34 | # [START getting_started_background_translate_init] 35 | # Get client objects once to reuse over multiple invocations. 36 | xlate = translate.Client() 37 | db = firestore.Client() 38 | # [END getting_started_background_translate_init] 39 | 40 | 41 | # [START getting_started_background_translate_string] 42 | def translate_string(from_string, to_language): 43 | """ Translates a string to a specified language. 44 | 45 | from_string - the original string before translation 46 | 47 | to_language - the language to translate to, as a two-letter code (e.g., 48 | 'en' for english, 'de' for german) 49 | 50 | Returns the translated string and the code for original language 51 | """ 52 | result = xlate.translate(from_string, target_language=to_language) 53 | return result['translatedText'], result['detectedSourceLanguage'] 54 | # [END getting_started_background_translate_string] 55 | 56 | 57 | # [START getting_started_background_translate] 58 | def document_name(message): 59 | """ Messages are saved in a Firestore database with document IDs generated 60 | from the original string and destination language. If the exact same 61 | translation is requested a second time, the result will overwrite the 62 | prior result. 63 | 64 | message - a dictionary with fields named Language and Original, and 65 | optionally other fields with any names 66 | 67 | Returns a unique name that is an allowed Firestore document ID 68 | """ 69 | key = '{}/{}'.format(message['Language'], message['Original']) 70 | hashed = hashlib.sha512(key.encode()).digest() 71 | 72 | # Note that document IDs should not contain the '/' character 73 | name = base64.b64encode(hashed, altchars=b'+-').decode('utf-8') 74 | return name 75 | 76 | 77 | @firestore.transactional 78 | def update_database(transaction, message): 79 | name = document_name(message) 80 | doc_ref = db.collection('translations').document(document_id=name) 81 | 82 | try: 83 | doc_ref.get(transaction=transaction) 84 | except firestore.NotFound: 85 | return # Don't replace an existing translation 86 | 87 | transaction.set(doc_ref, message) 88 | 89 | 90 | def translate_message(event, context): 91 | """ Process a pubsub message requesting a translation 92 | """ 93 | message_data = base64.b64decode(event['data']).decode('utf-8') 94 | message = json.loads(message_data) 95 | 96 | from_string = message['Original'] 97 | to_language = message['Language'] 98 | 99 | to_string, from_language = translate_string(from_string, to_language) 100 | 101 | message['Translated'] = to_string 102 | message['OriginalLanguage'] = from_language 103 | 104 | transaction = db.transaction() 105 | update_database(transaction, message) 106 | # [END getting_started_background_translate] 107 | -------------------------------------------------------------------------------- /background/function/main_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | import base64 16 | import json 17 | 18 | from google.cloud import firestore 19 | import main 20 | 21 | 22 | def clear_collection(collection): 23 | """ Removes every document from the collection, to make it easy to see 24 | what has been added by the current test run. 25 | """ 26 | for doc in collection.stream(): 27 | doc.reference.delete() 28 | 29 | 30 | def test_invocations(): 31 | db = firestore.Client() 32 | main.db = db 33 | 34 | translations = db.collection('translations') 35 | clear_collection(translations) 36 | 37 | event = { 38 | 'data': base64.b64encode(json.dumps({ 39 | 'Original': 'My test message', 40 | 'Language': 'de', 41 | }).encode('utf-8')) 42 | } 43 | 44 | main.translate_message(event, None) 45 | 46 | docs = [doc for doc in translations.stream()] 47 | assert len(docs) == 1 # Should be only the one just created 48 | 49 | message = docs[0].to_dict() 50 | 51 | assert message['Original'] == 'My test message' 52 | assert message['Language'] == 'de' 53 | assert len(message['Translated']) > 0 54 | assert message['OriginalLanguage'] == 'en' 55 | -------------------------------------------------------------------------------- /background/function/requirements.txt: -------------------------------------------------------------------------------- 1 | google-cloud-translate==3.11.1 2 | google-cloud-firestore==2.11.1 3 | -------------------------------------------------------------------------------- /bookshelf/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python image. 2 | # https://hub.docker.com/_/python 3 | FROM python:3.11-slim 4 | 5 | # Copy local code to the container image. 6 | ENV APP_HOME /app 7 | WORKDIR $APP_HOME 8 | COPY . ./ 9 | 10 | # Install production dependencies. 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | 13 | # Run the web service on container startup. 14 | ENTRYPOINT [ "gunicorn", "--bind", "0.0.0.0:8080", "main:app" ] -------------------------------------------------------------------------------- /bookshelf/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python37 2 | 3 | -------------------------------------------------------------------------------- /bookshelf/firestore.py: -------------------------------------------------------------------------------- 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 | # 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 bookshelf_firestore_client_import] 16 | from google.cloud import firestore 17 | # [END bookshelf_firestore_client_import] 18 | 19 | 20 | def document_to_dict(doc): 21 | if not doc.exists: 22 | return None 23 | doc_dict = doc.to_dict() 24 | doc_dict['id'] = doc.id 25 | return doc_dict 26 | 27 | 28 | def next_page(limit=10, start_after=None): 29 | db = firestore.Client() 30 | 31 | query = db.collection(u'Book').limit(limit).order_by(u'title') 32 | 33 | if start_after: 34 | # Construct a new query starting at this document. 35 | query = query.start_after({u'title': start_after}) 36 | 37 | docs = query.stream() 38 | docs = list(map(document_to_dict, docs)) 39 | 40 | last_title = None 41 | if limit == len(docs): 42 | # Get the last document from the results and set as the last title. 43 | last_title = docs[-1][u'title'] 44 | return docs, last_title 45 | 46 | 47 | def read(book_id): 48 | # [START bookshelf_firestore_client] 49 | db = firestore.Client() 50 | book_ref = db.collection(u'Book').document(book_id) 51 | snapshot = book_ref.get() 52 | # [END bookshelf_firestore_client] 53 | return document_to_dict(snapshot) 54 | 55 | 56 | def update(data, book_id=None): 57 | db = firestore.Client() 58 | book_ref = db.collection(u'Book').document(book_id) 59 | book_ref.set(data) 60 | return document_to_dict(book_ref.get()) 61 | 62 | 63 | create = update 64 | 65 | 66 | def delete(id): 67 | db = firestore.Client() 68 | book_ref = db.collection(u'Book').document(id) 69 | book_ref.delete() 70 | -------------------------------------------------------------------------------- /bookshelf/images/moby-dick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/getting-started-python/d9da8db203f7729f5da28b57be66b69084955bde/bookshelf/images/moby-dick.png -------------------------------------------------------------------------------- /bookshelf/main.py: -------------------------------------------------------------------------------- 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 | # 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 | import logging 16 | 17 | import firestore 18 | from flask import current_app, flash, Flask, Markup, redirect, render_template 19 | from flask import request, url_for 20 | from google.cloud import error_reporting 21 | import google.cloud.logging 22 | import storage 23 | 24 | 25 | # [START upload_image_file] 26 | def upload_image_file(img): 27 | """ 28 | Upload the user-uploaded file to Google Cloud Storage and retrieve its 29 | publicly-accessible URL. 30 | """ 31 | if not img: 32 | return None 33 | 34 | public_url = storage.upload_file( 35 | img.read(), 36 | img.filename, 37 | img.content_type 38 | ) 39 | 40 | current_app.logger.info( 41 | 'Uploaded file %s as %s.', img.filename, public_url) 42 | 43 | return public_url 44 | # [END upload_image_file] 45 | 46 | 47 | app = Flask(__name__) 48 | app.config.update( 49 | SECRET_KEY='secret', 50 | MAX_CONTENT_LENGTH=8 * 1024 * 1024, 51 | ALLOWED_EXTENSIONS=set(['png', 'jpg', 'jpeg', 'gif']) 52 | ) 53 | 54 | app.debug = False 55 | app.testing = False 56 | 57 | # Configure logging 58 | if not app.testing: 59 | logging.basicConfig(level=logging.INFO) 60 | client = google.cloud.logging.Client() 61 | # Attaches a Google Stackdriver logging handler to the root logger 62 | client.setup_logging() 63 | 64 | 65 | @app.route('/') 66 | def list(): 67 | start_after = request.args.get('start_after', None) 68 | books, last_title = firestore.next_page(start_after=start_after) 69 | 70 | return render_template('list.html', books=books, last_title=last_title) 71 | 72 | 73 | @app.route('/books/') 74 | def view(book_id): 75 | book = firestore.read(book_id) 76 | return render_template('view.html', book=book) 77 | 78 | 79 | @app.route('/books/add', methods=['GET', 'POST']) 80 | def add(): 81 | if request.method == 'POST': 82 | data = request.form.to_dict(flat=True) 83 | 84 | # If an image was uploaded, update the data to point to the new image. 85 | image_url = upload_image_file(request.files.get('image')) 86 | 87 | if image_url: 88 | data['imageUrl'] = image_url 89 | 90 | book = firestore.create(data) 91 | 92 | return redirect(url_for('.view', book_id=book['id'])) 93 | 94 | return render_template('form.html', action='Add', book={}) 95 | 96 | 97 | @app.route('/books//edit', methods=['GET', 'POST']) 98 | def edit(book_id): 99 | book = firestore.read(book_id) 100 | 101 | if request.method == 'POST': 102 | data = request.form.to_dict(flat=True) 103 | 104 | # If an image was uploaded, update the data to point to the new image. 105 | image_url = upload_image_file(request.files.get('image')) 106 | 107 | if image_url: 108 | data['imageUrl'] = image_url 109 | 110 | book = firestore.update(data, book_id) 111 | 112 | return redirect(url_for('.view', book_id=book['id'])) 113 | 114 | return render_template('form.html', action='Edit', book=book) 115 | 116 | 117 | @app.route('/books//delete') 118 | def delete(book_id): 119 | firestore.delete(book_id) 120 | return redirect(url_for('.list')) 121 | 122 | 123 | @app.route('/logs') 124 | def logs(): 125 | logging.info('Hey, you triggered a custom log entry. Good job!') 126 | flash(Markup('''You triggered a custom log entry. You can view it in the 127 | Cloud Console''')) 128 | return redirect(url_for('.list')) 129 | 130 | 131 | @app.route('/errors') 132 | def errors(): 133 | raise Exception('This is an intentional exception.') 134 | 135 | 136 | # Add an error handler that reports exceptions to Stackdriver Error 137 | # Reporting. Note that this error handler is only used when debug 138 | # is False 139 | @app.errorhandler(500) 140 | def server_error(e): 141 | client = error_reporting.Client() 142 | client.report_exception( 143 | http_context=error_reporting.build_flask_context(request)) 144 | return """ 145 | An internal error occurred:
{}
146 | See logs for full stacktrace. 147 | """.format(e), 500 148 | 149 | 150 | # This is only used when running locally. When running live, gunicorn runs 151 | # the application. 152 | if __name__ == '__main__': 153 | app.run(host='127.0.0.1', port=8080, debug=True) 154 | -------------------------------------------------------------------------------- /bookshelf/main_test.py: -------------------------------------------------------------------------------- 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 | # 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 | import os 16 | import re 17 | 18 | import google.auth 19 | import main 20 | import pytest 21 | import requests 22 | from six import BytesIO 23 | 24 | 25 | credentials, project_id = google.auth.default() 26 | os.environ['GOOGLE_CLOUD_PROJECT'] = project_id 27 | 28 | 29 | @pytest.fixture 30 | def app(request): 31 | """This fixture provides a Flask app instance configured for testing. 32 | 33 | It also ensures the tests run within a request context, allowing 34 | any calls to flask.request, flask.current_app, etc. to work.""" 35 | app = main.app 36 | 37 | with app.test_request_context(): 38 | yield app 39 | 40 | 41 | @pytest.fixture 42 | def firestore(): 43 | """This fixture provides a modified version of the app's Firebase model 44 | that tracks all created items and deletes them at the end of the test. 45 | 46 | Any tests that directly or indirectly interact with the database should 47 | use this to ensure that resources are properly cleaned up. 48 | """ 49 | 50 | import firestore 51 | 52 | # Ensure no books exist before running the tests. This typically helps if 53 | # tests somehow left the database in a bad state. 54 | delete_all_books(firestore) 55 | 56 | yield firestore 57 | 58 | # Delete all books that we created during tests. 59 | delete_all_books(firestore) 60 | 61 | 62 | def delete_all_books(firestore): 63 | while True: 64 | books, _ = firestore.next_page(limit=50) 65 | if not books: 66 | break 67 | for book in books: 68 | firestore.delete(book['id']) 69 | 70 | 71 | def test_list(app, firestore): 72 | for i in range(1, 12): 73 | firestore.create({'title': u'Book {0}'.format(i)}) 74 | 75 | with app.test_client() as c: 76 | rv = c.get('/') 77 | 78 | assert rv.status == '200 OK' 79 | 80 | body = rv.data.decode('utf-8') 81 | assert 'Book 1' in body, "Should show books" 82 | assert len(re.findall('

Book', body)) <= 10, ( 83 | "Should not show more than 10 books") 84 | assert 'More' in body, "Should have more than one page" 85 | 86 | 87 | def test_add(app): 88 | data = { 89 | 'title': 'Test Book', 90 | 'author': 'Test Author', 91 | 'publishedDate': 'Test Date Published', 92 | 'description': 'Test Description' 93 | } 94 | 95 | with app.test_client() as c: 96 | rv = c.post('books/add', data=data, follow_redirects=True) 97 | 98 | assert rv.status == '200 OK' 99 | body = rv.data.decode('utf-8') 100 | assert 'Test Book' in body 101 | assert 'Test Author' in body 102 | assert 'Test Date Published' in body 103 | assert 'Test Description' in body 104 | 105 | 106 | def test_edit(app, firestore): 107 | existing = firestore.create({'title': "Temp Title"}) 108 | 109 | with app.test_client() as c: 110 | rv = c.post( 111 | 'books/%s/edit' % existing['id'], 112 | data={'title': 'Updated Title'}, 113 | follow_redirects=True) 114 | 115 | assert rv.status == '200 OK' 116 | body = rv.data.decode('utf-8') 117 | assert 'Updated Title' in body 118 | assert 'Temp Title' not in body 119 | 120 | 121 | def test_delete(app, firestore): 122 | existing = firestore.create({'title': "Temp Title"}) 123 | 124 | with app.test_client() as c: 125 | rv = c.get( 126 | 'books/%s/delete' % existing['id'], 127 | follow_redirects=True) 128 | 129 | assert rv.status == '200 OK' 130 | assert not firestore.read(existing['id']) 131 | 132 | 133 | def test_upload_image(app): 134 | data = { 135 | 'title': 'Test Book', 136 | 'author': 'Test Author', 137 | 'publishedDate': 'Test Date Published', 138 | 'description': 'Test Description', 139 | 'image': (BytesIO(b'hello world'), 'hello.jpg') 140 | } 141 | 142 | with app.test_client() as c: 143 | rv = c.post('books/add', data=data, follow_redirects=True) 144 | 145 | assert rv.status == '200 OK' 146 | body = rv.data.decode('utf-8') 147 | 148 | img_tag = re.search(''), 162 | '1337h4x0r.php') 163 | } 164 | 165 | with app.test_client() as c: 166 | rv = c.post('/books/add', data=data, follow_redirects=True) 167 | 168 | # check we weren't pwned 169 | assert rv.status == '400 BAD REQUEST' 170 | -------------------------------------------------------------------------------- /bookshelf/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.5 2 | google-cloud-firestore==2.11.1 3 | google-cloud-storage==2.9.0 4 | google-cloud-error-reporting==1.9.1 5 | google-cloud-logging==3.5.0 6 | gunicorn==20.1.0 7 | six==1.16.0 8 | -------------------------------------------------------------------------------- /bookshelf/storage.py: -------------------------------------------------------------------------------- 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 | # 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 | from __future__ import absolute_import 16 | 17 | import datetime 18 | import os 19 | 20 | from flask import current_app 21 | from google.cloud import storage 22 | import six 23 | from werkzeug.exceptions import BadRequest 24 | from werkzeug.utils import secure_filename 25 | 26 | 27 | def _check_extension(filename, allowed_extensions): 28 | file, ext = os.path.splitext(filename) 29 | if (ext.replace('.', '') not in allowed_extensions): 30 | raise BadRequest( 31 | '{0} has an invalid name or extension'.format(filename)) 32 | 33 | 34 | def _safe_filename(filename): 35 | """ 36 | Generates a safe filename that is unlikely to collide with existing 37 | objects in Google Cloud Storage. 38 | 39 | ``filename.ext`` is transformed into ``filename-YYYY-MM-DD-HHMMSS.ext`` 40 | """ 41 | filename = secure_filename(filename) 42 | date = datetime.datetime.utcnow().strftime("%Y-%m-%d-%H%M%S") 43 | basename, extension = filename.rsplit('.', 1) 44 | return "{0}-{1}.{2}".format(basename, date, extension) 45 | 46 | 47 | def upload_file(file_stream, filename, content_type): 48 | """ 49 | Uploads a file to a given Cloud Storage bucket and returns the public url 50 | to the new object. 51 | """ 52 | _check_extension(filename, current_app.config['ALLOWED_EXTENSIONS']) 53 | filename = _safe_filename(filename) 54 | 55 | bucketname = os.getenv('GOOGLE_STORAGE_BUCKET') or os.getenv( 56 | 'GOOGLE_CLOUD_PROJECT') + '_bucket' 57 | 58 | # [START bookshelf_cloud_storage_client] 59 | client = storage.Client() 60 | bucket = client.bucket(bucketname) 61 | blob = bucket.blob(filename) 62 | 63 | blob.upload_from_string( 64 | file_stream, 65 | content_type=content_type) 66 | # Ensure the file is publicly readable. 67 | blob.make_public() 68 | 69 | url = blob.public_url 70 | # [END bookshelf_cloud_storage_client] 71 | 72 | if isinstance(url, six.binary_type): 73 | url = url.decode('utf-8') 74 | 75 | return url 76 | -------------------------------------------------------------------------------- /bookshelf/templates/base.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2019 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | 18 | 19 | Bookshelf - Python on Google Cloud Platform 20 | 21 | 22 | 23 | 24 | 25 | 35 |
36 | {% block content %}{% endblock %} 37 |
38 | {{user}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /bookshelf/templates/form.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2019 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | {# [START form] #} 18 | {% extends "base.html" %} 19 | 20 | {% block content %} 21 |

{{action}} book

22 | 23 |
24 | 25 |
26 | 27 | 28 |
29 | 30 |
31 | 32 | 33 |
34 | 35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 | 43 |
44 | 45 |
46 | 47 | 48 |
49 | 50 | 54 | 55 | 56 |
57 | 58 | {% endblock %} 59 | {# [END form] #} 60 | -------------------------------------------------------------------------------- /bookshelf/templates/list.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2019 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | {% extends "base.html" %} 18 | 19 | {% block content %} 20 | 21 | {% with messages = get_flashed_messages() %} 22 | {% for message in messages %} 23 |

{{ message }}

24 | {% endfor %} 25 | {% endwith %} 26 | 27 |

Books

28 | 29 | 30 | Add book 31 | 32 | 33 | {% for book in books %} 34 | 49 | {% else %} 50 |

No books found

51 | {% endfor %} 52 | 53 | {% if last_title %} 54 | 59 | {% endif %} 60 | 61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /bookshelf/templates/view.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2019 Google LLC 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | {% extends "base.html" %} 18 | 19 | {% block content %} 20 | 21 |

Book

22 | 23 | 33 | 34 |
35 | {# [START book_image] #} 36 |
37 | {% if book.imageUrl %} 38 | 39 | {% else %} 40 | 41 | {% endif %} 42 |
43 | {# [END book_image] #} 44 |
45 |

46 | {{book.title}} 47 | {{book.publishedDate}} 48 |

49 |
By {{book.author|default('Unknown', True)}}
50 |

{{book.description}}

51 |
52 |
53 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /decrypt-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016 Google Inc. All rights reserved. 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 | set -euo pipefail 18 | 19 | # Always cd to the project root. 20 | readonly root="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 21 | cd ${root} 22 | 23 | # Use SECRET_MANAGER_PROJECT if set, fallback to cloud-devrel-kokoro-resources. 24 | readonly project_id="${SECRET_MANAGER_PROJECT:-cloud-devrel-kokoro-resources}" 25 | 26 | # If there's already a secret file, skip retrieving the secret. 27 | if [[ -f "service-account.json" ]]; then 28 | echo "The secret already exists, skipping." 29 | exit 0 30 | fi 31 | 32 | gcloud secrets versions access latest \ 33 | --secret="getting-started-python-service-account" \ 34 | --project="${project_id}" \ 35 | > service-account.json 36 | -------------------------------------------------------------------------------- /encrypt-secrets.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016 Google Inc. All rights reserved. 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 | set -euo pipefail 18 | 19 | # Always cd to the project root. 20 | readonly root="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 21 | cd ${root} 22 | 23 | # Use SECRET_MANAGER_PROJECT if set, fallback to cloud-devrel-kokoro-resources. 24 | readonly project_id="${SECRET_MANAGER_PROJECT:-cloud-devrel-kokoro-resources}" 25 | 26 | gcloud secrets versions add "getting-started-python-service-account" \ 27 | --project="${project_id}" \ 28 | --data-file="service-account.json" 29 | -------------------------------------------------------------------------------- /gce/README.md: -------------------------------------------------------------------------------- 1 | # Hello World for Python on Google Compute Engine 2 | 3 | This folder contains the sample code for the [Deploying to Google Compute Engine][tutorial-gce] 4 | tutorial. Please refer to the tutorial for instructions on configuring, running, 5 | and deploying this sample. 6 | 7 | [tutorial-gce]: https://cloud.google.com/python/tutorials/getting-started-on-compute-engine 8 | -------------------------------------------------------------------------------- /gce/add-google-cloud-ops-agent-repo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2020 Google Inc. All rights reserved. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # *NOTE*: The source of truth for this script is: 17 | # https://dl.google.com/cloudagents/add-google-cloud-ops-agent-repo.sh 18 | # See https://cloud.google.com/stackdriver/docs/solutions/agents/ops-agent/installation 19 | # for installation instructions. 20 | # It is committed to this repository to follow security best practices. 21 | # 22 | # 23 | # Add repository for the Google ops agent. 24 | # 25 | # This script adds the required apt or yum repository and installs or uninstalls 26 | # the agent based on the corresponding flags. 27 | # 28 | # Available flags: 29 | # * `--verbose`: 30 | # Turns on verbose logging during the script execution, which is helpful for 31 | # debugging purposes. 32 | # 33 | # * `--also-install`: 34 | # Installs the agent after adding the agent package repository. If this flag 35 | # is absent, the script only adds the agent package repository. This flag 36 | # can not be run with the `--uninstall` flag. 37 | # 38 | # * `--version `: 39 | # Sets the agent version for the script to install. Allowed formats: 40 | # * `latest`: 41 | # Adds an agent package repository that contains all agent versions, and 42 | # installs the latest version of the agent. 43 | # * `MAJOR_VERSION.*.*`: 44 | # Adds an agent package repository that contains all agent versions up to 45 | # this major version (e.g. `1.*.*`), and installs the latest version of 46 | # the agent within the range of that major version. 47 | # * `MAJOR_VERSION.MINOR_VERSION.PATCH_VERSION`: 48 | # Adds an agent package repository that contains all agent versions, and 49 | # installs the specified version of the agent (e.g. `3.2.1`). 50 | # 51 | # * `--uninstall`: 52 | # Uninstalls the agent. This flag can not be run with the `--also-install` 53 | # flag. 54 | # 55 | # * `--remove-repo`: 56 | # Removes the corresponding agent package repository after installing or 57 | # uninstalling the agent. 58 | # 59 | # * `--dry-run`: 60 | # Triggers only a dry run of the script execution and prints out the 61 | # commands that it is supposed to execute. This is helpful to know what 62 | # actions the script will take. 63 | # 64 | # * `--uninstall-standalone-logging-agent`: 65 | # Uninstalls the standalone logging agent (`google-fluentd`). 66 | # 67 | # * `--uninstall-standalone-monitoring-agent`: 68 | # Uninstalls the standalone monitoring agent (`stackdriver-agent`). 69 | # 70 | # Sample usage: 71 | # * To add the repo that contains all agent versions, run: 72 | # $ bash add-google-cloud-ops-agent-repo.sh 73 | # 74 | # * To add the repo and also install the agent, run: 75 | # $ bash add-google-cloud-ops-agent-repo.sh --also-install --version= 76 | # 77 | # * To uninstall the agent run: 78 | # $ bash add-google-cloud-ops-agent-repo.sh --uninstall 79 | # 80 | # * To uninstall the agent and remove the repo, run: 81 | # $ bash add-google-cloud-ops-agent-repo.sh --uninstall --remove-repo 82 | # 83 | # * To run the script with verbose logging, run: 84 | # $ bash add-google-cloud-ops-agent-repo.sh --also-install --verbose 85 | # 86 | # * To run the script in dry-run mode, run: 87 | # $ bash add-google-cloud-ops-agent-repo.sh --also-install --dry-run 88 | # 89 | # * To replace standalone agents with the Ops agent, run: 90 | # $ bash add-google-cloud-ops-agent-repo.sh --also-install --uninstall-standalone-logging-agent --uninstall-standalone-monitoring-agent 91 | # 92 | # Internal usage only: 93 | # The environment variable `REPO_SUFFIX` can be set to alter which repository is 94 | # used. A dash (-) will be inserted prior to the supplied suffix. `REPO_SUFFIX` 95 | # defaults to `all` which contains all agent versions across different major 96 | # versions. The full repository name is: 97 | # "google-cloud-ops-agent-[-]-". 98 | 99 | # Ignore the return code of command substitution in variables. 100 | # shellcheck disable=SC2155 101 | # 102 | # Initialize var used to notify config management tools of when a change is made. 103 | CHANGED=0 104 | 105 | fail() { 106 | echo >&2 "[$(date +'%Y-%m-%dT%H:%M:%S%z')] $*" 107 | exit 1 108 | } 109 | 110 | # Parsing flag value. 111 | declare -a ACTIONS=() 112 | DRY_RUN='' 113 | VERBOSE='false' 114 | while getopts -- '-:' OPTCHAR; do 115 | case "${OPTCHAR}" in 116 | -) 117 | case "${OPTARG}" in 118 | # Note: Do not remove entries from this list when deprecating flags. 119 | # That would break user scripts that specify those flags. Instead, 120 | # leave the flag in place but make it a noop. 121 | also-install) ACTIONS+=('also-install') ;; 122 | version=*) AGENT_VERSION="${OPTARG#*=}" ;; 123 | uninstall) ACTIONS+=('uninstall') ;; 124 | remove-repo) ACTIONS+=('remove-repo') ;; 125 | uninstall-standalone-logging-agent) ACTIONS+=('uninstall-standalone-logging-agent') ;; 126 | uninstall-standalone-monitoring-agent) ACTIONS+=('uninstall-standalone-monitoring-agent') ;; 127 | dry-run) echo 'Starting dry run'; DRY_RUN='dryrun' ;; 128 | verbose) VERBOSE='true' ;; 129 | *) fail "Unknown option '${OPTARG}'." ;; 130 | esac 131 | esac 132 | done 133 | [[ " ${ACTIONS[*]} " == *\ uninstall\ * || ( " ${ACTIONS[*]} " == *\ remove-repo\ * && " ${ACTIONS[*]} " != *\ also-install\ * )]] || \ 134 | ACTIONS+=('add-repo') 135 | # Sort the actions array for easier parsing. 136 | readarray -t ACTIONS < <(printf '%s\n' "${ACTIONS[@]}" | sort) 137 | readonly ACTIONS DRY_RUN VERBOSE 138 | 139 | if [[ " ${ACTIONS[*]} " == *\ also-install*uninstall\ * ]]; then 140 | fail "Received conflicting flags 'also-install' and 'uninstall'." 141 | fi 142 | 143 | if [[ "${VERBOSE}" == 'true' ]]; then 144 | echo 'Enable verbose logging.' 145 | set -x 146 | fi 147 | 148 | # Host that serves the repositories. 149 | REPO_HOST='packages.cloud.google.com' 150 | 151 | # URL for the ops agent documentation. 152 | AGENT_DOCS_URL='https://cloud.google.com/stackdriver/docs/solutions/ops-agent' 153 | 154 | # URL documentation which lists supported platforms for running the ops agent. 155 | AGENT_SUPPORTED_URL="${AGENT_DOCS_URL}/#supported_operating_systems" 156 | 157 | # Packages to install. 158 | AGENT_PACKAGE='google-cloud-ops-agent' 159 | declare -a ADDITIONAL_PACKAGES=() 160 | 161 | if [[ -f /etc/os-release ]]; then 162 | . /etc/os-release 163 | fi 164 | 165 | # If dry-run mode is enabled, echo VM state-changing commands instead of executing them. 166 | dryrun() { 167 | # Needed for commands that use pipes. 168 | if [[ ! -t 0 ]]; then 169 | cat 170 | fi 171 | printf -v cmd_str '%q ' "$@" 172 | echo "DRY_RUN: Not executing '$cmd_str'" 173 | } 174 | 175 | refresh_failed() { 176 | local REPO_TYPE="$1" 177 | local OS_FAMILY="$2" 178 | fail "Could not refresh the google-cloud-ops-agent ${REPO_TYPE} repositories. 179 | Please check your network connectivity and make sure you are running a supported 180 | ${OS_FAMILY} distribution. See ${AGENT_SUPPORTED_URL} 181 | for a list of supported platforms." 182 | } 183 | 184 | resolve_version() { 185 | if [[ "${AGENT_VERSION:-latest}" == 'latest' ]]; then 186 | AGENT_VERSION='' 187 | elif grep -qE '^[0-9]+\.\*\.\*$' <<<"${AGENT_VERSION}"; then 188 | REPO_SUFFIX="${REPO_SUFFIX:-"${AGENT_VERSION%%.*}"}" 189 | elif ! grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"${AGENT_VERSION}"; then 190 | fail "The agent version [${AGENT_VERSION}] is not allowed. Expected values: [latest], 191 | or anything in the format of [MAJOR_VERSION.MINOR_VERSION.PATCH_VERSION] or [MAJOR_VERSION.*.*]." 192 | fi 193 | } 194 | 195 | handle_debian() { 196 | declare -a EXTRA_OPTS=() 197 | [[ "${VERBOSE}" == 'true' ]] && EXTRA_OPTS+=(-oDebug::pkgAcquire::Worker=1) 198 | 199 | add_repo() { 200 | [[ -n "${REPO_CODENAME:-}" ]] || lsb_release -v >/dev/null 2>&1 || { \ 201 | apt-get update; apt-get -y install lsb-release; CHANGED=1; 202 | } 203 | [[ "$(dpkg -l apt-transport-https 2>&1 | grep -o '^[a-z][a-z]')" == 'ii' ]] || { \ 204 | ${DRY_RUN} apt-get update; ${DRY_RUN} apt-get -y install apt-transport-https; CHANGED=1; 205 | } 206 | [[ "$(dpkg -l ca-certificates 2>&1 | grep -o '^[a-z][a-z]')" == 'ii' ]] || { \ 207 | ${DRY_RUN} apt-get update; ${DRY_RUN} apt-get -y install ca-certificates; CHANGED=1; 208 | } 209 | local CODENAME="${REPO_CODENAME:-"$(lsb_release -sc)"}" 210 | local REPO_NAME="google-cloud-ops-agent-${CODENAME}-${REPO_SUFFIX:-all}" 211 | local REPO_DATA="deb https://${REPO_HOST}/apt ${REPO_NAME} main" 212 | if ! cmp -s <<<"${REPO_DATA}" - /etc/apt/sources.list.d/google-cloud-ops-agent.list; then 213 | echo "Adding agent repository for ${ID}." 214 | ${DRY_RUN} tee <<<"${REPO_DATA}" /etc/apt/sources.list.d/google-cloud-ops-agent.list 215 | ${DRY_RUN} curl --connect-timeout 5 -s -f "https://${REPO_HOST}/apt/doc/apt-key.gpg" \ 216 | | ${DRY_RUN} apt-key add - 217 | CHANGED=1 218 | fi 219 | } 220 | 221 | remove_repo() { 222 | if [[ -f /etc/apt/sources.list.d/google-cloud-ops-agent.list ]]; then 223 | echo "Removing agent repository for ${ID}." 224 | ${DRY_RUN} rm /etc/apt/sources.list.d/google-cloud-ops-agent.list 225 | CHANGED=1 226 | fi 227 | } 228 | 229 | expected_version_installed() { 230 | [[ "$(dpkg -l "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" 2>&1 | grep -o '^[a-z][a-z]' | sort -u)" == 'ii' ]] || \ 231 | return 232 | if [[ -z "${AGENT_VERSION:-}" ]]; then 233 | apt-get --dry-run install "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" \ 234 | | grep -qo '^0 upgraded, 0 newly installed' 235 | elif grep -qE '^[0-9]+\.\*\.\*$' <<<"${AGENT_VERSION}"; then 236 | dpkg -l "${AGENT_PACKAGE}" | grep -qE "$AGENT_PACKAGE $AGENT_VERSION" && \ 237 | apt-get --dry-run install "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" \ 238 | | grep -qo '^0 upgraded, 0 newly installed' 239 | else 240 | dpkg -l "${AGENT_PACKAGE}" | grep -qE "$AGENT_PACKAGE $AGENT_VERSION" 241 | fi 242 | } 243 | 244 | install_agent() { 245 | ${DRY_RUN} apt-get update || refresh_failed 'apt' "${ID}" 246 | expected_version_installed || { \ 247 | if [[ -n "${AGENT_VERSION:-}" ]]; then 248 | # Differentiate `MAJOR_VERSION.MINOR_VERSION.PATCH_VERSION` from `MAJOR_VERSION.*.*`. 249 | # apt package version format: e.g. 2.0.1~debian9.13. 250 | if grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"${AGENT_VERSION}"; then 251 | AGENT_VERSION="=${AGENT_VERSION}~*" 252 | else 253 | AGENT_VERSION="=${AGENT_VERSION%.\*}" 254 | fi 255 | fi 256 | ${DRY_RUN} apt-get -y --allow-downgrades "${EXTRA_OPTS[@]}" install "${AGENT_PACKAGE}${AGENT_VERSION}" \ 257 | "${ADDITIONAL_PACKAGES[@]}" || fail "${AGENT_PACKAGE} ${ADDITIONAL_PACKAGES[*]} \ 258 | installation failed." 259 | echo "${AGENT_PACKAGE} ${ADDITIONAL_PACKAGES[*]} installation succeeded." 260 | CHANGED=1 261 | } 262 | } 263 | 264 | uninstall() { 265 | local -a packages=("$@") 266 | # Return early unless at least one package is installed. 267 | dpkg -l "${packages[@]}" 2>&1 | grep -qo '^ii' || return 268 | ${DRY_RUN} apt-get -y "${EXTRA_OPTS[@]}" remove "${packages[@]}" || \ 269 | fail "${packages[*]} uninstallation failed." 270 | echo "${packages[*]} uninstallation succeeded." 271 | CHANGED=1 272 | } 273 | } 274 | 275 | handle_rpm() { 276 | declare -a EXTRA_OPTS=() 277 | [[ "${VERBOSE}" == 'true' ]] && EXTRA_OPTS+=(-v) 278 | 279 | add_repo() { 280 | local REPO_NAME="google-cloud-ops-agent-${CODENAME}-\$basearch-${REPO_SUFFIX:-all}" 281 | local REPO_DATA="\ 282 | [google-cloud-ops-agent] 283 | name=Google Cloud Ops Agent Repository 284 | baseurl=https://${REPO_HOST}/yum/repos/${REPO_NAME} 285 | autorefresh=0 286 | enabled=1 287 | type=rpm-md 288 | gpgcheck=1 289 | repo_gpgcheck=0 290 | gpgkey=https://${REPO_HOST}/yum/doc/yum-key.gpg 291 | https://${REPO_HOST}/yum/doc/rpm-package-key.gpg" 292 | if ! cmp -s <<<"${REPO_DATA}" - /etc/yum.repos.d/google-cloud-ops-agent.repo; then 293 | echo "Adding agent repository for ${ID}." 294 | ${DRY_RUN} tee <<<"${REPO_DATA}" /etc/yum.repos.d/google-cloud-ops-agent.repo 295 | # After repo upgrades, CentOS7/RHEL7 won't pick up newly available packages 296 | # until the cache is cleared. 297 | ${DRY_RUN} rm -rf /var/cache/yum/*/*/google-cloud-ops-agent/ 298 | CHANGED=1 299 | fi 300 | } 301 | 302 | remove_repo() { 303 | if [[ -f /etc/yum.repos.d/google-cloud-ops-agent.repo ]]; then 304 | echo "Removing agent repository for ${ID}." 305 | ${DRY_RUN} rm /etc/yum.repos.d/google-cloud-ops-agent.repo 306 | CHANGED=1 307 | fi 308 | } 309 | 310 | expected_version_installed() { 311 | rpm -q "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" >/dev/null 2>&1 || return 312 | if [[ -z "${AGENT_VERSION:-}" ]]; then 313 | yum -y check-update "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" >/dev/null 2>&1 314 | elif grep -qE '^[0-9]+\.\*\.\*$' <<<"${AGENT_VERSION}"; then 315 | CURRENT_VERSION="$(rpm -q --queryformat '%{VERSION}' "${AGENT_PACKAGE}")" 316 | grep -qE "${AGENT_VERSION}" <<<"${CURRENT_VERSION}" && \ 317 | yum -y check-update "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" >/dev/null 2>&1 318 | else 319 | CURRENT_VERSION="$(rpm -q --queryformat '%{VERSION}' "${AGENT_PACKAGE}")" 320 | [[ "${AGENT_VERSION}" == "${CURRENT_VERSION}" ]] 321 | fi 322 | } 323 | 324 | install_agent() { 325 | expected_version_installed || { \ 326 | ${DRY_RUN} yum -y list updates || refresh_failed 'yum' "${ID}" 327 | local COMMAND='install' 328 | if [[ -n "${AGENT_VERSION:-}" ]]; then 329 | [[ -z "${CURRENT_VERSION:-}" ]] || \ 330 | [[ "${AGENT_VERSION}" == "$(sort -rV <<<"${AGENT_VERSION}"$'\n'"${CURRENT_VERSION}" | head -1)" ]] || \ 331 | COMMAND='downgrade' 332 | # Differentiate `MAJOR_VERSION.MINOR_VERSION.PATCH_VERSION` from `MAJOR_VERSION.*.*`. 333 | # yum package version format: e.g. 1.0.1-1.el8. 334 | if grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"${AGENT_VERSION}"; then 335 | AGENT_VERSION="-${AGENT_VERSION}-1*" 336 | else 337 | AGENT_VERSION="-${AGENT_VERSION}" 338 | fi 339 | fi 340 | ${DRY_RUN} yum -y "${EXTRA_OPTS[@]}" "${COMMAND}" "${AGENT_PACKAGE}${AGENT_VERSION}" \ 341 | "${ADDITIONAL_PACKAGES[@]}" || fail "${AGENT_PACKAGE} ${ADDITIONAL_PACKAGES[*]} \ 342 | installation failed." 343 | echo "${AGENT_PACKAGE} ${ADDITIONAL_PACKAGES[*]} installation succeeded." 344 | CHANGED=1 345 | } 346 | } 347 | 348 | uninstall() { 349 | local -a packages=("$@") 350 | # Return early if none of the packages are installed. 351 | rpm -q "${packages[@]}" | grep -qvE 'is not installed$' || return 352 | ${DRY_RUN} yum -y "${EXTRA_OPTS[@]}" remove "${packages[@]}" || \ 353 | fail "${packages[*]} uninstallation failed." 354 | echo "${packages[*]} uninstallation succeeded." 355 | CHANGED=1 356 | } 357 | } 358 | 359 | handle_redhat() { 360 | local MAJOR_VERSION="$(rpm --eval %{?rhel})" 361 | CODENAME="el${MAJOR_VERSION}" 362 | handle_rpm 363 | } 364 | 365 | handle_suse() { 366 | declare -a EXTRA_OPTS=() 367 | [[ "${VERBOSE}" == 'true' ]] && EXTRA_OPTS+=(-vv) 368 | 369 | add_repo() { 370 | local SUSE_VERSION=${VERSION_ID%%.*} 371 | local CODENAME="sles${SUSE_VERSION}" 372 | local REPO_NAME="google-cloud-ops-agent-${CODENAME}-\$basearch-${REPO_SUFFIX:-all}" 373 | { 374 | ${DRY_RUN} zypper --non-interactive refresh || { \ 375 | echo >&2 'Could not refresh zypper repositories.'; \ 376 | echo >&2 'This is not necessarily a fatal error; proceeding...'; \ 377 | } 378 | } | grep -qF 'Retrieving repository' || [[ -n "${DRY_RUN:-}" ]] && CHANGED=1 379 | local REPO_DATA="\ 380 | [google-cloud-ops-agent] 381 | name=Google Cloud Ops Agent Repository 382 | baseurl=https://${REPO_HOST}/yum/repos/${REPO_NAME} 383 | autorefresh=0 384 | enabled=1 385 | type=rpm-md 386 | gpgkey=https://${REPO_HOST}/yum/doc/yum-key.gpg 387 | https://${REPO_HOST}/yum/doc/rpm-package-key.gpg" 388 | if ! cmp -s <<<"${REPO_DATA}" - /etc/zypp/repos.d/google-cloud-ops-agent.repo; then 389 | echo "Adding agent repository for ${ID}." 390 | ${DRY_RUN} tee <<<"${REPO_DATA}" /etc/zypp/repos.d/google-cloud-ops-agent.repo 391 | CHANGED=1 392 | fi 393 | local RPM_KEYS="$(rpm --query gpg-pubkey)" # Save the installed keys. 394 | ${DRY_RUN} rpm --import "https://${REPO_HOST}/yum/doc/yum-key.gpg" "https://${REPO_HOST}/yum/doc/rpm-package-key.gpg" 395 | if [[ -n "${DRY_RUN:-}" ]] || ! cmp --silent <<<"${RPM_KEYS}" - <(rpm --query gpg-pubkey); then 396 | CHANGED=1 397 | fi 398 | { 399 | ${DRY_RUN} zypper --non-interactive --gpg-auto-import-keys refresh google-cloud-ops-agent || \ 400 | refresh_failed 'zypper' "${ID}"; \ 401 | } | grep -qF 'Retrieving repository' || [[ -n "${DRY_RUN:-}" ]] && CHANGED=1 402 | } 403 | 404 | remove_repo() { 405 | if [[ -f /etc/zypp/repos.d/google-cloud-ops-agent.repo ]]; then 406 | echo "Removing agent repository for ${ID}." 407 | ${DRY_RUN} rm /etc/zypp/repos.d/google-cloud-ops-agent.repo 408 | CHANGED=1 409 | fi 410 | } 411 | 412 | expected_version_installed() { 413 | rpm -q "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" >/dev/null 2>&1 || return 414 | if [[ -z "${AGENT_VERSION:-}" ]]; then 415 | zypper --non-interactive update --dry-run "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" \ 416 | | grep -qE '^Nothing to do.' 417 | elif grep -qE '^[0-9]+\.\*\.\*$' <<<"${AGENT_VERSION}"; then 418 | rpm -q --queryformat '%{VERSION}' "${AGENT_PACKAGE}" | grep -qE "${AGENT_VERSION}" && \ 419 | zypper --non-interactive update --dry-run "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" \ 420 | | grep -qE '^Nothing to do.' 421 | else 422 | [[ "${AGENT_VERSION}" == "$(rpm -q --queryformat '%{VERSION}' "${AGENT_PACKAGE}")" ]] 423 | fi 424 | } 425 | 426 | install_agent() { 427 | expected_version_installed || { \ 428 | if [[ -n "${AGENT_VERSION:-}" ]]; then 429 | # Differentiate `MAJOR_VERSION.MINOR_VERSION.PATCH_VERSION` from `MAJOR_VERSION.*.*`. 430 | # zypper package version format: e.g. 1.0.6-1.sles15. 431 | if grep -qE '^[0-9]+\.\*\.\*$' <<<"${AGENT_VERSION}"; then 432 | AGENT_VERSION="<$(( ${AGENT_VERSION%%.*} + 1 ))" 433 | else 434 | AGENT_VERSION="=${AGENT_VERSION}" 435 | fi 436 | fi 437 | ${DRY_RUN} zypper --non-interactive "${EXTRA_OPTS[@]}" install --oldpackage "${AGENT_PACKAGE}${AGENT_VERSION}" \ 438 | "${ADDITIONAL_PACKAGES[@]}" || fail "${AGENT_PACKAGE} ${ADDITIONAL_PACKAGES[*]} \ 439 | installation failed." 440 | echo "${AGENT_PACKAGE} ${ADDITIONAL_PACKAGES[*]} installation succeeded." 441 | CHANGED=1 442 | } 443 | } 444 | 445 | uninstall() { 446 | local -a packages=("$@") 447 | # Return early if none of the packages are installed. 448 | rpm -q "${packages[@]}" | grep -qvE 'is not installed$' || return 449 | ${DRY_RUN} zypper --non-interactive "${EXTRA_OPTS[@]}" remove "${packages[@]}" || \ 450 | fail "${packages[*]} uninstallation failed." 451 | echo "${packages[*]} uninstallation succeeded." 452 | CHANGED=1 453 | } 454 | } 455 | 456 | save_configuration_files() { 457 | local save_dir="/var/lib/google-cloud-ops-agent/saved_configs" 458 | ${DRY_RUN} mkdir -p "${save_dir}" 459 | ${DRY_RUN} cp -rp "$@" "${save_dir}" 460 | echo "$* is now copied over to ${save_dir} folder." 461 | } 462 | 463 | main() { 464 | case "${ID:-}" in 465 | debian|ubuntu) handle_debian ;; 466 | rhel|centos) handle_redhat ;; 467 | sles|opensuse-leap) handle_suse ;; 468 | *) 469 | # Fallback for systems lacking /etc/os-release. 470 | if [[ -f /etc/debian_version ]]; then 471 | ID='debian' 472 | handle_debian 473 | elif [[ -f /etc/redhat-release ]]; then 474 | ID='rhel' 475 | handle_redhat 476 | elif [[ -f /etc/SuSE-release ]]; then 477 | ID='sles' 478 | handle_suse 479 | else 480 | fail "Unidentifiable or unsupported platform. See 481 | ${AGENT_SUPPORTED_URL} for a list of supported platforms." 482 | fi 483 | esac 484 | 485 | 486 | if [[ " ${ACTIONS[*]} " == *\ uninstall-standalone-logging-agent\ * ]]; then 487 | save_configuration_files "/etc/google-fluentd" 488 | # This will also remove dependent packages, e.g. "google-fluentd-catch-all-config" or "google-fluentd-catch-all-config-structured". 489 | uninstall "google-fluentd" 490 | fi 491 | if [[ " ${ACTIONS[*]} " == *\ uninstall-standalone-monitoring-agent\ * ]]; then 492 | save_configuration_files "/etc/stackdriver" "/opt/stackdriver/collectd/etc" 493 | uninstall "stackdriver-agent" 494 | fi 495 | if [[ " ${ACTIONS[*]} " == *\ add-repo\ * ]]; then 496 | resolve_version 497 | add_repo 498 | fi 499 | if [[ " ${ACTIONS[*]} " == *\ also-install\ * ]]; then 500 | install_agent 501 | elif [[ " ${ACTIONS[*]} " == *\ uninstall\ * ]]; then 502 | save_configuration_files "/etc/google-cloud-ops-agent" 503 | uninstall "${AGENT_PACKAGE}" "${ADDITIONAL_PACKAGES[@]}" 504 | fi 505 | if [[ " ${ACTIONS[*]} " == *\ remove-repo\ * ]]; then 506 | remove_repo 507 | fi 508 | 509 | if [[ "${CHANGED}" == 0 ]]; then 510 | echo 'No changes made.' 511 | fi 512 | 513 | if [[ -n "${DRY_RUN:-}" ]]; then 514 | echo 'Finished dry run. This was only a simulation, remove the --dry-run flag 515 | to perform an actual execution of the script.' 516 | fi 517 | } 518 | 519 | main "$@" 520 | -------------------------------------------------------------------------------- /gce/deploy.sh: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | set -ex 16 | 17 | # [START getting_started_gce_create_instance] 18 | MY_INSTANCE_NAME="my-app-instance" 19 | ZONE=us-central1-a 20 | 21 | gcloud compute instances create $MY_INSTANCE_NAME \ 22 | --image-family=debian-10 \ 23 | --image-project=debian-cloud \ 24 | --machine-type=g1-small \ 25 | --scopes userinfo-email,cloud-platform \ 26 | --metadata-from-file startup-script=startup-script.sh \ 27 | --zone $ZONE \ 28 | --tags http-server 29 | # [END getting_started_gce_create_instance] 30 | 31 | gcloud compute firewall-rules create default-allow-http-8080 \ 32 | --allow tcp:8080 \ 33 | --source-ranges 0.0.0.0/0 \ 34 | --target-tags http-server \ 35 | --description "Allow port 8080 access to http-server" 36 | -------------------------------------------------------------------------------- /gce/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | from flask import Flask 16 | app = Flask(__name__) 17 | 18 | 19 | @app.route('/', methods=['GET']) 20 | def say_hello(): 21 | return "Hello, world!" 22 | 23 | 24 | if __name__ == '__main__': 25 | app.run(host='127.0.0.1', port=8080, debug=True) 26 | -------------------------------------------------------------------------------- /gce/main_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 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 | import main 16 | 17 | 18 | def test_hello(): 19 | main.app.testing = True 20 | client = main.app.test_client() 21 | 22 | r = client.get("/") 23 | assert r.status_code == 200 24 | response_text = r.data.decode("utf-8") 25 | assert "Hello, world!" in response_text 26 | -------------------------------------------------------------------------------- /gce/procfile: -------------------------------------------------------------------------------- 1 | hello: /opt/app/gce/env/bin/gunicorn -b 0.0.0.0:8080 main:app 2 | -------------------------------------------------------------------------------- /gce/python-app.conf: -------------------------------------------------------------------------------- 1 | [program:pythonapp] 2 | directory=/opt/app/gce 3 | command=/opt/app/gce/env/bin/honcho start -f ./procfile hello 4 | autostart=true 5 | autorestart=true 6 | user=pythonapp 7 | # Environment variables ensure that the application runs inside of the 8 | # configured virtualenv. 9 | environment=VIRTUAL_ENV="/opt/app/gce/env",PATH="/opt/app/gce/env/bin",HOME="/home/pythonapp",USER="pythonapp" 10 | stdout_logfile=syslog 11 | stderr_logfile=syslog 12 | -------------------------------------------------------------------------------- /gce/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==2.2.5 2 | honcho==1.1.0 3 | gunicorn==20.1.0 4 | -------------------------------------------------------------------------------- /gce/startup-script.sh: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | # Echo commands and fail on error 16 | set -ev 17 | 18 | # [START getting_started_gce_startup_script] 19 | # Install or update needed software 20 | apt-get update 21 | apt-get install -yq git supervisor python python-pip python3-distutils 22 | pip install --upgrade pip virtualenv 23 | 24 | # Fetch source code 25 | export HOME=/root 26 | git clone https://github.com/GoogleCloudPlatform/getting-started-python.git /opt/app 27 | 28 | # Install Cloud Ops Agent 29 | sudo bash /opt/app/gce/add-google-cloud-ops-agent-repo.sh --also-install 30 | 31 | # Account to own server process 32 | useradd -m -d /home/pythonapp pythonapp 33 | 34 | # Python environment setup 35 | virtualenv -p python3 /opt/app/gce/env 36 | /bin/bash -c "source /opt/app/gce/env/bin/activate" 37 | /opt/app/gce/env/bin/pip install -r /opt/app/gce/requirements.txt 38 | 39 | # Set ownership to newly created account 40 | chown -R pythonapp:pythonapp /opt/app 41 | 42 | # Put supervisor configuration in proper place 43 | cp /opt/app/gce/python-app.conf /etc/supervisor/conf.d/python-app.conf 44 | 45 | # Start service via supervisorctl 46 | supervisorctl reread 47 | supervisorctl update 48 | # [END getting_started_gce_startup_script] 49 | -------------------------------------------------------------------------------- /gce/teardown.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # Copyright 2019 Google LLC All Rights Reserved. 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 | set -x 18 | 19 | MY_INSTANCE_NAME="my-app-instance" 20 | ZONE=us-central1-a 21 | 22 | gcloud compute instances delete $MY_INSTANCE_NAME \ 23 | --zone=$ZONE --delete-disks=all 24 | 25 | gcloud compute firewall-rules delete default-allow-http-8080 26 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | import os 3 | 4 | import nox 5 | 6 | REPO_TOOLS_REQ = \ 7 | 'git+https://github.com/GoogleCloudPlatform/python-repo-tools.git' 8 | 9 | DIRS = [ 10 | 'authenticating-users', 11 | 'background/app', 12 | 'background/function', 13 | 'gce', 14 | 'sessions', 15 | 'bookshelf', 16 | ] 17 | 18 | PYTEST_COMMON_ARGS = ['--junitxml=sponge_log.xml', '-m', 'not e2e'] 19 | 20 | 21 | @nox.session 22 | def check_requirements(session): 23 | session.install(REPO_TOOLS_REQ) 24 | 25 | if 'update' in session.posargs: 26 | command = 'update-requirements' 27 | else: 28 | command = 'check-requirements' 29 | 30 | for reqfile in glob('**/requirements*.txt'): 31 | session.run('gcp-devrel-py-tools', command, reqfile) 32 | 33 | 34 | @nox.session 35 | def lint(session): 36 | session.install('flake8', 'flake8-import-order') 37 | session.run( 38 | 'flake8', '--exclude=env,.nox,._config.py,.tox', 39 | '--import-order-style=google', '.') 40 | 41 | 42 | def run_test(session, dir): 43 | session.install('-r', 'requirements.txt') 44 | session.chdir(dir) 45 | if os.path.exists('requirements.txt'): 46 | session.install('-r', 'requirements.txt') 47 | 48 | session.env['PYTHONPATH'] = os.getcwd() 49 | session.run( 50 | 'pytest', 51 | *(PYTEST_COMMON_ARGS + session.posargs), 52 | # Pytest will return 5 when no tests are collected. This can happen 53 | # when slow and flaky tests are excluded. 54 | # See http://doc.pytest.org/en/latest/_modules/_pytest/main.html 55 | success_codes=[0, 5]) 56 | 57 | 58 | @nox.session(python="3.12") 59 | @nox.parametrize('dir', DIRS) 60 | def run_tests(session, dir=None): 61 | """Run all tests for all directories (slow!)""" 62 | run_test(session, dir) 63 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | .Python 6 | env 7 | pip-log.txt 8 | pip-delete-this-directory.txt 9 | .tox 10 | .coverage 11 | .coverage.* 12 | .cache 13 | nosetests.xml 14 | coverage.xml 15 | *.cover 16 | *.log 17 | .git 18 | .mypy_cache 19 | .pytest_cache 20 | .hypothesis 21 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/Dockerfile: -------------------------------------------------------------------------------- 1 | # The Google App Engine python runtime is Debian Jessie with Python installed 2 | # and various os-level packages to allow installation of popular Python 3 | # libraries. The source is on github at: 4 | # https://github.com/GoogleCloudPlatform/python-docker 5 | FROM gcr.io/google-appengine/python 6 | 7 | # Create a virtualenv for the application dependencies. 8 | # If you want to use Python 2, add the -p python2.7 flag. 9 | RUN virtualenv -p python3.4 /env 10 | 11 | # Set virtualenv environment variables. This is equivalent to running 12 | # source /env/bin/activate. This ensures the application is executed within 13 | # the context of the virtualenv and will have access to its dependencies. 14 | ENV VIRTUAL_ENV /env 15 | ENV PATH /env/bin:$PATH 16 | 17 | # Install dependencies. 18 | ADD requirements.txt /app/requirements.txt 19 | RUN pip install -r /app/requirements.txt 20 | 21 | # Add application code. 22 | ADD . /app 23 | 24 | # Instead of using gunicorn directly, we'll use Honcho. Honcho is a python port 25 | # of the Foreman process manager. $PROCESSES is set in the pod manifest 26 | # to control which processes Honcho will start. 27 | CMD honcho start -f /app/procfile $PROCESSES 28 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/Makefile: -------------------------------------------------------------------------------- 1 | GCLOUD_PROJECT:=$(shell gcloud config list project --format="value(core.project)") 2 | 3 | .PHONY: all 4 | all: deploy 5 | 6 | .PHONY: create-cluster 7 | create-cluster: 8 | gcloud container clusters create bookshelf \ 9 | --scopes "cloud-platform" \ 10 | --num-nodes 2 11 | gcloud container clusters get-credentials bookshelf 12 | 13 | .PHONY: create-bucket 14 | create-bucket: 15 | gsutil mb gs://$(GCLOUD_PROJECT) 16 | gsutil defacl set public-read gs://$(GCLOUD_PROJECT) 17 | 18 | .PHONY: build 19 | build: 20 | docker build -t gcr.io/$(GCLOUD_PROJECT)/bookshelf . 21 | 22 | .PHONY: push 23 | push: build 24 | gcloud docker -- push gcr.io/$(GCLOUD_PROJECT)/bookshelf 25 | 26 | .PHONY: template 27 | template: 28 | sed -i ".tmpl" "s/\[GCLOUD_PROJECT\]/$(GCLOUD_PROJECT)/g" bookshelf-frontend.yaml 29 | sed -i ".tmpl" "s/\[GCLOUD_PROJECT\]/$(GCLOUD_PROJECT)/g" bookshelf-worker.yaml 30 | 31 | .PHONY: create-service 32 | create-service: 33 | kubectl create -f bookshelf-service.yaml 34 | 35 | .PHONY: deploy-frontend 36 | deploy-frontend: push template 37 | kubectl create -f bookshelf-frontend.yaml 38 | 39 | .PHONY: deploy-worker 40 | deploy-worker: push template 41 | kubectl create -f bookshelf-worker.yaml 42 | 43 | .PHONY: deploy 44 | deploy: deploy-frontend deploy-worker create-service 45 | 46 | .PHONY: delete 47 | delete: 48 | -kubectl delete -f bookshelf-service.yaml 49 | -kubectl delete -f bookshelf-worker.yaml 50 | -kubectl delete -f bookshelf-frontend.yaml 51 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/README.md: -------------------------------------------------------------------------------- 1 | # Deploy Bookshelf to Google Kubernetes Engine 2 | 3 | This optional tutorial will walk you through how to deploy the Bookshelf sample application to [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/). This tutorial is also applicable to [Kubernetes](http://kubernetes.io/) outside of Google Kubernetes Engine, but may require additional steps for external load balancing. 4 | 5 | ## Pre-requisites 6 | 7 | 1. Create a project in the [Google Cloud Platform Console](https://console.cloud.google.com). 8 | 9 | 2. [Enable billing](https://console.cloud.google.com/project/_/settings) for your project. 10 | 11 | 3. [Enable APIs](https://console.cloud.google.com/flows/enableapi?apiid=datastore,pubsub,storage_api,logging,plus) for your project. The provided link will enable all necessary APIs, but if you wish to do so manually you will need Datastore, Pub/Sub, Storage, and Logging. 12 | 13 | 4. Install the [Google Cloud SDK](https://cloud.google.com/sdk) 14 | 15 | $ curl https://sdk.cloud.google.com | bash 16 | $ gcloud init 17 | 18 | 5. Install [Docker](https://www.docker.com/). 19 | 20 | ## Create a cluster 21 | 22 | Create a cluster for the bookshelf application: 23 | 24 | gcloud container clusters create bookshelf \ 25 | --scopes "cloud-platform" \ 26 | --num-nodes 2 27 | gcloud container clusters get-credentials bookshelf 28 | 29 | The scopes specified in the `--scopes` argument allows nodes in the cluster to access Google Cloud Platform APIs, such as the Cloud Datastore API. 30 | 31 | Alternatively, you can use make: 32 | 33 | make create-cluster 34 | 35 | ## Create a Cloud Storage bucket 36 | 37 | The bookshelf application uses [Google Cloud Storage](https://cloud.google.com/storage) to store image files. Create a bucket for your project: 38 | 39 | gsutil mb gs:// 40 | gsutil defacl set public-read gs:// 41 | 42 | Alternatively, you can use make: 43 | 44 | make create-bucket 45 | 46 | ## Update config.py 47 | 48 | Modify config.py and enter your Cloud Project ID into the `PROJECT_ID` and `CLOUD_STORAGE_BUCKET` field. The remaining configuration values are only needed if you wish to use a different database or if you wish to enable log-in via oauth2, which requires a domain name. 49 | 50 | ## Build the bookshelf container 51 | 52 | Before the application can be deployed to Kubernetes Engine, you will need build and push the image to [Google Container Registry](https://cloud.google.com/container-registry/). 53 | 54 | docker build -t gcr.io//bookshelf . 55 | gcloud docker push gcr.io//bookshelf 56 | 57 | Alternatively, you can use make: 58 | 59 | make push 60 | 61 | ## Deploy the bookshelf frontend 62 | 63 | The bookshelf app has two distinct "tiers". The frontend serves a web interface to create and manage books, while the worker handles fetching book information from the Google Books API. 64 | 65 | Update `bookshelf-frontend.yaml` with your Project ID or use `make template`. This file contains the Kubernetes resource definitions to deploy the frontend. You can use `kubectl` to create these resources on the cluster: 66 | 67 | kubectl create -f bookshelf-frontend.yaml 68 | 69 | Alternatively, you can use make: 70 | 71 | make deploy-frontend 72 | 73 | Once the resources are created, there should be 3 `bookshelf-frontend` pods on the cluster. To see the pods and ensure that they are running: 74 | 75 | kubectl get pods 76 | 77 | If the pods are not ready or if you see restarts, you can get the logs for a particular pod to figure out the issue: 78 | 79 | kubectl logs pod-id 80 | 81 | Once the pods are ready, you can get the public IP address of the load balancer: 82 | 83 | kubectl get services bookshelf-frontend 84 | 85 | You can then browse to the public IP address in your browser to see the bookshelf application. 86 | 87 | ## Deploy worker 88 | 89 | Update `bookshelf-worker.yaml` with your Project ID or use `make template`. This file contains the Kubernetes resource definitions to deploy the worker. The worker doesn't need to serve web traffic or expose any ports, so it has significantly less configuration than the frontend. You can use `kubectl` to create these resources on the cluster: 90 | 91 | kubectl create -f bookshelf-worker.yaml 92 | 93 | Alternatively, you can use make: 94 | 95 | make deploy-worker 96 | 97 | Once again, use `kubectl get pods` to check the status of the worker pods. Once the worker pods are up and running, you should be able to create books on the frontend and the workers will handle updating book information in the background. 98 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf-frontend.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | # This file configures the bookshelf application frontend. The frontend serves 16 | # public web traffic. 17 | 18 | apiVersion: apps/v1 19 | kind: Deployment 20 | metadata: 21 | name: bookshelf-frontend 22 | labels: 23 | app: bookshelf 24 | # The bookshelf frontend replica set ensures that at least 3 25 | # instances of the bookshelf app are running on the cluster. 26 | # For more info about Pods see: 27 | # https://cloud.google.com/kubernetes-engine/docs/pods/ 28 | spec: 29 | replicas: 3 30 | selector: 31 | matchLabels: 32 | app: bookshelf 33 | tier: frontend 34 | template: 35 | metadata: 36 | labels: 37 | app: bookshelf 38 | tier: frontend 39 | spec: 40 | containers: 41 | - name: bookshelf-app 42 | # Replace [GCLOUD_PROJECT] with your project ID or use `make template`. 43 | image: gcr.io/[GCLOUD_PROJECT]/bookshelf 44 | # This setting makes nodes pull the docker image every time before 45 | # starting the pod. This is useful when debugging, but should be turned 46 | # off in production. 47 | imagePullPolicy: Always 48 | # The PROCESSES environment variable is used by Honcho in the 49 | # Dockerfile's CMD to control which processes are started. In this 50 | # case, only the bookshelf process is needed. 51 | env: 52 | - name: PROCESSES 53 | value: bookshelf 54 | # The bookshelf process listens on port 8080 for web traffic by default. 55 | ports: 56 | - name: http-server 57 | containerPort: 8080 58 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf-service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. 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 | # The bookshelf service provides a load-balancing proxy over the bookshelf 16 | # frontend pods. By specifying the type as a 'LoadBalancer', Kubernetes Engine 17 | # will create an external HTTP load balancer. 18 | # For more information about Services see: 19 | # https://cloud.google.com/kubernetes-engine/docs/services/ 20 | # For more information about external HTTP load balancing see: 21 | # https://cloud.google.com/kubernetes-engine/docs/load-balancer 22 | apiVersion: v1 23 | kind: Service 24 | metadata: 25 | name: bookshelf-frontend 26 | labels: 27 | app: bookshelf 28 | tier: frontend 29 | spec: 30 | type: LoadBalancer 31 | ports: 32 | - port: 80 33 | targetPort: http-server 34 | selector: 35 | app: bookshelf 36 | tier: frontend 37 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf-worker.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | # This file configures the bookshelf task worker. The worker is responsible 16 | # for processing book requests and updating book information. 17 | 18 | apiVersion: apps/v1 19 | kind: Deployment 20 | metadata: 21 | name: bookshelf-worker 22 | labels: 23 | app: bookshelf 24 | # The bookshelf worker replica set ensures that at least 2 instances of the 25 | # bookshelf worker pod are running on the cluster. 26 | # For more info about Pods see: 27 | # https://cloud.google.com/kubernetes-engine/docs/pods/ 28 | spec: 29 | replicas: 2 30 | selector: 31 | matchLabels: 32 | app: bookshelf 33 | tier: worker 34 | template: 35 | metadata: 36 | labels: 37 | app: bookshelf 38 | tier: worker 39 | spec: 40 | containers: 41 | - name: bookshelf-app 42 | # Replace [GCLOUD_PROJECT] with your project ID or use `make template`. 43 | image: gcr.io/[GCLOUD_PROJECT]/bookshelf 44 | # This setting makes nodes pull the docker image every time before 45 | # starting the pod. This is useful when debugging, but should be turned 46 | # off in production. 47 | imagePullPolicy: Always 48 | # The PROCESSES environment variable is used by Honcho in the 49 | # Dockerfile's CMD to control which processes are started. In this 50 | # case, only the worker process is needed. 51 | env: 52 | - name: PROCESSES 53 | value: worker 54 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | import json 16 | import logging 17 | 18 | from flask import current_app, Flask, redirect, request, session, url_for 19 | import httplib2 20 | from oauth2client.contrib.flask_util import UserOAuth2 21 | 22 | 23 | oauth2 = UserOAuth2() 24 | 25 | 26 | def create_app(config, debug=False, testing=False, config_overrides=None): 27 | app = Flask(__name__) 28 | app.config.from_object(config) 29 | 30 | app.debug = debug 31 | app.testing = testing 32 | 33 | if config_overrides: 34 | app.config.update(config_overrides) 35 | 36 | # Configure logging 37 | if not app.testing: 38 | logging.basicConfig(level=logging.INFO) 39 | 40 | # Setup the data model. 41 | with app.app_context(): 42 | model = get_model() 43 | model.init_app(app) 44 | 45 | # Create a health check handler. Health checks are used when running on 46 | # Google Compute Engine by the load balancer to determine which instances 47 | # can serve traffic. Google App Engine also uses health checking, but 48 | # accepts any non-500 response as healthy. 49 | @app.route('/_ah/health') 50 | def health_check(): 51 | return 'ok', 200 52 | 53 | # Initalize the OAuth2 helper. 54 | oauth2.init_app( 55 | app, 56 | scopes=['email', 'profile'], 57 | authorize_callback=_request_user_info) 58 | 59 | # Add a logout handler. 60 | @app.route('/logout') 61 | def logout(): 62 | # Delete the user's profile and the credentials stored by oauth2. 63 | del session['profile'] 64 | session.modified = True 65 | oauth2.storage.delete() 66 | return redirect(request.referrer or '/') 67 | 68 | # Register the Bookshelf CRUD blueprint. 69 | from .crud import crud 70 | app.register_blueprint(crud, url_prefix='/books') 71 | 72 | # Add a default root route. 73 | @app.route("/") 74 | def index(): 75 | return redirect(url_for('crud.list')) 76 | 77 | # Add an error handler. This is useful for debugging the live application, 78 | # however, you should disable the output of the exception for production 79 | # applications. 80 | @app.errorhandler(500) 81 | def server_error(e): 82 | return """ 83 | An internal error occurred:
{}
84 | See logs for full stacktrace. 85 | """.format(e), 500 86 | 87 | return app 88 | 89 | 90 | def get_model(): 91 | model_backend = current_app.config['DATA_BACKEND'] 92 | if model_backend == 'cloudsql': 93 | from . import model_cloudsql 94 | model = model_cloudsql 95 | elif model_backend == 'datastore': 96 | from . import model_datastore 97 | model = model_datastore 98 | elif model_backend == 'mongodb': 99 | from . import model_mongodb 100 | model = model_mongodb 101 | else: 102 | raise ValueError( 103 | "No appropriate databackend configured. " 104 | "Please specify datastore, cloudsql, or mongodb") 105 | 106 | return model 107 | 108 | 109 | def _request_user_info(credentials): 110 | """ 111 | Makes an HTTP request to the Google OAuth2 API to retrieve the user's basic 112 | profile information, including full name and photo, and stores it in the 113 | Flask session. 114 | """ 115 | http = httplib2.Http() 116 | credentials.authorize(http) 117 | resp, content = http.request( 118 | 'https://www.googleapis.com/oauth2/v3/userinfo') 119 | 120 | if resp.status != 200: 121 | current_app.logger.error( 122 | "Error while obtaining user profile: \n%s: %s", resp, content) 123 | return None 124 | 125 | session['profile'] = json.loads(content.decode('utf-8')) 126 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf/crud.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | from bookshelf import get_model, oauth2, storage, tasks 16 | from flask import Blueprint, current_app, redirect, render_template, request, \ 17 | session, url_for 18 | 19 | 20 | crud = Blueprint('crud', __name__) 21 | 22 | 23 | def upload_image_file(file): 24 | """ 25 | Upload the user-uploaded file to Google Cloud Storage and retrieve its 26 | publicly-accessible URL. 27 | """ 28 | if not file: 29 | return None 30 | 31 | public_url = storage.upload_file( 32 | file.read(), 33 | file.filename, 34 | file.content_type 35 | ) 36 | 37 | current_app.logger.info( 38 | "Uploaded file %s as %s.", file.filename, public_url) 39 | 40 | return public_url 41 | 42 | 43 | @crud.route("/") 44 | def list(): 45 | token = request.args.get('page_token', None) 46 | if token: 47 | token = token.encode('utf-8') 48 | 49 | books, next_page_token = get_model().list(cursor=token) 50 | 51 | return render_template( 52 | "list.html", 53 | books=books, 54 | next_page_token=next_page_token) 55 | 56 | 57 | @crud.route("/mine") 58 | @oauth2.required 59 | def list_mine(): 60 | token = request.args.get('page_token', None) 61 | if token: 62 | token = token.encode('utf-8') 63 | 64 | books, next_page_token = get_model().list_by_user( 65 | user_id=session['profile']['id'], 66 | cursor=token) 67 | 68 | return render_template( 69 | "list.html", 70 | books=books, 71 | next_page_token=next_page_token) 72 | 73 | 74 | @crud.route('/') 75 | def view(id): 76 | book = get_model().read(id) 77 | return render_template("view.html", book=book) 78 | 79 | 80 | @crud.route('/add', methods=['GET', 'POST']) 81 | def add(): 82 | if request.method == 'POST': 83 | data = request.form.to_dict(flat=True) 84 | 85 | # If an image was uploaded, update the data to point to the new image. 86 | image_url = upload_image_file(request.files.get('image')) 87 | 88 | if image_url: 89 | data['imageUrl'] = image_url 90 | 91 | # If the user is logged in, associate their profile with the new book. 92 | if 'profile' in session: 93 | data['createdBy'] = session['profile']['name'] 94 | data['createdById'] = session['profile']['email'] 95 | 96 | book = get_model().create(data) 97 | 98 | q = tasks.get_books_queue() 99 | q.enqueue(tasks.process_book, book['id']) 100 | 101 | return redirect(url_for('.view', id=book['id'])) 102 | 103 | return render_template("form.html", action="Add", book={}) 104 | 105 | 106 | @crud.route('//edit', methods=['GET', 'POST']) 107 | def edit(id): 108 | book = get_model().read(id) 109 | 110 | if request.method == 'POST': 111 | data = request.form.to_dict(flat=True) 112 | 113 | image_url = upload_image_file(request.files.get('image')) 114 | 115 | if image_url: 116 | data['imageUrl'] = image_url 117 | 118 | book = get_model().update(data, id) 119 | 120 | q = tasks.get_books_queue() 121 | q.enqueue(tasks.process_book, book['id']) 122 | 123 | return redirect(url_for('.view', id=book['id'])) 124 | 125 | return render_template("form.html", action="Edit", book=book) 126 | 127 | 128 | @crud.route('//delete') 129 | def delete(id): 130 | get_model().delete(id) 131 | return redirect(url_for('.list')) 132 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf/model_cloudsql.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | from flask import Flask 16 | from flask_sqlalchemy import SQLAlchemy 17 | 18 | 19 | builtin_list = list 20 | 21 | 22 | db = SQLAlchemy() 23 | 24 | 25 | def init_app(app): 26 | # Disable track modifications, as it unnecessarily uses memory. 27 | app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False) 28 | db.init_app(app) 29 | 30 | 31 | def from_sql(row): 32 | """Translates a SQLAlchemy model instance into a dictionary""" 33 | data = row.__dict__.copy() 34 | data['id'] = row.id 35 | data.pop('_sa_instance_state') 36 | return data 37 | 38 | 39 | class Book(db.Model): 40 | __tablename__ = 'books' 41 | 42 | id = db.Column(db.Integer, primary_key=True) 43 | title = db.Column(db.String(255)) 44 | author = db.Column(db.String(255)) 45 | publishedDate = db.Column(db.String(255)) 46 | imageUrl = db.Column(db.String(255)) 47 | description = db.Column(db.String(4096)) 48 | createdBy = db.Column(db.String(255)) 49 | createdById = db.Column(db.String(255)) 50 | 51 | def __repr__(self): 52 | return " 17 | 18 | 19 | Bookshelf - Python on Google Cloud Platform 20 | 21 | 22 | 23 | 24 | 25 | 35 |
36 | {% block content %}{% endblock %} 37 |
38 | {{user}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf/templates/form.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2015 Google Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | {% extends "base.html" %} 18 | 19 | {% block content %} 20 |

{{action}} book

21 | 22 |
23 | 24 |
25 | 26 | 27 |
28 | 29 |
30 | 31 | 32 |
33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 |
48 | 49 | 53 | 54 | 58 | 59 | 63 | 64 | 65 |
66 | 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf/templates/list.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2015 Google Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | {% extends "base.html" %} 18 | 19 | {% block content %} 20 | 21 |

Books

22 | 23 | 24 | Add book 25 | 26 | 27 | {% for book in books %} 28 | 43 | {% else %} 44 |

No books found

45 | {% endfor %} 46 | 47 | {% if next_page_token %} 48 | 53 | {% endif %} 54 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/bookshelf/templates/view.html: -------------------------------------------------------------------------------- 1 | {# 2 | # Copyright 2015 Google Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | #} 16 | 17 | {% extends "base.html" %} 18 | 19 | {% block content %} 20 | 21 |

Book

22 | 23 | 33 | 34 |
35 |
36 | {% if book.imageUrl %} 37 | 38 | {% else %} 39 | 40 | {% endif %} 41 |
42 |
43 |

44 | {{book.title}} 45 | {{book.publishedDate}} 46 |

47 |
By {{book.author|default('Unknown', True)}}
48 |

{{book.description}}

49 | Added by {{book.createdBy|default('Anonymous', True)}} 50 |
51 |
52 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | """ 16 | This file contains all of the configuration values for the application. 17 | Update this file with the values for your specific Google Cloud project. 18 | You can create and manage projects at https://console.developers.google.com 19 | """ 20 | 21 | import os 22 | 23 | # The secret key is used by Flask to encrypt session cookies. 24 | SECRET_KEY = 'secret' 25 | 26 | # There are three different ways to store the data in the application. 27 | # You can choose 'datastore', 'cloudsql', or 'mongodb'. Be sure to 28 | # configure the respective settings for the one you choose below. 29 | # You do not have to configure the other data backends. If unsure, choose 30 | # 'datastore' as it does not require any additional configuration. 31 | DATA_BACKEND = 'datastore' 32 | 33 | # Google Cloud Project ID. This can be found on the 'Overview' page at 34 | # https://console.developers.google.com 35 | PROJECT_ID = 'your-project-id' 36 | 37 | # CloudSQL & SQLAlchemy configuration 38 | # Replace the following values the respective values of your Cloud SQL 39 | # instance. 40 | CLOUDSQL_USER = 'root' 41 | CLOUDSQL_PASSWORD = 'your-cloudsql-password' 42 | CLOUDSQL_DATABASE = 'bookshelf' 43 | # Set this value to the Cloud SQL connection name, e.g. 44 | # "project:region:cloudsql-instance". 45 | # You must also update the value in app.yaml. 46 | CLOUDSQL_CONNECTION_NAME = 'your-cloudsql-connection-name' 47 | 48 | # The CloudSQL proxy is used locally to connect to the cloudsql instance. 49 | # To start the proxy, use: 50 | # 51 | # $ cloud_sql_proxy -instances=your-connection-name=tcp:3306 52 | # 53 | # Alternatively, you could use a local MySQL instance for testing. 54 | LOCAL_SQLALCHEMY_DATABASE_URI = ( 55 | 'mysql+pymysql://{user}:{password}@localhost/{database}').format( 56 | user=CLOUDSQL_USER, password=CLOUDSQL_PASSWORD, 57 | database=CLOUDSQL_DATABASE) 58 | 59 | # When running on App Engine a unix socket is used to connect to the cloudsql 60 | # instance. 61 | LIVE_SQLALCHEMY_DATABASE_URI = ( 62 | 'mysql+pymysql://{user}:{password}@localhost/{database}' 63 | '?unix_socket=/cloudsql/{connection_name}').format( 64 | user=CLOUDSQL_USER, password=CLOUDSQL_PASSWORD, 65 | database=CLOUDSQL_DATABASE, connection_name=CLOUDSQL_CONNECTION_NAME) 66 | 67 | if os.environ.get('GAE_INSTANCE'): 68 | SQLALCHEMY_DATABASE_URI = LIVE_SQLALCHEMY_DATABASE_URI 69 | else: 70 | SQLALCHEMY_DATABASE_URI = LOCAL_SQLALCHEMY_DATABASE_URI 71 | 72 | # Mongo configuration 73 | # If using mongolab, the connection URI is available from the mongolab control 74 | # panel. If self-hosting on compute engine, replace the values below. 75 | MONGO_URI = 'mongodb://user:password@host:27017/database' 76 | 77 | # Google Cloud Storage and upload settings. 78 | # Typically, you'll name your bucket the same as your project. To create a 79 | # bucket: 80 | # 81 | # $ gsutil mb gs:// 82 | # 83 | # You also need to make sure that the default ACL is set to public-read, 84 | # otherwise users will not be able to see their upload images: 85 | # 86 | # $ gsutil defacl set public-read gs:// 87 | # 88 | # You can adjust the max content length and allow extensions settings to allow 89 | # larger or more varied file types if desired. 90 | CLOUD_STORAGE_BUCKET = 'your-bucket-name' 91 | MAX_CONTENT_LENGTH = 8 * 1024 * 1024 92 | ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif']) 93 | 94 | # OAuth2 configuration. 95 | # This can be generated from the Google Developers Console at 96 | # https://console.developers.google.com/project/_/apiui/credential. 97 | # Note that you will need to add all URLs that your application uses as 98 | # authorized redirect URIs. For example, typically you would add the following: 99 | # 100 | # * http://localhost:8080/oauth2callback 101 | # * https://.appspot.com/oauth2callback. 102 | # 103 | # If you receive a invalid redirect URI error review you settings to ensure 104 | # that the current URI is allowed. 105 | GOOGLE_OAUTH2_CLIENT_ID = \ 106 | 'your-client-id' 107 | GOOGLE_OAUTH2_CLIENT_SECRET = 'your-client-secret' 108 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | import bookshelf 16 | import config 17 | 18 | 19 | # Note: debug=True is enabled here to help with troubleshooting. You should 20 | # remove this in production. 21 | app = bookshelf.create_app(config, debug=True) 22 | 23 | 24 | # Make the queue available at the top-level, this allows you to run 25 | # `psqworker main.books_queue`. We have to use the app's context because 26 | # it contains all the configuration for plugins. 27 | # If you were using another task queue, such as celery or rq, you can use this 28 | # section to configure your queues to work with Flask. 29 | with app.app_context(): 30 | books_queue = bookshelf.tasks.get_books_queue() 31 | 32 | 33 | # This is only used when running locally. When running live, gunicorn runs 34 | # the application. 35 | if __name__ == '__main__': 36 | app.run(host='127.0.0.1', port=8080, debug=True) 37 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/procfile: -------------------------------------------------------------------------------- 1 | bookshelf: gunicorn -b 0.0.0.0:$PORT main:app 2 | worker: psqworker main.books_queue 3 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox==3.5.3 2 | flake8==3.6.0 3 | flaky==3.4.0 4 | pytest==4.0.1 5 | pytest-cov==2.6.0 6 | BeautifulSoup4==4.6.3 7 | requests==2.20.1 8 | retrying==1.3.3 9 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0.4 2 | google-cloud-datastore==1.7.1 3 | google-cloud-storage==1.23.0 4 | google-cloud-logging==1.8.0 5 | google-cloud-error_reporting==0.30.0 6 | gunicorn==19.9.0 7 | oauth2client==4.1.3 8 | mock==2.0.0 9 | Flask-SQLAlchemy==2.3.2 10 | PyMySQL==0.9.2 11 | Flask-PyMongo==2.3.0 12 | PyMongo==3.7.2 13 | six==1.11.0 14 | requests[security]==2.21.0 15 | honcho==1.0.1 16 | psq==0.7.0 17 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | """conftest.py is used to define common test fixtures for pytest.""" 16 | 17 | import bookshelf 18 | import config 19 | from google.cloud.exceptions import ServiceUnavailable 20 | from oauth2client.client import HttpAccessTokenRefreshError 21 | import pytest 22 | from retrying import retry 23 | 24 | 25 | @pytest.fixture(params=['datastore', 'mongodb']) 26 | def app(request): 27 | """This fixtures provides a Flask app instance configured for testing. 28 | 29 | Because it's parametric, it will cause every test that uses this fixture 30 | to run three times: one time for each backend (datastore, cloudsql, and 31 | mongodb). 32 | 33 | It also ensures the tests run within a request context, allowing 34 | any calls to flask.request, flask.current_app, etc. to work.""" 35 | app = bookshelf.create_app( 36 | config, 37 | testing=True, 38 | config_overrides={ 39 | 'DATA_BACKEND': request.param 40 | }) 41 | 42 | with app.test_request_context(): 43 | yield app 44 | 45 | 46 | @pytest.fixture 47 | def model(monkeypatch, app): 48 | """This fixture provides a modified version of the app's model that tracks 49 | all created items and deletes them at the end of the test. 50 | 51 | Any tests that directly or indirectly interact with the database should use 52 | this to ensure that resources are properly cleaned up. 53 | 54 | Monkeypatch is provided by pytest and used to patch the model's create 55 | method. 56 | 57 | The app fixture is needed to provide the configuration and context needed 58 | to get the proper model object. 59 | """ 60 | model = bookshelf.get_model() 61 | 62 | # Ensure no books exist before running. This typically helps if tests 63 | # somehow left the database in a bad state. 64 | delete_all_books(model) 65 | 66 | yield model 67 | 68 | # Delete all books that we created during tests. 69 | delete_all_books(model) 70 | 71 | 72 | # The backend data stores can sometimes be flaky. It's useful to retry this 73 | # a few times before giving up. 74 | @retry( 75 | stop_max_attempt_number=3, 76 | wait_exponential_multiplier=100, 77 | wait_exponential_max=2000) 78 | def delete_all_books(model): 79 | while True: 80 | books, _ = model.list(limit=50) 81 | if not books: 82 | break 83 | for book in books: 84 | model.delete(book['id']) 85 | 86 | 87 | def flaky_filter(info, *args): 88 | """Used by flaky to determine when to re-run a test case.""" 89 | _, e, _ = info 90 | return isinstance(e, (ServiceUnavailable, HttpAccessTokenRefreshError)) 91 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | import contextlib 16 | 17 | import bookshelf 18 | from conftest import flaky_filter 19 | from flaky import flaky 20 | import mock 21 | from oauth2client.client import OAuth2Credentials 22 | import pytest 23 | 24 | 25 | @pytest.fixture 26 | def client_with_credentials(app): 27 | """This fixture provides a Flask app test client that has a session 28 | pre-configured with use credentials.""" 29 | credentials = OAuth2Credentials( 30 | 'access_token', 31 | 'client_id', 32 | 'client_secret', 33 | 'refresh_token', 34 | '3600', 35 | None, 36 | 'Test', 37 | id_token={'sub': '123', 'email': 'user@example.com'}, 38 | scopes=('email', 'profile')) 39 | 40 | @contextlib.contextmanager 41 | def inner(): 42 | with app.test_client() as client: 43 | with client.session_transaction() as session: 44 | session['profile'] = { 45 | 'email': 'abc@example.com', 46 | 'name': 'Test User' 47 | } 48 | session['google_oauth2_credentials'] = credentials.to_json() 49 | yield client 50 | 51 | return inner 52 | 53 | 54 | # Mark all test cases in this class as flaky, so that if errors occur they 55 | # can be retried. This is useful when databases are temporarily unavailable. 56 | @flaky(rerun_filter=flaky_filter) 57 | # Tell pytest to use both the app and model fixtures for all test cases. 58 | # This ensures that configuration is properly applied and that all database 59 | # resources created during tests are cleaned up. These fixtures are defined 60 | # in conftest.py 61 | @pytest.mark.usefixtures('app', 'model') 62 | class TestAuth(object): 63 | def test_not_logged_in(self, app): 64 | with app.test_client() as c: 65 | rv = c.get('/books/') 66 | 67 | assert rv.status == '200 OK' 68 | body = rv.data.decode('utf-8') 69 | assert 'Login' in body 70 | 71 | def test_logged_in(self, client_with_credentials): 72 | with client_with_credentials() as c: 73 | rv = c.get('/books/') 74 | 75 | assert rv.status == '200 OK' 76 | body = rv.data.decode('utf-8') 77 | assert 'Test User' in body 78 | 79 | def test_add_anonymous(self, app): 80 | data = { 81 | 'title': 'Test Book', 82 | } 83 | 84 | with app.test_client() as c: 85 | rv = c.post('/books/add', data=data, follow_redirects=True) 86 | 87 | assert rv.status == '200 OK' 88 | body = rv.data.decode('utf-8') 89 | assert 'Test Book' in body 90 | assert 'Added by Anonymous' in body 91 | 92 | def test_add_logged_in(self, client_with_credentials): 93 | data = { 94 | 'title': 'Test Book', 95 | } 96 | 97 | with client_with_credentials() as c: 98 | rv = c.post('/books/add', data=data, follow_redirects=True) 99 | 100 | assert rv.status == '200 OK' 101 | body = rv.data.decode('utf-8') 102 | assert 'Test Book' in body 103 | assert 'Added by Test User' in body 104 | 105 | def test_mine(self, model, client_with_credentials): 106 | # Create two books, one created by the logged in user and one 107 | # created by another user. 108 | model.create({ 109 | 'title': 'Book 1', 110 | 'createdById': 'abc@example.com' 111 | }) 112 | 113 | model.create({ 114 | 'title': 'Book 2', 115 | 'createdById': 'def@example.com' 116 | }) 117 | 118 | # Check the "My Books" page and make sure only one of the books 119 | # appears. 120 | with client_with_credentials() as c: 121 | rv = c.get('/books/mine') 122 | 123 | assert rv.status == '200 OK' 124 | body = rv.data.decode('utf-8') 125 | assert 'Book 1' in body 126 | assert 'Book 2' not in body 127 | 128 | @mock.patch("httplib2.Http") 129 | def test_request_user_info(self, HttpMock): 130 | httpObj = mock.MagicMock() 131 | responseMock = mock.MagicMock(status=200) 132 | httpObj.request = mock.MagicMock( 133 | return_value=(responseMock, b'{"name": "bill"}')) 134 | HttpMock.return_value = httpObj 135 | credentials = mock.MagicMock() 136 | bookshelf._request_user_info(credentials) 137 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/tests/test_crud.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | import re 16 | 17 | from conftest import flaky_filter 18 | from flaky import flaky 19 | import pytest 20 | 21 | 22 | # Mark all test cases in this class as flaky, so that if errors occur they 23 | # can be retried. This is useful when databases are temporarily unavailable. 24 | @flaky(rerun_filter=flaky_filter) 25 | # Tell pytest to use both the app and model fixtures for all test cases. 26 | # This ensures that configuration is properly applied and that all database 27 | # resources created during tests are cleaned up. These fixtures are defined 28 | # in conftest.py 29 | @pytest.mark.usefixtures('app', 'model') 30 | class TestCrudActions(object): 31 | 32 | def test_list(self, app, model): 33 | for i in range(1, 12): 34 | model.create({'title': u'Book {0}'.format(i)}) 35 | 36 | with app.test_client() as c: 37 | rv = c.get('/books/') 38 | 39 | assert rv.status == '200 OK' 40 | 41 | body = rv.data.decode('utf-8') 42 | assert 'Book 1' in body, "Should show books" 43 | assert len(re.findall('

Book', body)) == 10, ( 44 | "Should not show more than 10 books") 45 | assert 'More' in body, "Should have more than one page" 46 | 47 | def test_add(self, app): 48 | data = { 49 | 'title': 'Test Book', 50 | 'author': 'Test Author', 51 | 'publishedDate': 'Test Date Published', 52 | 'description': 'Test Description' 53 | } 54 | 55 | with app.test_client() as c: 56 | rv = c.post('/books/add', data=data, follow_redirects=True) 57 | 58 | assert rv.status == '200 OK' 59 | body = rv.data.decode('utf-8') 60 | assert 'Test Book' in body 61 | assert 'Test Author' in body 62 | assert 'Test Date Published' in body 63 | assert 'Test Description' in body 64 | 65 | def test_edit(self, app, model): 66 | existing = model.create({'title': "Temp Title"}) 67 | 68 | with app.test_client() as c: 69 | rv = c.post( 70 | '/books/%s/edit' % existing['id'], 71 | data={'title': 'Updated Title'}, 72 | follow_redirects=True) 73 | 74 | assert rv.status == '200 OK' 75 | body = rv.data.decode('utf-8') 76 | assert 'Updated Title' in body 77 | assert 'Temp Title' not in body 78 | 79 | def test_delete(self, app, model): 80 | existing = model.create({'title': "Temp Title"}) 81 | 82 | with app.test_client() as c: 83 | rv = c.get( 84 | '/books/%s/delete' % existing['id'], 85 | follow_redirects=True) 86 | 87 | assert rv.status == '200 OK' 88 | assert not model.read(existing['id']) 89 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/tests/test_end_to_end.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | import os 16 | import re 17 | 18 | from bs4 import BeautifulSoup 19 | import pytest 20 | import requests 21 | from retrying import retry 22 | 23 | 24 | @pytest.mark.e2e 25 | def test_end_to_end(): 26 | """Tests designed to be run against live environments. 27 | 28 | Unlike the integration tests in the other packages, these tests are 29 | designed to be run against fully-functional live environments. 30 | 31 | To run locally, start both main.py and psq_worker main.books_queue and 32 | run this file. 33 | 34 | It can be run against a live environment by setting the E2E_URL 35 | environment variables before running the tests: 36 | 37 | E2E_URL=http://your-app-id.appspot.com \ 38 | nosetests tests/test_end_to_end.py 39 | """ 40 | 41 | base_url = os.environ.get('E2E_URL', 'http://localhost:8080') 42 | 43 | book_data = { 44 | 'title': 'a confederacy of dunces', 45 | } 46 | 47 | response = requests.post(base_url + '/books/add', data=book_data) 48 | 49 | # There was a 302, so get the book's URL from the redirect. 50 | book_url = response.request.url 51 | book_id = book_url.rsplit('/', 1).pop() 52 | 53 | # Use retry because it will take some indeterminate time for the pub/sub 54 | # message to be processed. 55 | @retry(wait_exponential_multiplier=5000, stop_max_attempt_number=12) 56 | def check_for_updated_data(): 57 | # Check that the book's information was updated. 58 | response = requests.get(book_url) 59 | assert response.status_code == 200 60 | 61 | soup = BeautifulSoup(response.text, 'html.parser') 62 | 63 | title = soup.find('h4', 'book-title').contents[0].strip() 64 | assert re.search(r'A Confederacy of Dunces', title, re.I) 65 | 66 | author = soup.find('h5', 'book-author').string 67 | assert re.search(r'John Kennedy Toole', author, re.I) 68 | 69 | description = soup.find('p', 'book-description').string 70 | assert re.search(r'Ignatius', description, re.I) 71 | 72 | image_src = soup.find('img', 'book-image')['src'] 73 | image = requests.get(image_src) 74 | assert image.status_code == 200 75 | 76 | try: 77 | check_for_updated_data() 78 | finally: 79 | # Delete the book we created. 80 | requests.get(base_url + '/books/{}/delete'.format(book_id)) 81 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/tests/test_storage.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Google Inc. 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 | import re 16 | 17 | from conftest import flaky_filter 18 | from flaky import flaky 19 | import httplib2 20 | import pytest 21 | from six import BytesIO 22 | 23 | 24 | # Mark all test cases in this class as flaky, so that if errors occur they 25 | # can be retried. This is useful when databases are temporarily unavailable. 26 | @flaky(rerun_filter=flaky_filter) 27 | # Tell pytest to use both the app and model fixtures for all test cases. 28 | # This ensures that configuration is properly applied and that all database 29 | # resources created during tests are cleaned up. These fixtures are defined 30 | # in conftest.py 31 | @pytest.mark.usefixtures('app', 'model') 32 | class TestStorage(object): 33 | 34 | def test_upload_image(self, app): 35 | data = { 36 | 'title': 'Test Book', 37 | 'author': 'Test Author', 38 | 'publishedDate': 'Test Date Published', 39 | 'description': 'Test Description', 40 | 'image': (BytesIO(b'hello world'), 'hello.jpg') 41 | } 42 | 43 | with app.test_client() as c: 44 | rv = c.post('/books/add', data=data, follow_redirects=True) 45 | 46 | assert rv.status == '200 OK' 47 | body = rv.data.decode('utf-8') 48 | 49 | img_tag = re.search(''), 63 | '1337h4x0r.php') 64 | } 65 | 66 | with app.test_client() as c: 67 | rv = c.post('/books/add', data=data, follow_redirects=True) 68 | 69 | # check we weren't pwned 70 | assert rv.status == '400 BAD REQUEST' 71 | -------------------------------------------------------------------------------- /optional-kubernetes-engine/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = lint,py27,py36 4 | 5 | [testenv] 6 | deps = 7 | -rrequirements.txt 8 | -rrequirements-dev.txt 9 | commands = 10 | py.test --cov=bookshelf --no-success-flaky-report -m "not e2e" {posargs: tests} 11 | passenv = GOOGLE_APPLICATION_CREDENTIALS DATASTORE_HOST E2E_URL 12 | setenv = PYTHONPATH={toxinidir} 13 | 14 | 15 | [testenv:py27-e2e] 16 | basepython = python2.7 17 | commands = 18 | py.test --no-success-flaky-report -m "e2e" {posargs: tests} 19 | 20 | [testenv:py36-e2e] 21 | basepython = python3.6 22 | commands = 23 | py.test --no-success-flaky-report -m "e2e" {posargs: tests} 24 | 25 | [testenv:lint] 26 | deps = 27 | flake8 28 | flake8-import-order 29 | commands = 30 | flake8 --import-order-style=google bookshelf tests 31 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | -v 4 | --tb=native 5 | norecursedirs = .git env lib .tox .nox 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flake8===5.0.4; python_version < '3.8' 2 | flake8==6.0.0; python_version >= '3.8' 3 | pytest==7.3.1 4 | nox==2023.4.22 5 | requests==2.31.0 6 | -------------------------------------------------------------------------------- /secrets.tar.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GoogleCloudPlatform/getting-started-python/d9da8db203f7729f5da28b57be66b69084955bde/secrets.tar.enc -------------------------------------------------------------------------------- /sessions/app.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 getting_started_sessions_runtime] 16 | runtime: python37 17 | # [END getting_started_sessions_runtime] 18 | -------------------------------------------------------------------------------- /sessions/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 getting_started_sessions_all] 16 | import random 17 | from uuid import uuid4 18 | 19 | from flask import Flask, make_response, request 20 | from google.cloud import firestore 21 | 22 | 23 | app = Flask(__name__) 24 | db = firestore.Client() 25 | sessions = db.collection('sessions') 26 | greetings = [ 27 | 'Hello World', 28 | 'Hallo Welt', 29 | 'Ciao Mondo', 30 | 'Salut le Monde', 31 | 'Hola Mundo', 32 | ] 33 | 34 | 35 | @firestore.transactional 36 | def get_session_data(transaction, session_id): 37 | """ Looks up (or creates) the session with the given session_id. 38 | Creates a random session_id if none is provided. Increments 39 | the number of views in this session. Updates are done in a 40 | transaction to make sure no saved increments are overwritten. 41 | """ 42 | if session_id is None: 43 | session_id = str(uuid4()) # Random, unique identifier 44 | 45 | doc_ref = sessions.document(document_id=session_id) 46 | doc = doc_ref.get(transaction=transaction) 47 | if doc.exists: 48 | session = doc.to_dict() 49 | else: 50 | session = { 51 | 'greeting': random.choice(greetings), 52 | 'views': 0 53 | } 54 | 55 | session['views'] += 1 # This counts as a view 56 | transaction.set(doc_ref, session) 57 | 58 | session['session_id'] = session_id 59 | return session 60 | 61 | 62 | @app.route('/', methods=['GET']) 63 | def home(): 64 | template = '{} views for "{}"' 65 | 66 | transaction = db.transaction() 67 | session = get_session_data(transaction, request.cookies.get('session_id')) 68 | 69 | resp = make_response(template.format( 70 | session['views'], 71 | session['greeting'] 72 | ) 73 | ) 74 | resp.set_cookie('session_id', session['session_id'], httponly=True) 75 | return resp 76 | 77 | 78 | if __name__ == '__main__': 79 | app.run(host='127.0.0.1', port=8080) 80 | # [END getting_started_sessions_all] 81 | -------------------------------------------------------------------------------- /sessions/main_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC All Rights Reserved. 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 | import re 16 | import uuid 17 | 18 | import main 19 | import pytest 20 | 21 | 22 | @pytest.fixture 23 | def client(): 24 | """ Yields a test client, AND creates and later cleans up a 25 | dummy collection for sessions. 26 | """ 27 | main.app.testing = True 28 | 29 | # Override the Firestore collection used for sessions in main 30 | main.sessions = main.db.collection(str(uuid.uuid4())) 31 | 32 | client = main.app.test_client() 33 | yield client 34 | 35 | # Clean up session objects created in test collection 36 | for doc_ref in main.sessions.list_documents(): 37 | doc_ref.delete() 38 | 39 | 40 | def test_session(client): 41 | r = client.get('/') 42 | assert r.status_code == 200 43 | data = r.data.decode('utf-8') 44 | assert '1 views' in data 45 | 46 | match = re.search('views for "([A-Za-z ]+)"', data) 47 | assert match is not None 48 | greeting = match.group(1) 49 | 50 | r = client.get('/') 51 | assert r.status_code == 200 52 | data = r.data.decode('utf-8') 53 | assert '2 views' in data 54 | assert greeting in data 55 | -------------------------------------------------------------------------------- /sessions/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=5.0.0 2 | -------------------------------------------------------------------------------- /sessions/requirements.txt: -------------------------------------------------------------------------------- 1 | google-cloud-firestore==2.11.1 2 | flask==2.2.5 3 | --------------------------------------------------------------------------------