Add support for sqlalchemy reporter

This will allow us to enter results from all jobs for
use with the openstack-health dashboard.

Depends-On: I08dbbb64b3daba915a94e455f75eef61ab392852
Change-Id: I28056d84a3f6abcd8d9038a91a6c9a3902142f90
Signed-off-by: Paul Belanger <pabelanger@redhat.com>
diff --git a/tests/base.py b/tests/base.py
index 2559eb4..9dc412b 100755
--- a/tests/base.py
+++ b/tests/base.py
@@ -34,16 +34,20 @@
 import swiftclient
 import threading
 import time
+import uuid
+
 
 import git
 import gear
 import fixtures
+import pymysql
 import statsd
 import testtools
 from git import GitCommandError
 
 import zuul.connection.gerrit
 import zuul.connection.smtp
+import zuul.connection.sql
 import zuul.scheduler
 import zuul.webapp
 import zuul.rpclistener
@@ -855,6 +859,43 @@
         return endpoint, ''
 
 
+class MySQLSchemaFixture(fixtures.Fixture):
+    def setUp(self):
+        super(MySQLSchemaFixture, self).setUp()
+
+        random_bits = ''.join(random.choice(string.ascii_lowercase +
+                                            string.ascii_uppercase)
+                              for x in range(8))
+        self.name = '%s_%s' % (random_bits, os.getpid())
+        self.passwd = uuid.uuid4().hex
+        db = pymysql.connect(host="localhost",
+                             user="openstack_citest",
+                             passwd="openstack_citest",
+                             db="openstack_citest")
+        cur = db.cursor()
+        cur.execute("create database %s" % self.name)
+        cur.execute(
+            "grant all on %s.* to '%s'@'localhost' identified by '%s'" %
+            (self.name, self.name, self.passwd))
+        cur.execute("flush privileges")
+
+        self.dburi = 'mysql+pymysql://%s:%s@localhost/%s' % (self.name,
+                                                             self.passwd,
+                                                             self.name)
+        self.addDetail('dburi', testtools.content.text_content(self.dburi))
+        self.addCleanup(self.cleanup)
+
+    def cleanup(self):
+        db = pymysql.connect(host="localhost",
+                             user="openstack_citest",
+                             passwd="openstack_citest",
+                             db="openstack_citest")
+        cur = db.cursor()
+        cur.execute("drop database %s" % self.name)
+        cur.execute("drop user '%s'@'localhost'" % self.name)
+        cur.execute("flush privileges")
+
+
 class BaseTestCase(testtools.TestCase):
     log = logging.getLogger("zuul.test")
 
@@ -1039,6 +1080,8 @@
         self.addCleanup(self.shutdown)
 
     def configure_connections(self):
+        # TODO(jhesketh): This should come from lib.connections for better
+        # coverage
         # Register connections from the config
         self.smtp_messages = []
 
@@ -1087,6 +1130,9 @@
             elif con_driver == 'smtp':
                 self.connections[con_name] = \
                     zuul.connection.smtp.SMTPConnection(con_name, con_config)
+            elif con_driver == 'sql':
+                self.connections[con_name] = \
+                    zuul.connection.sql.SQLConnection(con_name, con_config)
             else:
                 raise Exception("Unknown driver, %s, for connection %s"
                                 % (con_config['driver'], con_name))
@@ -1429,3 +1475,20 @@
 
         pprint.pprint(self.statsd.stats)
         raise Exception("Key %s not found in reported stats" % key)
