Expose final job attribute

Exposing the final job attribute make it possible to directly
configure a job as final.

Prohibit inheritance with final

This is no longer automatically set based on auth inheritance, so
now only exists as an attribute for a user to set explicitly.
The word "final" has a pretty specifig meaning for software developers
at least, so let's err on the side of safety there to provide folks
with the least surprise.

Also document it.

Change-Id: Ibeb7fd0ec1ce4f053a16066ccc8c2dd93c6f659e
Co-Authored-By: James E. Blair <jeblair@redhat.com>
diff --git a/doc/source/user/config.rst b/doc/source/user/config.rst
index 7ccce28..4898e17 100644
--- a/doc/source/user/config.rst
+++ b/doc/source/user/config.rst
@@ -438,7 +438,7 @@
 jobs on the system should have, progressing through stages of
 specialization before arriving at a particular job.  A job may inherit
 from any other job in any project (however, if the other job is marked
-as ``final``, some attributes may not be overidden).
+as :attr:`job.final`, jobs may not inherit from it).
 
 A job with no parent is called a *base job* and may only be defined in
 a :term:`config-project`.  Every other job must have a parent, and so
@@ -452,7 +452,8 @@
 These may have different selection criteria which indicate to Zuul
 that, for instance, the job should behave differently on a different
 git branch.  Unlike inheritance, all job variants must be defined in
-the same project.
+the same project.  Some attributes of jobs marked :attr:`job.final`
+may not be overidden
 
 When Zuul decides to run a job, it performs a process known as
 freezing the job.  Because any number of job variants may be
@@ -529,6 +530,14 @@
       to auto-document Zuul jobs (in which case it is interpreted as
       ReStructuredText.
 
+   .. attr:: final
+      :default: false
+
+      To prevent other jobs from inheriting from this job, and also to
+      prevent changing execution-related attributes when this job is
+      specified in a project's pipeline, set this attribute to
+      ``true``.
+
    .. attr:: success-message
       :default: SUCCESS
 
diff --git a/tests/fixtures/config/final/git/common-config/playbooks/job-final.yaml b/tests/fixtures/config/final/git/common-config/playbooks/job-final.yaml
new file mode 100644
index 0000000..f679dce
--- /dev/null
+++ b/tests/fixtures/config/final/git/common-config/playbooks/job-final.yaml
@@ -0,0 +1,2 @@
+- hosts: all
+  tasks: []
diff --git a/tests/fixtures/config/final/git/common-config/zuul.yaml b/tests/fixtures/config/final/git/common-config/zuul.yaml
new file mode 100644
index 0000000..f08d66e
--- /dev/null
+++ b/tests/fixtures/config/final/git/common-config/zuul.yaml
@@ -0,0 +1,28 @@
+- pipeline:
+    name: check
+    manager: independent
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        Verified: 1
+    failure:
+      gerrit:
+        Verified: -1
+
+- job:
+    name: base
+    parent: null
+
+- job:
+    name: job-final
+    final: true
+    vars:
+      dont_override_this: dummy
+
+- project:
+    name: org/project
+    check:
+      jobs: []
+
diff --git a/tests/fixtures/config/final/git/org_project/README b/tests/fixtures/config/final/git/org_project/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/final/git/org_project/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/final/git/org_project/playbooks/placeholder b/tests/fixtures/config/final/git/org_project/playbooks/placeholder
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/fixtures/config/final/git/org_project/playbooks/placeholder
diff --git a/tests/fixtures/config/final/main.yaml b/tests/fixtures/config/final/main.yaml
new file mode 100644
index 0000000..208e274
--- /dev/null
+++ b/tests/fixtures/config/final/main.yaml
@@ -0,0 +1,8 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-projects:
+          - common-config
+        untrusted-projects:
+          - org/project
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 74d72e7..5e3a6fc 100755
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -67,6 +67,89 @@
                          "not affect tenant one")
 
 
+class TestFinal(ZuulTestCase):
+
+    tenant_config_file = 'config/final/main.yaml'
+
+    def test_final_variant_ok(self):
+        # test clean usage of final parent job
+        in_repo_conf = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - job-final
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '1')
+
+    def test_final_variant_error(self):
+        # test misuse of final parent job
+        in_repo_conf = textwrap.dedent(
+            """
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - job-final:
+                        vars:
+                          dont_override_this: bar
+            """)
+        file_dict = {'.zuul.yaml': in_repo_conf}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # The second patch tried to override some variables.
+        # Thus it should fail.
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1')
+        self.assertIn('Unable to modify final job', A.messages[0])
+
+    def test_final_inheritance(self):
+        # test misuse of final parent job
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test
+                parent: job-final
+
+            - project:
+                name: org/project
+                check:
+                  jobs:
+                    - project-test
+            """)
+
+        in_repo_playbook = textwrap.dedent(
+            """
+            - hosts: all
+              tasks: []
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf,
+                     'playbooks/project-test.yaml': in_repo_playbook}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # The second patch tried to override some variables.
+        # Thus it should fail.
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1')
+        self.assertIn('Unable to inherit from final job', A.messages[0])
+
+
 class TestInRepoConfig(ZuulTestCase):
     # A temporary class to hold new tests while others are disabled
 
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 708a132..86459b0 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -341,6 +341,7 @@
 
         job = {vs.Required('name'): str,
                'parent': vs.Any(str, None),
+               'final': bool,
                'failure-message': str,
                'success-message': str,
                'failure-url': str,
@@ -374,6 +375,7 @@
         return vs.Schema(job)
 
     simple_attributes = [
+        'final',
         'timeout',
         'workspace',
         'voting',
diff --git a/zuul/model.py b/zuul/model.py
index 092c0ed..cf57851 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -917,6 +917,10 @@
         if not isinstance(other, Job):
             raise Exception("Job unable to inherit from %s" % (other,))
 
+        if other.final:
+            raise Exception("Unable to inherit from final job %s" %
+                            (repr(other),))
+
         # copy all attributes
         for k in self.inheritable_attributes:
             if (other._get(k) is not None):