Add max-job-timeout tenant setting

This change adds a tenant setting to limit the timeout value a job can set.

Change-Id: I3875e81b1f6a6e059e7eb57362772e3446e8d022
diff --git a/doc/source/admin/tenants.rst b/doc/source/admin/tenants.rst
index 54bc10a..4722750 100644
--- a/doc/source/admin/tenants.rst
+++ b/doc/source/admin/tenants.rst
@@ -163,6 +163,11 @@
       The maximum number of nodes a job can request.  A value of
       '-1' value removes the limit.
 
+   .. attr:: max-job-timeout
+      :default: 10800
+
+      The maximum timeout for jobs. A value of '-1' value removes the limit.
+
    .. attr:: exclude-unprotected-branches
       :default: false
 
diff --git a/tests/fixtures/config/multi-tenant/main.yaml b/tests/fixtures/config/multi-tenant/main.yaml
index 4916905..e667588 100644
--- a/tests/fixtures/config/multi-tenant/main.yaml
+++ b/tests/fixtures/config/multi-tenant/main.yaml
@@ -1,5 +1,6 @@
 - tenant:
     name: tenant-one
+    max-job-timeout: 1800
     source:
       gerrit:
         config-projects:
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 2b27b0e..d55ff92 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -1402,7 +1402,7 @@
 class TestMaxNodesPerJob(AnsibleZuulTestCase):
     tenant_config_file = 'config/multi-tenant/main.yaml'
 
-    def test_max_nodes_reached(self):
+    def test_max_timeout_exceeded(self):
         in_repo_conf = textwrap.dedent(
             """
             - job:
@@ -1437,6 +1437,32 @@
                          "B should not fail because of nodes limit")
 
 
+class TestMaxTimeout(AnsibleZuulTestCase):
+    tenant_config_file = 'config/multi-tenant/main.yaml'
+
+    def test_max_nodes_reached(self):
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: test-job
+                timeout: 3600
+            """)
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertIn('The job "test-job" exceeds tenant max-job-timeout',
+                      A.messages[0], "A should fail because of timeout limit")
+
+        B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+        self.assertNotIn("exceeds tenant max-job-timeout", B.messages[0],
+                         "B should not fail because of timeout limit")
+
+
 class TestBaseJobs(ZuulTestCase):
     tenant_config_file = 'config/base-jobs/main.yaml'
 
diff --git a/zuul/configloader.py b/zuul/configloader.py
index a923fca..3221154 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -76,6 +76,15 @@
         super(MaxNodeError, self).__init__(message)
 
 
+class MaxTimeoutError(Exception):
+    def __init__(self, job, tenant):
+        message = textwrap.dedent("""\
+        The job "{job}" exceeds tenant max-job-timeout {maxtimeout}.""")
+        message = textwrap.fill(message.format(
+            job=job.name, maxtimeout=tenant.max_job_timeout))
+        super(MaxTimeoutError, self).__init__(message)
+
+
 class DuplicateGroupError(Exception):
     def __init__(self, nodeset, group):
         message = textwrap.dedent("""\
@@ -505,6 +514,10 @@
         if secrets and not conf['_source_context'].trusted:
             job.post_review = True
 
+        if conf.get('timeout') and tenant.max_job_timeout != -1 and \
+           int(conf['timeout']) > tenant.max_job_timeout:
+            raise MaxTimeoutError(job, tenant)
+
         if 'post-review' in conf:
             if conf['post-review']:
                 job.post_review = True
@@ -1059,6 +1072,7 @@
     def getSchema(connections=None):
         tenant = {vs.Required('name'): str,
                   'max-nodes-per-job': int,
+                  'max-job-timeout': int,
                   'source': TenantParser.validateTenantSources(connections),
                   'exclude-unprotected-branches': bool,
                   'default-parent': str,
@@ -1072,6 +1086,8 @@
         tenant = model.Tenant(conf['name'])
         if conf.get('max-nodes-per-job') is not None:
             tenant.max_nodes_per_job = conf['max-nodes-per-job']
+        if conf.get('max-job-timeout') is not None:
+            tenant.max_job_timeout = int(conf['max-job-timeout'])
         if conf.get('exclude-unprotected-branches') is not None:
             tenant.exclude_unprotected_branches = \
                 conf['exclude-unprotected-branches']
diff --git a/zuul/model.py b/zuul/model.py
index 1ef8d3a..429a0c3 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -2506,6 +2506,7 @@
     def __init__(self, name):
         self.name = name
         self.max_nodes_per_job = 5
+        self.max_job_timeout = 10800
         self.exclude_unprotected_branches = False
         self.default_base_job = None
         self.layout = None