Repository: TheFriendlyCoder/pyjen

Issue:
Fix SSL cert handling logic
The PyJen APIs should be restructured to properly handle SSL certificate validation. By default, SSL verification should be enabled. Also, we should add the ability to customize the SSL certificate authorities in some sensible way.

Discussion:

Patch:
diff --git a/src/pyjen/build.py b/src/pyjen/build.py
index d0c902e..aba3a36 100644
--- a/src/pyjen/build.py
+++ b/src/pyjen/build.py
@@ -82,7 +82,6 @@ class Build(object):
         :rtype: :class:`bool`
         """
         data = self._api.get_api_data()
-
         return data['building']
 
     @property
@@ -105,10 +104,11 @@ class Build(object):
             * "SUCCESS"
             * "UNSTABLE"
             * "FAILURE"
+            * "ABORTED"
+
         :rtype: :class:`str`
         """
         data = self._api.get_api_data()
-
         return data['result']
 
     @property
@@ -162,6 +162,10 @@ class Build(object):
 
         return retval
 
+    def abort(self):
+        """Aborts this build before it completes"""
+        self._api.post(self._api.url + "stop")
+
 
 if __name__ == "__main__":  # pragma: no cover
     pass
diff --git a/src/pyjen/jenkins.py b/src/pyjen/jenkins.py
index f042f26..1231e03 100644
--- a/src/pyjen/jenkins.py
+++ b/src/pyjen/jenkins.py
@@ -54,14 +54,14 @@ class Jenkins(object):
         authenticate with. If not provided, credentials will be loaded from any
         PyJen config files detected on the system. If no config files can be
         found, anonymous access will be assumed
-    :param bool ssl_verify:
-        flag indicating whether SSL certificates should be verified or not on
-        this connection. Enabling this flag on servers running Jenkins behind
-        an HTTPS connect with self-signed SSL certificates will result in
-        connection errors
+    :param ssl_cert:
+        Passed directly to the requests library when authenticating to the
+        remote server. Maybe be a boolean indicating whether SSL verification
+        is enabled or disabled, or may be a path to a certificate authority
+        bundle.
     """
 
-    def __init__(self, url, credentials=None, ssl_verify=False):
+    def __init__(self, url, credentials=None, ssl_cert=True):
         super(Jenkins, self).__init__()
         self._log = logging.getLogger(__name__)
 
@@ -74,7 +74,7 @@ class Jenkins(object):
         else:
             creds = credentials
 
-        self._api = JenkinsAPI(url, creds, ssl_verify)
+        self._api = JenkinsAPI(url, creds, ssl_cert)
 
     @property
     def connected(self):
diff --git a/src/pyjen/plugins/buildblocker.py b/src/pyjen/plugins/buildblocker.py
index 87d09e0..f89643f 100644
--- a/src/pyjen/plugins/buildblocker.py
+++ b/src/pyjen/plugins/buildblocker.py
@@ -8,6 +8,58 @@ class BuildBlockerProperty(XMLPlugin):
 
     https://wiki.jenkins-ci.org/display/JENKINS/Build+Blocker+Plugin
     """
+    QUEUE_SCAN_TYPES = ("DISABLED", "ALL", "BUILDABLE")
+    LEVEL_TYPES = ("GLOBAL", "NODE")
+
+    @property
+    def queue_scan(self):
+        """Checks to see whether build blocking scans the build queue or not
+
+        :returns: One of BuildBlockerProperty.QUEUE_SCAN_TYPES
+        :rtype: :class:`str`
+        """
+        retval = self._root.find("scanQueueFor").text
+        assert retval in BuildBlockerProperty.QUEUE_SCAN_TYPES
+        return retval
+
+    @queue_scan.setter
+    def queue_scan(self, value):
+        """Sets the type of build queue scanning for the blocking job
+
+        :param str value:
+            type of queue scanning to perform
+            Must be one of BuildBlockerProperty.QUEUE_SCAN_TYPES
+        """
+        if value not in BuildBlockerProperty.QUEUE_SCAN_TYPES:
+            raise ValueError(
+                "Build blocker queue scan may only be one of the following "
+                "types: " + ",".join(BuildBlockerProperty.QUEUE_SCAN_TYPES))
+        self._root.find("scanQueueFor").text = value
+
+    @property
+    def level(self):
+        """Gets the scope of the blocked job settings
+
+        :returns: One of BuildBlockerProperty.LEVEL_TYPES
+        :rtype: :class:`str`
+        """
+        retval = self._root.find("blockLevel").text
+        assert retval in BuildBlockerProperty.LEVEL_TYPES
+        return retval
+
+    @level.setter
+    def level(self, value):
+        """Sets the scope of the blocked builds
+
+        :param str value:
+            scope for this build blocker
+            Must be one of BuildBlockerProperty.LEVEL_TYPES
+        """
+        if value not in BuildBlockerProperty.LEVEL_TYPES:
+            raise ValueError(
+                "Build blocker scope level may only be one of the following "
+                "types: " + ",".join(BuildBlockerProperty.LEVEL_TYPES))
+        self._root.find("blockLevel").text = value
 
     @property
     def blockers(self):
