Repository: GoogleCloudPlatform/django-cloud-deploy

Issue:
Add a label for Github Issues generated crash handling

Discussion:

Patch:
diff --git a/django_cloud_deploy/cloudlib/database.py b/django_cloud_deploy/cloudlib/database.py
index cb5ed34..63c4980 100644
--- a/django_cloud_deploy/cloudlib/database.py
+++ b/django_cloud_deploy/cloudlib/database.py
@@ -18,13 +18,16 @@ See https://cloud.google.com/sql/docs/
 
 import contextlib
 import signal
+import shutil
 import time
 from typing import Optional
 
 from django import db
 from django.core import management
 from django_cloud_deploy import crash_handling
+
 import pexpect
+from pexpect import popen_spawn
 
 from googleapiclient import discovery
 from googleapiclient import errors
@@ -187,7 +190,7 @@ class DatabaseClient(object):
     def with_cloud_sql_proxy(self,
                              project_id: str,
                              instance_name: str,
-                             cloud_sql_proxy_path: str = 'cloud_sql_proxy',
+                             cloud_sql_proxy_path: Optional[str] = None,
                              region: str = 'us-west1',
                              port: int = 5432):
         """A context manager to run and kill cloud sql proxy subprocesses.
@@ -217,7 +220,10 @@ class DatabaseClient(object):
             project_id, region, instance_name)
         instance_flag = '-instances={}=tcp:{}'.format(
             instance_connection_string, port)
-        process = pexpect.spawn(cloud_sql_proxy_path, args=[instance_flag])
+        if cloud_sql_proxy_path is None:
+            cloud_sql_proxy_path = shutil.which('cloud_sql_proxy')
+            assert cloud_sql_proxy_path, 'could not find cloud_sql_proxy_path'
+        process = popen_spawn.PopenSpawn([cloud_sql_proxy_path, instance_flag])
         try:
             # Make sure cloud sql proxy is started before doing the real work
             process.expect('Ready for new connections', timeout=5)
diff --git a/django_cloud_deploy/crash_handling/__init__.py b/django_cloud_deploy/crash_handling/__init__.py
index e03933d..5c665a1 100644
--- a/django_cloud_deploy/crash_handling/__init__.py
+++ b/django_cloud_deploy/crash_handling/__init__.py
@@ -39,6 +39,10 @@ class UserError(Exception):
 _DISPLAYABLE_EXCEPTIONS = [UserError]
 
 
+# The Github issue label for all issues generated by crash handling
+_ISSUE_LABEL = 'crash handler'
+
+
 with open(os.path.join(os.path.dirname(__file__), 'template',
                        'issue_template.txt')) as f:
     _ISSUE_TEMPLATE = f.read()
@@ -101,12 +105,11 @@ def _create_issue(issue_title: str, issue_content: str):
         issue_content: Body of the Github issue.
     """
 
-    # TODO: Add an issue label for issues reported by users
     request_url = ('https://github.com/GoogleCloudPlatform/django-cloud-deploy/'
                    'issues/new?{}')
 
     params = urllib.parse.urlencode(
-        {'title': issue_title, 'body': issue_content})
+        {'title': issue_title, 'body': issue_content, 'labels': _ISSUE_LABEL})
     url = request_url.format(params)
     webbrowser.open(url)
 
diff --git a/django_cloud_deploy/workflow/__init__.py b/django_cloud_deploy/workflow/__init__.py
index 0464b71..9f28530 100644
--- a/django_cloud_deploy/workflow/__init__.py
+++ b/django_cloud_deploy/workflow/__init__.py
@@ -64,7 +64,7 @@ class WorkflowManager(object):
         if backend == 'gke':
             self._deploygke_workflow = _deploygke.DeploygkeWorkflow(credentials)
         else:
-            self._deploygae_workflow = _deploygae.DeploygaeWorkflow()
+            self._deploygae_workflow = _deploygae.DeploygaeWorkflow(credentials)
         self._enable_service_workflow = _enable_service.EnableServiceWorkflow(
             credentials)
         self._service_account_workflow = (
diff --git a/django_cloud_deploy/workflow/_deploygae.py b/django_cloud_deploy/workflow/_deploygae.py
index 4e7f070..88ed31a 100644
--- a/django_cloud_deploy/workflow/_deploygae.py
+++ b/django_cloud_deploy/workflow/_deploygae.py
@@ -14,8 +14,13 @@
 """Workflow for deploying a Django app to GAE."""
 
 import os
+import shutil
+import subprocess
+import time
 
-import pexpect
+from googleapiclient import discovery
+
+from google.auth import credentials
 
 
 class DeployNewAppError(Exception):
@@ -25,6 +30,33 @@ class DeployNewAppError(Exception):
 class DeploygaeWorkflow(object):
     """Workflow to deploy Django app on GAE."""
 
+    def __init__(self, credentials: credentials.Credentials):
+        self._appengine_service = discovery.build(
+            'appengine', 'v1', credentials=credentials)
+
+    def _create_app(self, project_id: str, region: str):
+        """Synchronously create an App Engine application in the project."""
+        create_response = self._appengine_service.apps().create(
+            body={
+                'id': project_id,
+                'locationId': region
+            }).execute()
+
+        # The creation response will be reference to an on-going operation.
+        # Pool the operation until it is complete or returns an error. See:
+        # https://cloud.google.com/appengine/docs/admin-api/creating-an-application
+        operation_id = create_response['name'].split('/')[-1]
+        while True:
+            operation = self._appengine_service.apps().operations().get(
+                appsId=project_id, operationsId=operation_id).execute()
+            if 'error' in operation:
+                raise DeployNewAppError(
+                    'Failed to create App Engine app: {}'.format(
+                        operation['error']))
+            elif operation.get('done'):
+                break
+            time.sleep(2)
+
     def deploy_gae_app(self,
                        project_id: str,
                        django_directory_path: str,
@@ -46,27 +78,24 @@ class DeploygaeWorkflow(object):
             The url of the deployed Django app.
         """
 
+        if is_new:
+            self._create_app(project_id, region)
+
+        gcloud_path = shutil.which('gcloud')
+        assert gcloud_path, 'could not find gcloud'
+
         app_yaml_path = os.path.join(django_directory_path, 'app.yaml')
         project = '--project={}'.format(project_id)
-        args = ['app', 'deploy', app_yaml_path, project]
+
         # We need to grab all environment variables to pass to the subprocess
         env_vars = dict(os.environ)
-
-        # Set Env Variable used by Gcloud for User Agent String
         env_vars['CLOUDSDK_METRICS_ENVIRONMENT'] = 'django-cloud-deploy'
-        process = pexpect.spawn('gcloud', args, env=env_vars)
-        try:
-            if is_new:
-                index = process.expect(
-                    ['\[{}\]\s*{}'.format(i, region) for i in range(1, 10)])
-                process.sendline(str(index))
-            process.expect('Do you want to continue (Y/n)?')
-            process.sendline('Y')
-            process.expect('Deployed service', timeout=600)  # 10 Min Timeout
-        except (pexpect.exceptions.TIMEOUT, pexpect.exceptions.EOF):
-            raise DeployNewAppError(
-                ('Error occured when trying to deploy GAE application. Output '
-                 'of process: \n{}').format(process.before))
-        finally:
-            process.close()
+        gcloud_result = subprocess.run(
+            [gcloud_path, '-q', project, 'app', 'deploy', app_yaml_path],
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.PIPE,
+            universal_newlines=True,
+            env=env_vars)
+        if gcloud_result.returncode != 0:
+            raise DeployNewAppError(gcloud_result.stderr)
         return 'https://{}.appspot.com/'.format(project_id)


Test patch:
diff --git a/django_cloud_deploy/tests/unit/crash_handling/crash_handling_test.py b/django_cloud_deploy/tests/unit/crash_handling/crash_handling_test.py
index a119f23..67d7c67 100644
--- a/django_cloud_deploy/tests/unit/crash_handling/crash_handling_test.py
+++ b/django_cloud_deploy/tests/unit/crash_handling/crash_handling_test.py
@@ -41,7 +41,7 @@ class CrashHandlingTest(unittest.TestCase):
 
     def parse_body_and_title_from_url(self, url):
         params = parse.parse_qs(parse.urlparse(url).query)
-        return params['title'][0], params['body'][0]
+        return params['title'][0], params['body'][0], params['labels'][0]
 
     @mock.patch('webbrowser.open')
     def test_create_issue(self, mock_open_browser, *unused_mocks):
@@ -51,9 +51,10 @@ class CrashHandlingTest(unittest.TestCase):
         crash_handling.handle_crash(error, 'command_fake', test_io)
         self.assertEqual(mock_open_browser.call_count, 1)
         url = mock_open_browser.call_args[0][0]
-        title, body = self.parse_body_and_title_from_url(url)
+        title, body, label = self.parse_body_and_title_from_url(url)
         self.assert_valid_issue_title(title, error)
         self.assert_valid_issue_body(body)
+        self.assertEqual(label, crash_handling._ISSUE_LABEL)
         self.assertIn(type(error).__name__, title)
 
     @mock.patch('webbrowser.open')