+
+
+class ZuulDBTestCase(ZuulTestCase):
+    def setup_config(self, config_file='zuul-connections-same-gerrit.conf'):
+        super(ZuulDBTestCase, self).setup_config(config_file)
+        for section_name in self.config.sections():
+            con_match = re.match(r'^connection ([\'\"]?)(.*)(\1)$',
+                                 section_name, re.I)
+            if not con_match:
+                continue
+
+            if self.config.get(section_name, 'driver') == 'sql':
+                f = MySQLSchemaFixture()
+                self.useFixture(f)
+                if (self.config.get(section_name, 'dburi') ==
+                    '$MYSQL_FIXTURE_DBURI$'):
+                    self.config.set(section_name, 'dburi', f.dburi)
diff --git a/tests/fixtures/layout-sql-reporter.yaml b/tests/fixtures/layout-sql-reporter.yaml
new file mode 100644
index 0000000..c79a432
--- /dev/null
+++ b/tests/fixtures/layout-sql-reporter.yaml
@@ -0,0 +1,27 @@
+pipelines:
+  - name: check
+    manager: IndependentPipelineManager
+    source:
+        review_gerrit
+    trigger:
+      review_gerrit:
+        - event: patchset-created
+    success:
+      review_gerrit:
+        verified: 1
+      resultsdb:
+        score: 1
+    failure:
+      review_gerrit:
+        verified: -1
+      resultsdb:
+        score: -1
+      resultsdb_failures:
+        score: -1
+
+projects:
+  - name: org/project
+    check:
+      - project-merge:
+        - project-test1
+        - project-test2
diff --git a/tests/fixtures/zuul-connections-bad-sql.conf b/tests/fixtures/zuul-connections-bad-sql.conf
new file mode 100644
index 0000000..150643d
--- /dev/null
+++ b/tests/fixtures/zuul-connections-bad-sql.conf
@@ -0,0 +1,50 @@
+[gearman]
+server=127.0.0.1
+
+[zuul]
+layout_config=layout-connections-multiple-voters.yaml
+url_pattern=http://logs.example.com/{change.number}/{change.patchset}/{pipeline.name}/{job.name}/{build.number}
+job_name_in_report=true
+
+[merger]
+git_dir=/tmp/zuul-test/git
+git_user_email=zuul@example.com
+git_user_name=zuul
+zuul_url=http://zuul.example.com/p
+
+[swift]
+authurl=https://identity.api.example.org/v2.0/
+user=username
+key=password
+tenant_name=" "
+
+default_container=logs
+region_name=EXP
+logserver_prefix=http://logs.example.org/server.app/
+
+[connection review_gerrit]
+driver=gerrit
+server=review.example.com
+user=jenkins
+sshkey=none
+
+[connection alt_voting_gerrit]
+driver=gerrit
+server=alt_review.example.com
+user=civoter
+sshkey=none
+
+[connection outgoing_smtp]
+driver=smtp
+server=localhost
+port=25
+default_from=zuul@example.com
+default_to=you@example.com
+
+[connection resultsdb]
+driver=sql
+dburi=mysql+pymysql://bad:creds@host/db
+
+[connection resultsdb_failures]
+driver=sql
+dburi=mysql+pymysql://bad:creds@host/db
diff --git a/tests/fixtures/zuul-connections-same-gerrit.conf b/tests/fixtures/zuul-connections-same-gerrit.conf
index af31c8a..8c76c6c 100644
--- a/tests/fixtures/zuul-connections-same-gerrit.conf
+++ b/tests/fixtures/zuul-connections-same-gerrit.conf
@@ -40,3 +40,11 @@
 port=25
 default_from=zuul@example.com
 default_to=you@example.com
+
+[connection resultsdb]
+driver=sql
+dburi=$MYSQL_FIXTURE_DBURI$
+
+[connection resultsdb_failures]
+driver=sql
+dburi=$MYSQL_FIXTURE_DBURI$
diff --git a/tests/test_connection.py b/tests/test_connection.py
index c3458ac..f9f54f3 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -15,9 +15,21 @@
 import logging
 import testtools
 
-import zuul.connection.gerrit
+import sqlalchemy as sa
 
-from tests.base import ZuulTestCase
+import zuul.connection.gerrit
+import zuul.connection.sql
+
+from tests.base import ZuulTestCase, ZuulDBTestCase
+
+
+def _get_reporter_from_connection_name(reporters, connection_name):
+    # Reporters are placed into lists for each action they may exist in.
+    # Search through the given list for the correct reporter by its conncetion
+    # name
+    for r in reporters:
+        if r.connection.connection_name == connection_name:
+            return r
 
 
 class TestGerritConnection(testtools.TestCase):
@@ -28,11 +40,18 @@
                          zuul.connection.gerrit.GerritConnection.driver_name)
 
 
-class TestConnections(ZuulTestCase):
-    def setup_config(self, config_file='zuul-connections-same-gerrit.conf'):
-        super(TestConnections, self).setup_config(config_file)
+class TestSQLConnection(testtools.TestCase):
+    log = logging.getLogger("zuul.test_connection")
 
-    def test_multiple_connections(self):
+    def test_driver_name(self):
+        self.assertEqual(
+            'sql',
+            zuul.connection.sql.SQLConnection.driver_name
+        )
+
+
+class TestConnections(ZuulDBTestCase):
+    def test_multiple_gerrit_connections(self):
         "Test multiple connections to the one gerrit"
 
         A = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'A')