@@ -16,27 +68,24 @@ class BuildBlockerProperty(XMLPlugin):
         :return: list of search criteria for blocking jobs
         :rtype: :class:`list`
         """
-        retval = []
-        if not self.is_enabled:
-            return retval
-
         temp = self._root.find("blockingJobs").text
-        if temp is None:
-            return retval
-
-        retval = temp.split()
-        return retval
+        return temp.split()
 
     @blockers.setter
-    def blockers(self, new_blockers):
-        """Sets the list of search criteria for blocking jobs
-
-        :param list new_blockers: list of search criteria for blocking jobs
+    def blockers(self, patterns):
+        """Defines the names or regular expressions for jobs that block
+        execution of this job
+
+        :param patterns:
+            One or more names or regular expressions for jobs that block the
+            execution of this one.
+        :type patterns: either :class:`list` or :class:`str`
         """
         node = self._root.find("blockingJobs")
-        if node is None:
-            node = ElementTree.SubElement(self._root, 'blockingJobs')
-        node.text = "\n".join(new_blockers)
+        if isinstance(patterns, str):
+            node.text = patterns
+        else:
+            node.text = "\n".join(patterns)
 
     @property
     def is_enabled(self):
@@ -51,15 +100,11 @@ class BuildBlockerProperty(XMLPlugin):
     def enable(self):
         """Enables this set of build blockers"""
         node = self._root.find("useBuildBlocker")
-        if node is None:
-            node = ElementTree.SubElement(self._root, 'useBuildBlocker')
         node.text = "true"
 
     def disable(self):
         """Disables this set of build blockers"""
         node = self._root.find("useBuildBlocker")
-        if node is None:
-            node = ElementTree.SubElement(self._root, 'useBuildBlocker')
         node.text = "false"
 
     @staticmethod
@@ -71,7 +116,30 @@ class BuildBlockerProperty(XMLPlugin):
 
         :rtype: :class:`str`
         """
-        return "buildblocker"
+        return "hudson.plugins.buildblocker.BuildBlockerProperty"
+
+    @classmethod
+    def create(cls, patterns):
+        """Factory method used to instantiate an instance of this plugin
+
+        :param patterns:
+            One or more names or regular expressions for jobs that block the
+            execution of this one.
+        :type patterns: either :class:`list` or :class:`str`
+        """
+        default_xml = """<hudson.plugins.buildblocker.BuildBlockerProperty plugin="build-blocker-plugin@1.7.3">
+<useBuildBlocker>true</useBuildBlocker>
+<blockLevel>GLOBAL</blockLevel>
+<scanQueueFor>DISABLED</scanQueueFor>
+</hudson.plugins.buildblocker.BuildBlockerProperty>"""
+
+        root_node = ElementTree.fromstring(default_xml)
+        jobs_node = ElementTree.SubElement(root_node, "blockingJobs")
+        if isinstance(patterns, str):
+            jobs_node.text = patterns
+        else:
+            jobs_node.text = " ".join(patterns)
+        return cls(root_node)
 
 
 PluginClass = BuildBlockerProperty
diff --git a/src/pyjen/queue_item.py b/src/pyjen/queue_item.py
index 7783f26..7c2165b 100644
--- a/src/pyjen/queue_item.py
+++ b/src/pyjen/queue_item.py
@@ -88,7 +88,7 @@ class QueueItem(object):
 
         :rtype: :class:`bool`
         """
-        return self._data["cancelled"]
+        return self._data.get("cancelled", False)
 
     @property
     def job(self):
diff --git a/src/pyjen/utils/jenkins_api.py b/src/pyjen/utils/jenkins_api.py
index c2082a5..a18a31f 100644
--- a/src/pyjen/utils/jenkins_api.py
+++ b/src/pyjen/utils/jenkins_api.py
@@ -14,17 +14,17 @@ class JenkinsAPI(object):
     :param tuple creds:
         username and password pair to authenticate with when accessing
         the REST API
-    :param bool verify_ssl:
-        indicates whether ssl certificate verification should be performed
-        on the REST API endpoints
+    :param ssl_cert:
+        Either a boolean controlling SSL verification, or a path to a cert
+        authority bundle to use for SSL verification.
     ."""
 
-    def __init__(self, url, creds, verify_ssl):
+    def __init__(self, url, creds, ssl_cert):
         self._log = logging.getLogger(__name__)
 
         self._url = url.rstrip("/\\") + "/"
         self._creds = creds
-        self._ssl_verify_enabled = verify_ssl
+        self._ssl_cert = ssl_cert
 
         self._jenkins_root_url = self._url
 
@@ -50,8 +50,7 @@ class JenkinsAPI(object):
             newly created JenkinsAPI
         :rtype: :class:`~.utils.jenkins_api.JenkinsAPI`
         """
