diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c6c8778e0c49..d901a4f77627 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -54,6 +54,7 @@ /profiler/**/*.py @kalyanac @GoogleCloudPlatform/python-samples-owners /pubsub/**/*.py @anguillanneuf @hongalex @GoogleCloudPlatform/python-samples-owners /run/**/*.py @averikitsch @grant @GoogleCloudPlatform/python-samples-owners +/run/django/**/*.py @glasnt @GoogleCloudPlatform/python-samples-owners /scheduler/**/*.py @averikitsch @GoogleCloudPlatform/python-samples-owners /spanner/**/*.py @larkee @GoogleCloudPlatform/python-samples-owners /speech/**/*.py @telpirion @sirtorry @GoogleCloudPlatform/python-samples-owners diff --git a/noxfile-template.py b/noxfile-template.py index e7ad325db370..9c28d5a0050e 100644 --- a/noxfile-template.py +++ b/noxfile-template.py @@ -17,6 +17,7 @@ import os from pathlib import Path import sys +from typing import Callable, Dict, List, Optional import nox @@ -68,7 +69,7 @@ TEST_CONFIG.update(TEST_CONFIG_OVERRIDE) -def get_pytest_env_vars(): +def get_pytest_env_vars() -> Dict[str, str]: """Returns a dict for pytest invocation.""" ret = {} @@ -98,7 +99,7 @@ def get_pytest_env_vars(): # -def _determine_local_import_names(start_dir): +def _determine_local_import_names(start_dir: str) -> List[str]: """Determines all import names that should be considered "local". This is used when running the linter to insure that import order is @@ -136,7 +137,7 @@ def _determine_local_import_names(start_dir): @nox.session -def lint(session): +def lint(session: nox.sessions.Session) -> None: if not TEST_CONFIG['enforce_type_hints']: session.install("flake8", "flake8-import-order") else: @@ -156,7 +157,7 @@ def lint(session): # @nox.session -def blacken(session): +def blacken(session: nox.sessions.Session) -> None: session.install("black") python_files = [path for path in os.listdir(".") if path.endswith(".py")] @@ -171,7 +172,7 @@ def blacken(session): PYTEST_COMMON_ARGS = ["--junitxml=sponge_log.xml"] -def _session_tests(session, post_install=None): +def _session_tests(session: nox.sessions.Session, post_install: Callable = None) -> None: """Runs py.test for a particular project.""" if os.path.exists("requirements.txt"): session.install("-r", "requirements.txt") @@ -197,7 +198,7 @@ def _session_tests(session, post_install=None): @nox.session(python=ALL_VERSIONS) -def py(session): +def py(session: nox.sessions.Session) -> None: """Runs py.test for a sample using the specified version of Python.""" if session.python in TESTED_VERSIONS: _session_tests(session) @@ -212,9 +213,10 @@ def py(session): # -def _get_repo_root(): +def _get_repo_root() -> Optional[str]: """ Returns the root folder of the project. """ - # Get root of this repository. Assume we don't have directories nested deeper than 10 items. + # Get root of this repository. + # Assume we don't have directories nested deeper than 10 items. p = Path(os.getcwd()) for i in range(10): if p is None: @@ -230,7 +232,7 @@ def _get_repo_root(): @nox.session @nox.parametrize("path", GENERATED_READMES) -def readmegen(session, path): +def readmegen(session: nox.sessions.Session, path: str) -> None: """(Re-)generates the readme for a sample.""" session.install("jinja2", "pyyaml") dir_ = os.path.dirname(path) diff --git a/run/README.md b/run/README.md index c3b24af5b1ce..4b6bb5223f72 100644 --- a/run/README.md +++ b/run/README.md @@ -15,6 +15,7 @@ This directory contains samples for [Google Cloud Run](https://cloud.run). [Clou |[Cloud Pub/Sub][pubsub] | Handling Pub/Sub push messages | [Run on Google Cloud][run_button_pubsub] | |[Cloud SQL (MySQL)][mysql] | Use MySQL with Cloud Run | - | |[Cloud SQL (Postgres)][postgres] | Use Postgres with Cloud Run | - | +|[Django][django] | Deploy Django on Cloud Run | - | For more Cloud Run samples beyond Python, see the main list in the [Cloud Run Samples repository](https://github.com/GoogleCloudPlatform/cloud-run-samples). @@ -109,6 +110,7 @@ for more information. [pubsub]: pubsub/ [mysql]: ../cloud-sql/mysql/sqlalchemy [postgres]: ../cloud-sql/postgres/sqlalchemy +[django]: django/ [run_button_helloworld]: https://deploy.cloud.run/?git_repo=https://github.com/knative/docs&dir=docs/serving/samples/hello-world/helloworld-python [run_button_pubsub]: https://deploy.cloud.run/?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&dir=run/pubsub [testing]: https://cloud.google.com/run/docs/testing/local#running_locally_using_docker_with_access_to_services diff --git a/run/django/.gcloudignore b/run/django/.gcloudignore new file mode 100644 index 000000000000..4c49bd78f1d0 --- /dev/null +++ b/run/django/.gcloudignore @@ -0,0 +1 @@ +.env diff --git a/run/django/Dockerfile b/run/django/Dockerfile new file mode 100644 index 000000000000..f85c7118c3a6 --- /dev/null +++ b/run/django/Dockerfile @@ -0,0 +1,36 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Use an official lightweight Python image. +# https://hub.docker.com/_/python +FROM python:3.8-slim + +ENV APP_HOME /app +WORKDIR $APP_HOME + +# Removes output stream buffering, allowing for more efficient logging +ENV PYTHONUNBUFFERED 1 + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy local code to the container image. +COPY . . + +# Run the web service on container startup. Here we use the gunicorn +# webserver, with one worker process and 8 threads. +# For environments with multiple CPU cores, increase the number of workers +# to be equal to the cores available. +CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 mysite.wsgi:application diff --git a/run/django/README.md b/run/django/README.md new file mode 100644 index 000000000000..564991ee8a4b --- /dev/null +++ b/run/django/README.md @@ -0,0 +1,15 @@ +# Getting started with Django on Cloud Run + +[![Open in Cloud Shell][shell_img]][shell_link] + +[shell_img]: http://gstatic.com/cloudssh/images/open-btn.png +[shell_link]: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&page=editor&open_in_editor=run/django/README.md + +This repository is an example of how to run a [Django](https://www.djangoproject.com/) +app on Google Cloud Run (fully managed). + +The Django application used in this tutorial is the [Writing your first Django app](https://docs.djangoproject.com/en/3.0/#first-steps), after completing [Part 1](https://docs.djangoproject.com/en/3.0/intro/tutorial01/) and [Part 2](https://docs.djangoproject.com/en/3.0/intro/tutorial02/). + + +# Tutorial +See our [Running Django on Cloud Run (fully managed)](https://cloud.google.com/python/django/run) tutorial for instructions for setting up and deploying this sample application. \ No newline at end of file diff --git a/run/django/cloudmigrate.yaml b/run/django/cloudmigrate.yaml new file mode 100644 index 000000000000..bef3f789a329 --- /dev/null +++ b/run/django/cloudmigrate.yaml @@ -0,0 +1,64 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +steps: + - id: "build image" + name: "gcr.io/cloud-builders/docker" + args: ["build", "-t", "gcr.io/${PROJECT_ID}/${_SERVICE_NAME}", "."] + + - id: "push image" + name: "gcr.io/cloud-builders/docker" + args: ["push", "gcr.io/${PROJECT_ID}/${_SERVICE_NAME}"] + + - id: "apply migrations" + name: "gcr.io/google-appengine/exec-wrapper" + args: + [ + "-i", + "gcr.io/$PROJECT_ID/${_SERVICE_NAME}", + "-s", + "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", + "-e", + "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", + "--", + "python", + "manage.py", + "migrate", + ] + + - id: "collect static" + name: "gcr.io/google-appengine/exec-wrapper" + args: + [ + "-i", + "gcr.io/$PROJECT_ID/${_SERVICE_NAME}", + "-s", + "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", + "-e", + "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", + "--", + "python", + "manage.py", + "collectstatic", + "--no-input", + ] + +substitutions: + _INSTANCE_NAME: django-instance + _REGION: us-central1 + _SERVICE_NAME: polls-service + _SECRET_SETTINGS_NAME: django-settings + +images: + - "gcr.io/${PROJECT_ID}/${_SERVICE_NAME}" diff --git a/run/django/e2e_test.py b/run/django/e2e_test.py new file mode 100644 index 000000000000..9a161d5a1180 --- /dev/null +++ b/run/django/e2e_test.py @@ -0,0 +1,406 @@ +# Copyright 2020 Google, LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This test creates a Cloud SQL instance, a Cloud Storage bucket, associated +# secrets, and deploys a Django service + +import os +import subprocess +from typing import Iterator, List, Tuple +import uuid + +from google.cloud import secretmanager_v1 as sm +import pytest +import requests + +# Unique suffix to create distinct service names +SUFFIX = uuid.uuid4().hex[:10] + +PROJECT = os.environ["GOOGLE_CLOUD_PROJECT"] +REGION = "us-central1" +POSTGRES_INSTANCE = os.environ["POSTGRES_INSTANCE"] + +# Most commands in this test require the short instance form +if ":" in POSTGRES_INSTANCE: + POSTGRES_INSTANCE = POSTGRES_INSTANCE.split(":")[-1] + +CLOUD_STORAGE_BUCKET = f"{PROJECT}-media-{SUFFIX}" + +POSTGRES_DATABASE = f"polls-{SUFFIX}" +POSTGRES_USER = f"django-{SUFFIX}" +POSTGRES_PASSWORD = uuid.uuid4().hex[:26] + +ADMIN_NAME = "admin" +ADMIN_PASSWORD = uuid.uuid4().hex[:26] + +SECRET_SETTINGS_NAME = f"django_settings-{SUFFIX}" +SECRET_PASSWORD_NAME = f"superuser_password-{SUFFIX}" + + +@pytest.fixture +def project_number() -> Iterator[str]: + projectnum = ( + subprocess.run( + [ + "gcloud", + "projects", + "list", + "--filter", + f"name={PROJECT}", + "--format", + "value(projectNumber)", + ], + stdout=subprocess.PIPE, + check=True, + ) + .stdout.strip() + .decode() + ) + yield projectnum + + +@pytest.fixture +def postgres_host() -> Iterator[str]: + # Create database + subprocess.run( + [ + "gcloud", + "sql", + "databases", + "create", + POSTGRES_DATABASE, + "--instance", + POSTGRES_INSTANCE, + "--project", + PROJECT, + ], + check=True, + ) + # Create User + # NOTE Creating a user via the tutorial method is not automatable. + subprocess.run( + [ + "gcloud", + "sql", + "users", + "create", + POSTGRES_USER, + "--password", + POSTGRES_PASSWORD, + "--instance", + POSTGRES_INSTANCE, + "--project", + PROJECT, + ], + check=True, + ) + yield POSTGRES_INSTANCE + + subprocess.run( + [ + "gcloud", + "sql", + "databases", + "delete", + POSTGRES_DATABASE, + "--instance", + POSTGRES_INSTANCE, + "--project", + PROJECT, + "--quiet", + ], + check=True, + ) + + subprocess.run( + [ + "gcloud", + "sql", + "users", + "delete", + POSTGRES_USER, + "--instance", + POSTGRES_INSTANCE, + "--project", + PROJECT, + "--quiet", + ], + check=True, + ) + + +@pytest.fixture +def media_bucket() -> Iterator[str]: + # Create storage bucket + subprocess.run( + ["gsutil", "mb", "-l", REGION, "-p", PROJECT, f"gs://{CLOUD_STORAGE_BUCKET}"], + check=True, + ) + + yield CLOUD_STORAGE_BUCKET + + # Recursively delete assets and bucket (does not take a -p flag, apparently) + subprocess.run( + ["gsutil", "-m", "rm", "-r", f"gs://{CLOUD_STORAGE_BUCKET}"], + check=True, + ) + + +@pytest.fixture +def secrets(project_number: str) -> Iterator[str]: + # Create a number of secrets and allow Google Cloud services access to them + + def create_secret(name: str, value: str) -> None: + secret = client.create_secret( + request={ + "parent": f"projects/{PROJECT}", + "secret": {"replication": {"automatic": {}}}, + "secret_id": name, + } + ) + + client.add_secret_version( + request={"parent": secret.name, "payload": {"data": value.encode("UTF-8")}} + ) + + def allow_access(name: str, member: str) -> None: + subprocess.run( + [ + "gcloud", + "secrets", + "add-iam-policy-binding", + name, + "--member", + member, + "--role", + "roles/secretmanager.secretAccessor", + "--project", + PROJECT, + ], + check=True, + ) + + client = sm.SecretManagerServiceClient() + secret_key = uuid.uuid4().hex[:56] + settings = f""" +DATABASE_URL=postgres://{POSTGRES_USER}:{POSTGRES_PASSWORD}@//cloudsql/{PROJECT}:{REGION}:{POSTGRES_INSTANCE}/{POSTGRES_DATABASE} +GS_BUCKET_NAME={CLOUD_STORAGE_BUCKET} +SECRET_KEY={secret_key} +PASSWORD_NAME={SECRET_PASSWORD_NAME} + """ + + create_secret(SECRET_SETTINGS_NAME, settings) + allow_access( + SECRET_SETTINGS_NAME, + f"serviceAccount:{project_number}-compute@developer.gserviceaccount.com", + ) + allow_access( + SECRET_SETTINGS_NAME, + f"serviceAccount:{project_number}@cloudbuild.gserviceaccount.com", + ) + + create_secret(SECRET_PASSWORD_NAME, ADMIN_PASSWORD) + allow_access( + SECRET_PASSWORD_NAME, + f"serviceAccount:{project_number}@cloudbuild.gserviceaccount.com", + ) + + yield SECRET_SETTINGS_NAME + + # delete secrets + subprocess.run( + [ + "gcloud", + "secrets", + "delete", + SECRET_PASSWORD_NAME, + "--project", + PROJECT, + "--quiet", + ], + check=True, + ) + subprocess.run( + [ + "gcloud", + "secrets", + "delete", + SECRET_SETTINGS_NAME, + "--project", + PROJECT, + "--quiet", + ], + check=True, + ) + + +@pytest.fixture +def container_image(postgres_host: str, media_bucket: str, secrets: str) -> Iterator[str]: + # Build container image for Cloud Run deployment + image_name = f"gcr.io/{PROJECT}/polls-{SUFFIX}" + service_name = f"polls-{SUFFIX}" + cloudbuild_config = "cloudmigrate.yaml" + subprocess.run( + [ + "gcloud", + "builds", + "submit", + "--config", + cloudbuild_config, + "--substitutions", + ( + f"_INSTANCE_NAME={postgres_host}," + f"_REGION={REGION}," + f"_SERVICE_NAME={service_name}," + f"_SECRET_SETTINGS_NAME={SECRET_SETTINGS_NAME}" + ), + "--project", + PROJECT, + ], + check=True, + ) + yield image_name + + # Delete container image + subprocess.run( + [ + "gcloud", + "container", + "images", + "delete", + image_name, + "--quiet", + "--project", + PROJECT, + ], + check=True, + ) + + +@pytest.fixture +def deployed_service(container_image: str) -> Iterator[str]: + # Deploy image to Cloud Run + service_name = f"polls-{SUFFIX}" + subprocess.run( + [ + "gcloud", + "run", + "deploy", + service_name, + "--image", + container_image, + "--platform=managed", + "--no-allow-unauthenticated", + "--region", + REGION, + "--add-cloudsql-instances", + f"{PROJECT}:{REGION}:{POSTGRES_INSTANCE}", + "--set-env-vars", + f"SETTINGS_NAME={SECRET_SETTINGS_NAME}", + "--project", + PROJECT, + ], + check=True, + ) + yield service_name + + # Delete Cloud Run service + subprocess.run( + [ + "gcloud", + "run", + "services", + "delete", + service_name, + "--platform=managed", + "--region=us-central1", + "--quiet", + "--project", + PROJECT, + ], + check=True, + ) + + +@pytest.fixture +def service_url_auth_token(deployed_service: str) -> Iterator[Tuple[str, str]]: + # Get Cloud Run service URL and auth token + service_url = ( + subprocess.run( + [ + "gcloud", + "run", + "services", + "describe", + deployed_service, + "--platform", + "managed", + "--region", + REGION, + "--format", + "value(status.url)", + "--project", + PROJECT, + ], + stdout=subprocess.PIPE, + check=True, + ) + .stdout.strip() + .decode() + ) + auth_token = ( + subprocess.run( + ["gcloud", "auth", "print-identity-token", "--project", PROJECT], + stdout=subprocess.PIPE, + check=True, + ) + .stdout.strip() + .decode() + ) + + yield service_url, auth_token + + # no deletion needed + + +def test_end_to_end(service_url_auth_token: List[str]) -> None: + service_url, auth_token = service_url_auth_token + headers = {"Authorization": f"Bearer {auth_token}"} + login_slug = "/admin/login/?next=/admin/" + client = requests.session() + + # Check homepage + response = client.get(service_url, headers=headers) + body = response.text + + assert response.status_code == 200 + assert "Hello, world" in body + + # Load login page, collecting csrf token + client.get(service_url + login_slug, headers=headers) + csrftoken = client.cookies["csrftoken"] + + # Log into Django admin + payload = { + "username": ADMIN_NAME, + "password": ADMIN_PASSWORD, + "csrfmiddlewaretoken": csrftoken, + } + response = client.post(service_url + login_slug, data=payload, headers=headers) + body = response.text + + # Check Django admin landing page + assert response.status_code == 200 + assert "Site administration" in body + assert "Polls" in body diff --git a/run/django/manage.py b/run/django/manage.py new file mode 100755 index 000000000000..627ae5a9f14f --- /dev/null +++ b/run/django/manage.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/run/django/mysite/__init__.py b/run/django/mysite/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/run/django/mysite/asgi.py b/run/django/mysite/asgi.py new file mode 100644 index 000000000000..ed252fbc2b29 --- /dev/null +++ b/run/django/mysite/asgi.py @@ -0,0 +1,30 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +ASGI config for mysite project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + +application = get_asgi_application() diff --git a/run/django/mysite/migrations/0001_createsuperuser.py b/run/django/mysite/migrations/0001_createsuperuser.py new file mode 100644 index 000000000000..e9b2c473513b --- /dev/null +++ b/run/django/mysite/migrations/0001_createsuperuser.py @@ -0,0 +1,56 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from django.db import migrations +from django.db.backends.postgresql.schema import DatabaseSchemaEditor +from django.db.migrations.state import StateApps +import google.auth +from google.cloud import secretmanager_v1 + + +def createsuperuser(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: + """ + Dynamically create an admin user as part of a migration + Password is pulled from Secret Manger (previously created as part of tutorial) + """ + if os.getenv("TRAMPOLINE_CI", None): + admin_password = "test" + else: + client = secretmanager_v1.SecretManagerServiceClient() + + # Get project value for identifying current context + _, project = google.auth.default() + + # Retrieve the previously stored admin password + PASSWORD_NAME = os.environ.get("PASSWORD_NAME", "superuser_password") + name = f"projects/{project}/secrets/{PASSWORD_NAME}/versions/latest" + admin_password = client.access_secret_version(name=name).payload.data.decode( + "UTF-8" + ) + + # Create a new user using acquired password + from django.contrib.auth.models import User + + User.objects.create_superuser("admin", password=admin_password) + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [migrations.RunPython(createsuperuser)] diff --git a/run/django/mysite/migrations/__init__.py b/run/django/mysite/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/run/django/mysite/settings.py b/run/django/mysite/settings.py new file mode 100644 index 000000000000..50d01032863f --- /dev/null +++ b/run/django/mysite/settings.py @@ -0,0 +1,145 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Django settings for mysite project. + +Generated by 'django-admin startproject' using Django 3.0.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" +import os + +import environ + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +env_file = os.path.join(BASE_DIR, ".env") + +# If no .env has been provided, pull it from Secret Manager, storing it locally +if not os.path.isfile(".env"): + if os.getenv('TRAMPOLINE_CI', None): + payload = f"SECRET_KEY=a\nGS_BUCKET_NAME=none\nDATABASE_URL=sqlite://{os.path.join(BASE_DIR, 'db.sqlite3')}" + else: + # [START cloudrun_secretconfig] + import google.auth + from google.cloud import secretmanager_v1 + + _, project = google.auth.default() + + if project: + client = secretmanager_v1.SecretManagerServiceClient() + + SETTINGS_NAME = os.environ.get("SETTINGS_NAME", "django_settings") + name = f"projects/{project}/secrets/{SETTINGS_NAME}/versions/latest" + payload = client.access_secret_version(name=name).payload.data.decode( + "UTF-8" + ) + + with open(env_file, "w") as f: + f.write(payload) + +env = environ.Env() +env.read_env(env_file) +# [END cloudrun_secretconfig] + +SECRET_KEY = env("SECRET_KEY") + +ALLOWED_HOSTS = ["*"] + +# Application definition + +INSTALLED_APPS = [ + "polls.apps.PollsConfig", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "mysite", + "storages", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "mysite.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] +WSGI_APPLICATION = "mysite.wsgi.application" + + +# [START cloudrun_dbconfig] +# Use django-environ to define the connection string +DATABASES = {"default": env.db()} +# [END cloudrun_dbconfig] + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" +DEBUG = True + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# [START cloudrun_staticconfig] +# Define static storage via django-storages[google] +GS_BUCKET_NAME = env("GS_BUCKET_NAME") + +DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" +STATICFILES_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" +GS_DEFAULT_ACL = "publicRead" +# [END cloudrun_staticconfig] diff --git a/run/django/mysite/urls.py b/run/django/mysite/urls.py new file mode 100644 index 000000000000..7e7f336ce614 --- /dev/null +++ b/run/django/mysite/urls.py @@ -0,0 +1,21 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("", include("polls.urls")), + path("admin/", admin.site.urls), +] diff --git a/run/django/mysite/wsgi.py b/run/django/mysite/wsgi.py new file mode 100644 index 000000000000..444d68f6e5df --- /dev/null +++ b/run/django/mysite/wsgi.py @@ -0,0 +1,30 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +WSGI config for mysite project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + +application = get_wsgi_application() diff --git a/run/django/noxfile_config.py b/run/django/noxfile_config.py new file mode 100644 index 000000000000..118cf9fa5c5f --- /dev/null +++ b/run/django/noxfile_config.py @@ -0,0 +1,38 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Default TEST_CONFIG_OVERRIDE for python repos. + +# You can copy this file into your directory, then it will be inported from +# the noxfile.py. + +# The source of truth: +# https://github.com/GoogleCloudPlatform/python-docs-samples/blob/master/noxfile_config.py + +TEST_CONFIG_OVERRIDE = { + # You can opt out from the test for specific Python versions. + "ignored_versions": ["2.7", "3.6", "3.7"], + # Old samples are opted out of enforcing Python type hints + # All new samples should feature the + "enforce_type_hints": True, + # An envvar key for determining the project id to use. Change it + # to 'BUILD_SPECIFIC_GCLOUD_PROJECT' if you want to opt in using a + # build specific Cloud project. You can also use your own string + # to use your own Cloud project. + "gcloud_project_env": "GOOGLE_CLOUD_PROJECT", + # 'gcloud_project_env': 'BUILD_SPECIFIC_GCLOUD_PROJECT', + # A dictionary you want to inject into your test. Don't put any + # secrets here. These values will override predefined values. + "envs": {"DJANGO_SETTINGS_MODULE": "mysite.settings"}, +} diff --git a/run/django/polls/__init__.py b/run/django/polls/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/run/django/polls/admin.py b/run/django/polls/admin.py new file mode 100644 index 000000000000..e90a167fb2a3 --- /dev/null +++ b/run/django/polls/admin.py @@ -0,0 +1,19 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.contrib import admin + +from .models import Question + +admin.site.register(Question) diff --git a/run/django/polls/apps.py b/run/django/polls/apps.py new file mode 100644 index 000000000000..156a0cf65582 --- /dev/null +++ b/run/django/polls/apps.py @@ -0,0 +1,19 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.apps import AppConfig + + +class PollsConfig(AppConfig): + name = "polls" diff --git a/run/django/polls/models.py b/run/django/polls/models.py new file mode 100644 index 000000000000..9234289b05e4 --- /dev/null +++ b/run/django/polls/models.py @@ -0,0 +1,26 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.db import models + + +class Question(models.Model): + question_text = models.CharField(max_length=200) + pub_date = models.DateTimeField("date published") + + +class Choice(models.Model): + question = models.ForeignKey(Question, on_delete=models.CASCADE) + choice_text = models.CharField(max_length=200) + votes = models.IntegerField(default=0) diff --git a/run/django/polls/test_polls.py b/run/django/polls/test_polls.py new file mode 100644 index 000000000000..e02f4a34f44b --- /dev/null +++ b/run/django/polls/test_polls.py @@ -0,0 +1,24 @@ +# Copyright 2020 Google LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from django.test import TestCase + + +class PollViewTests(TestCase): + def test_index_view(self: PollViewTests) -> None: + response = self.client.get('/') + assert response.status_code == 200 + assert 'Hello, world' in str(response.content) diff --git a/run/django/polls/urls.py b/run/django/polls/urls.py new file mode 100644 index 000000000000..daaf7a501182 --- /dev/null +++ b/run/django/polls/urls.py @@ -0,0 +1,21 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/run/django/polls/views.py b/run/django/polls/views.py new file mode 100644 index 000000000000..66a49f990cb3 --- /dev/null +++ b/run/django/polls/views.py @@ -0,0 +1,19 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from django.http import HttpRequest, HttpResponse + + +def index(request: HttpRequest) -> HttpResponse: + return HttpResponse("Hello, world. You're at the polls index.") diff --git a/run/django/requirements-test.txt b/run/django/requirements-test.txt new file mode 100644 index 000000000000..cae45e2c3280 --- /dev/null +++ b/run/django/requirements-test.txt @@ -0,0 +1,3 @@ +pytest==6.1.2 +pytest-django==4.1.0 +requests==2.24.0 diff --git a/run/django/requirements.txt b/run/django/requirements.txt new file mode 100644 index 000000000000..4cb5f30bf24c --- /dev/null +++ b/run/django/requirements.txt @@ -0,0 +1,6 @@ +Django==3.1.3 +django-storages[google]==1.10.1 +django-environ==0.4.5 +psycopg2-binary==2.8.4 +gunicorn==20.0.4 +google-cloud-secret-manager==2.0.0