@@ -58,6 +77,178 @@
         self.assertEqual(B.patchsets[-1]['approvals'][0]['by']['username'],
                          'civoter')
 
+    def _test_sql_tables_created(self, metadata_table=None):
+        "Test the tables for storing results are created properly"
+        buildset_table = 'zuul_buildset'
+        build_table = 'zuul_build'
+
+        insp = sa.engine.reflection.Inspector(
+            self.connections['resultsdb'].engine)
+
+        self.assertEqual(9, len(insp.get_columns(buildset_table)))
+        self.assertEqual(10, len(insp.get_columns(build_table)))
+
+    def test_sql_tables_created(self):
+        "Test the default table is created"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-sql-reporter.yaml')
+        self.sched.reconfigure(self.config)
+        self._test_sql_tables_created()
+
+    def _test_sql_results(self):
+        "Test results are entered into an sql table"
+        # Grab the sa tables
+        reporter = _get_reporter_from_connection_name(
+            self.sched.layout.pipelines['check'].success_actions,
+            'resultsdb'
+        )
+
+        # Add a success result
+        A = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_review_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Add a failed result for a negative score
+        B = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.worker.addFailTest('project-test1', B)
+        self.fake_review_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        conn = self.connections['resultsdb'].engine.connect()
+        result = conn.execute(
+            sa.sql.select([reporter.connection.zuul_buildset_table]))
+
+        buildsets = result.fetchall()
+        self.assertEqual(2, len(buildsets))
+        buildset0 = buildsets[0]
+        buildset1 = buildsets[1]
+
+        self.assertEqual('check', buildset0['pipeline'])
+        self.assertEqual('org/project', buildset0['project'])
+        self.assertEqual(1, buildset0['change'])
+        self.assertEqual(1, buildset0['patchset'])
+        self.assertEqual(1, buildset0['score'])
+        self.assertEqual('Build succeeded.', buildset0['message'])
+
+        buildset0_builds = conn.execute(
+            sa.sql.select([reporter.connection.zuul_build_table]).
+            where(
+                reporter.connection.zuul_build_table.c.buildset_id ==
+                buildset0['id']
+            )
+        ).fetchall()
+
+        # Check the first result, which should be the project-merge job
+        self.assertEqual('project-merge', buildset0_builds[0]['job_name'])
+        self.assertEqual("SUCCESS", buildset0_builds[0]['result'])
+        self.assertEqual('http://logs.example.com/1/1/check/project-merge/0',
+                         buildset0_builds[0]['log_url'])
+
+        self.assertEqual('check', buildset1['pipeline'])
+        self.assertEqual('org/project', buildset1['project'])
+        self.assertEqual(2, buildset1['change'])
+        self.assertEqual(1, buildset1['patchset'])
+        self.assertEqual(-1, buildset1['score'])
+        self.assertEqual('Build failed.', buildset1['message'])
+
+        buildset1_builds = conn.execute(
+            sa.sql.select([reporter.connection.zuul_build_table]).
+            where(
+                reporter.connection.zuul_build_table.c.buildset_id ==
+                buildset1['id']
+            )
+        ).fetchall()
+
+        # Check the second last result, which should be the project-test1 job
+        # which failed
+        self.assertEqual('project-test1', buildset1_builds[-2]['job_name'])
+        self.assertEqual("FAILURE", buildset1_builds[-2]['result'])
+        self.assertEqual('http://logs.example.com/2/1/check/project-test1/4',
+                         buildset1_builds[-2]['log_url'])
+
+    def test_sql_results(self):
+        "Test results are entered into the default sql table"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-sql-reporter.yaml')
+        self.sched.reconfigure(self.config)
+        self._test_sql_results()
+
+    def test_multiple_sql_connections(self):
+        "Test putting results in different databases"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-sql-reporter.yaml')
+        self.sched.reconfigure(self.config)
+
+        # Add a successful result
+        A = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_review_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Add a failed result
+        B = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'B')
+        self.worker.addFailTest('project-test1', B)
+        self.fake_review_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # Grab the sa tables for resultsdb
+        reporter1 = _get_reporter_from_connection_name(
+            self.sched.layout.pipelines['check'].success_actions,
+            'resultsdb'
+        )
+
+        conn = self.connections['resultsdb'].engine.connect()
+        buildsets_resultsdb = conn.execute(sa.sql.select(
+            [reporter1.connection.zuul_buildset_table])).fetchall()
+        # Should have been 2 buildset reported to the resultsdb (both success
+        # and failure report)
+        self.assertEqual(2, len(buildsets_resultsdb))
+
+        # The first one should have passed
+        self.assertEqual('check', buildsets_resultsdb[0]['pipeline'])
+        self.assertEqual('org/project', buildsets_resultsdb[0]['project'])
+        self.assertEqual(1, buildsets_resultsdb[0]['change'])
+        self.assertEqual(1, buildsets_resultsdb[0]['patchset'])
+        self.assertEqual(1, buildsets_resultsdb[0]['score'])
+        self.assertEqual('Build succeeded.', buildsets_resultsdb[0]['message'])
+
+        # Grab the sa tables for resultsdb_failures
+        reporter2 = _get_reporter_from_connection_name(
+            self.sched.layout.pipelines['check'].failure_actions,
+            'resultsdb_failures'
+        )
+
+        conn = self.connections['resultsdb_failures'].engine.connect()
+        buildsets_resultsdb_failures = conn.execute(sa.sql.select(
+            [reporter2.connection.zuul_buildset_table])).fetchall()
+        # The failure db should only have 1 buildset failed
+        self.assertEqual(1, len(buildsets_resultsdb_failures))
+
+        self.assertEqual('check', buildsets_resultsdb_failures[0]['pipeline'])
+        self.assertEqual(
+            'org/project', buildsets_resultsdb_failures[0]['project'])
+        self.assertEqual(2, buildsets_resultsdb_failures[0]['change'])
+        self.assertEqual(1, buildsets_resultsdb_failures[0]['patchset'])
+        self.assertEqual(-1, buildsets_resultsdb_failures[0]['score'])
+        self.assertEqual(
+            'Build failed.', buildsets_resultsdb_failures[0]['message'])
+
+
+class TestConnectionsBadSQL(ZuulDBTestCase):
+    def setup_config(self, config_file='zuul-connections-bad-sql.conf'):
+        super(TestConnectionsBadSQL, self).setup_config(config_file)
+
+    def test_unable_to_connect(self):
+        "Test the SQL reporter fails gracefully when unable to connect"
+        self.config.set('zuul', 'layout_config',
+                        'tests/fixtures/layout-sql-reporter.yaml')
+        self.sched.reconfigure(self.config)
+
+        # Trigger a reporter. If no errors are raised, the reporter has been
+        # disabled correctly
+        A = self.fake_review_gerrit.addFakeChange('org/project', 'master', 'A')
+        self.fake_review_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
 
 class TestMultipleGerrits(ZuulTestCase):
     def setup_config(self,
diff --git a/tests/test_reporter.py b/tests/test_reporter.py
index 8d3090a..6a179d2 100644
--- a/tests/test_reporter.py
+++ b/tests/test_reporter.py
@@ -12,18 +12,18 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import fixtures
 import logging
 import testtools
 
-import zuul.reporter
+import zuul.reporter.gerrit
+import zuul.reporter.smtp
+import zuul.reporter.sql
 
 
 class TestSMTPReporter(testtools.TestCase):
     log = logging.getLogger("zuul.test_reporter")
 
-    def setUp(self):
-        super(TestSMTPReporter, self).setUp()
-
     def test_reporter_abc(self):
         # We only need to instantiate a class for this
         reporter = zuul.reporter.smtp.SMTPReporter({})  # noqa
@@ -35,12 +35,30 @@
 class TestGerritReporter(testtools.TestCase):
     log = logging.getLogger("zuul.test_reporter")
 
-    def setUp(self):
-        super(TestGerritReporter, self).setUp()
-
     def test_reporter_abc(self):
         # We only need to instantiate a class for this
         reporter = zuul.reporter.gerrit.GerritReporter(None)  # noqa
 
     def test_reporter_name(self):
         self.assertEqual('gerrit', zuul.reporter.gerrit.GerritReporter.name)
+
+
+class TestSQLReporter(testtools.TestCase):
+    log = logging.getLogger("zuul.test_reporter")
+
+    def test_reporter_abc(self):
+        # We only need to instantiate a class for this
+        # First mock out _setup_tables
+        def _fake_setup_tables(self):
+            pass
+
+        self.useFixture(fixtures.MonkeyPatch(
+            'zuul.reporter.sql.SQLReporter._setup_tables',
+            _fake_setup_tables
+        ))
+
+        reporter = zuul.reporter.sql.SQLReporter()  # noqa
+
+    def test_reporter_name(self):
+        self.assertEqual(
+            'sql', zuul.reporter.sql.SQLReporter.name)