-        retval = JenkinsAPI(
-            api_url, self._creds, self._ssl_verify_enabled)
+        retval = JenkinsAPI(api_url, self._creds, self._ssl_cert)
         retval._jenkins_root_url = self._jenkins_root_url
         return retval
 
@@ -90,7 +89,7 @@ class JenkinsAPI(object):
             req = requests.get(
                 temp_path,
                 auth=self._creds,
-                verify=self._ssl_verify_enabled)
+                verify=self._ssl_cert)
             req.raise_for_status()
 
             self._jenkins_headers_cache = req.headers
@@ -136,7 +135,7 @@ class JenkinsAPI(object):
         req = requests.get(
             temp_url,
             auth=self._creds,
-            verify=self._ssl_verify_enabled)
+            verify=self._ssl_cert)
         req.raise_for_status()
         retval = req.json()
         self._log.debug(json.dumps(retval, indent=4))
@@ -159,7 +158,7 @@ class JenkinsAPI(object):
         req = requests.get(
             temp_url,
             auth=self._creds,
-            verify=self._ssl_verify_enabled)
+            verify=self._ssl_cert)
         req.raise_for_status()
 
         return req.text
@@ -194,7 +193,7 @@ class JenkinsAPI(object):
         req = requests.post(
             target_url,
             auth=self._creds,
-            verify=self._ssl_verify_enabled,
+            verify=self._ssl_cert,
             headers=temp_headers,
             **args if args else dict())
 
@@ -222,7 +221,7 @@ class JenkinsAPI(object):
             req = requests.get(
                 self.root_url + 'crumbIssuer/api/json',
                 auth=self._creds,
-                verify=self._ssl_verify_enabled)
+                verify=self._ssl_cert)
 
             if req.status_code == 404:
                 # If we get a 404 error, endpoint not found, assume the Cross


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 75dce52..178ae95 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -37,6 +37,7 @@ JENKINS_PLUGINS = [
     "sectioned-view",
     "conditional-buildstep",
     "parameterized-trigger",
+    "build-blocker-plugin",
 ]
 
 
diff --git a/tests/test_build.py b/tests/test_build.py
index 1d02763..68e7129 100644
--- a/tests/test_build.py
+++ b/tests/test_build.py
@@ -88,5 +88,32 @@ def test_console_text(jenkins_env):
         assert expected_output in jb.last_build.console_output
 
 
+@pytest.mark.timeout(10)
+def test_abort(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    expected_job_name = "test_abort"
+    jb = jk.create_job(expected_job_name, "hudson.model.FreeStyleProject")
+
+    with clean_job(jb):
+        jb.quiet_period = 0
+        shell_builder = ShellBuilder.create("echo 'waiting for sleep' && sleep 40")
+        jb.add_builder(shell_builder)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(expected_job_name).builders)
+
+        # Trigger a build and wait for it to complete
+        jb.start_build()
+        async_assert(lambda: jb.last_build)
+
+        async_assert(lambda: "waiting for sleep" in jb.last_build.console_output)
+
+        jb.last_build.abort()
+
+        assert jb.last_build.is_building is False
+        assert jb.last_build.result == "ABORTED"
+
+
 if __name__ == "__main__":
     pytest.main([__file__, "-v", "-s"])
diff --git a/tests/test_build_queue.py b/tests/test_build_queue.py
index eb0d335..79bea8f 100644
--- a/tests/test_build_queue.py
+++ b/tests/test_build_queue.py
@@ -83,5 +83,19 @@ def test_start_build_returned_queue_item(jenkins_env):
         assert queue.items[0] == item
 
 
+def test_queue_get_build(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    jb = jk.create_job("test_queue_get_build", "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        jb.quiet_period = 0
+        item = jb.start_build()
+
+        async_assert(lambda: not item.waiting)
+
+        bld = item.build
+        assert bld is not None
+        assert bld == jb.last_build
+
+
 if __name__ == "__main__":
     pytest.main([__file__, "-v", "-s"])
diff --git a/tests/test_job_properties/test_build_blocker.py b/tests/test_job_properties/test_build_blocker.py
new file mode 100644
index 0000000..e884044
--- /dev/null
+++ b/tests/test_job_properties/test_build_blocker.py
@@ -0,0 +1,209 @@
+import pytest
+from pyjen.jenkins import Jenkins
+from pyjen.plugins.buildblocker import BuildBlockerProperty
+from pyjen.plugins.shellbuilder import ShellBuilder
+from ..utils import async_assert, clean_job
+
+
+def test_add_build_blocker(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_add_build_blocker"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_job_name = "MyCoolJob"
+        build_blocker = BuildBlockerProperty.create(expected_job_name)
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        assert isinstance(properties, list)
+        assert len(properties) == 1
+        assert isinstance(properties[0], BuildBlockerProperty)
+
+
+def test_multiple_blocking_jobs(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_multiple_blocking_jobs"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+        build_blocker = BuildBlockerProperty.create(["ShouldNotSeeMe"])
+        build_blocker.blockers = expected_jobs
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        prop = properties[0]
+        blockers = prop.blockers
+        assert isinstance(blockers, list)
+        assert len(blockers) == 2
+        assert expected_jobs[0] in blockers
+        assert expected_jobs[1] in blockers
+
+
+def test_default_queue_scan(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_default_queue_scan"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+        build_blocker = BuildBlockerProperty.create(expected_jobs)
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        prop = properties[0]
+        assert prop.queue_scan == "DISABLED"
+
+
+def test_custom_queue_scan(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_custom_queue_scan"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+        expected_type = "ALL"
+        build_blocker = BuildBlockerProperty.create(expected_jobs)
+        build_blocker.queue_scan = expected_type
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        prop = properties[0]
+        assert prop.queue_scan == expected_type
+
+
+def test_invalid_queue_scan_type():
+    expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+    build_blocker = BuildBlockerProperty.create(expected_jobs)
+    with pytest.raises(ValueError):
+        build_blocker.queue_scan = "FuBar"
+
+
+def test_default_block_level(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_default_block_level"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+        build_blocker = BuildBlockerProperty.create(expected_jobs)
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        prop = properties[0]
+        assert prop.level == "GLOBAL"
+
+
+def test_custom_block_level(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_custom_block_level"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+        expected_type = "NODE"
+        build_blocker = BuildBlockerProperty.create(expected_jobs)
+        build_blocker.level = expected_type
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        prop = properties[0]
+        assert prop.level == expected_type
+
+
+def test_invalid_block_level():
+    expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+    build_blocker = BuildBlockerProperty.create(expected_jobs)
+    with pytest.raises(ValueError):
+        build_blocker.level = "FuBar"
+
+
+def test_default_queue_scan(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_default_queue_scan"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        expected_jobs = ["MyCoolJob1", "MyCoolJob2"]
+        build_blocker = BuildBlockerProperty.create(expected_jobs)
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        prop = properties[0]
+        assert prop.queue_scan == "DISABLED"
+
+
+def test_disable_build_blocker(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name = "test_disable_build_blocker"
+    jb = jk.create_job(job_name, "hudson.model.FreeStyleProject")
+    with clean_job(jb):
+        build_blocker = BuildBlockerProperty.create("MyJob")
+        build_blocker.disable()
+        jb.quiet_period = 0
+        jb.add_property(build_blocker)
+
+        # Get a fresh copy of our job to ensure we have an up to date
+        # copy of the config.xml for the job
+        async_assert(lambda: jk.find_job(job_name).properties)
+        properties = jk.find_job(job_name).properties
+
+        assert properties[0].is_enabled is False
+
+
+def test_build_blocker_functionality(jenkins_env):
+    jk = Jenkins(jenkins_env["url"], (jenkins_env["admin_user"], jenkins_env["admin_token"]))
+    job_name1 = "test_build_blocker_functionality1"
+    jb1 = jk.create_job(job_name1, "hudson.model.FreeStyleProject")
+    with clean_job(jb1):
+        job_name2 = "test_build_blocker_functionality2"
+        jb2 = jk.create_job(job_name2, "hudson.model.FreeStyleProject")
+        with clean_job(jb2):
+            expected_jobs = job_name2
+            build_blocker = BuildBlockerProperty.create(expected_jobs)
+            jb1.quiet_period = 0
+            jb1.add_property(build_blocker)
+
+            # Get a fresh copy of our job to ensure we have an up to date
+            # copy of the config.xml for the job
+            async_assert(lambda: jk.find_job(job_name1).properties)
+
+            build_step = ShellBuilder.create("sleep 10")
+            jb2.quiet_period = 0
+            jb2.add_builder(build_step)
+            async_assert(lambda: jb2.builders)
+            queue2 = jb2.start_build()
+
+            async_assert(lambda: not queue2.waiting)
+
+            queue1 = jb1.start_build()
+            assert job_name2 in queue1.reason
+
+            queue2.build.abort()
+            assert queue1.waiting is False
+
+
+if __name__ == "__main__":
+    pytest.main([__file__, "-v", "-s"])