diff options
39 files changed, 542 insertions, 218 deletions
diff --git a/README.rst b/README.rst index 52b89dfb6..8d0066530 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,14 @@ preparation for the third major version of Zuul. We call this effort The latest documentation for Zuul v3 is published at: https://docs.openstack.org/infra/zuul/feature/zuulv3/ +If you are looking for the Edge routing service named Zuul that is +related to Netflix, it can be found here: +https://github.com/Netflix/zuul + +If you are looking for the Javascript testing tool named Zuul, it +can be found here: +https://github.com/defunctzombie/zuul + Contributing ------------ diff --git a/doc/source/admin/components.rst b/doc/source/admin/components.rst index 18bbfa3f4..d6b0984d2 100644 --- a/doc/source/admin/components.rst +++ b/doc/source/admin/components.rst @@ -8,7 +8,6 @@ Components Zuul is a distributed system consisting of several components, each of which is described below. - .. graphviz:: :align: center @@ -31,7 +30,27 @@ which is described below. Scheduler -- GitHub; } +Each of the Zuul processes may run on the same host, or different +hosts. Within Zuul, the components communicate with the scheduler via +the Gearman protocol, so each Zuul component needs to be able to +connect to the host running the Gearman server (the scheduler has a +built-in Gearman server which is recommended) on the Gearman port -- +TCP port 4730 by default. + +The Zuul scheduler communicates with Nodepool via the ZooKeeper +protocol. Nodepool requires an external ZooKeeper cluster, and the +Zuul scheduler needs to be able to connect to the hosts in that +cluster on TCP port 2181. + +Both the Nodepool launchers and Zuul executors need to be able to +communicate with the hosts which nodepool provides. If these are on +private networks, the Executors will need to be able to route traffic +to them. +If statsd is enabled, every service needs to be able to emit data to +statsd. Statsd can be configured to run on each host and forward +data, or services may emit to a centralized statsd collector. Statsd +listens on UDP port 8125 by default. All Zuul processes read the ``/etc/zuul/zuul.conf`` file (an alternate location may be supplied on the command line) which uses an INI file @@ -154,6 +173,23 @@ connections to remote systems which have been configured, enqueues items into pipelines, distributes jobs to executors, and reports results. +The scheduler includes a Gearman server which is used to communicate +with other components of Zuul. It is possible to use an external +Gearman server, but the built-in server is well-tested and +recommended. If the built-in server is used, other Zuul hosts will +need to be able to connect to the scheduler on the Gearman port, TCP +port 4730. It is also strongly recommended to use SSL certs with +Gearman, as secrets are transferred from the scheduler to executors +over this link. + +The scheduler must be able to connect to the ZooKeeper cluster used by +Nodepool in order to request nodes. It does not need to connect +directly to the nodes themselves, however -- that function is handled +by the Executors. + +It must also be able to connect to any services for which connections +are configured (Gerrit, GitHub, etc). + Configuration ~~~~~~~~~~~~~ @@ -280,6 +316,10 @@ perform them, large numbers may impact their ability to run jobs. Therefore, administrators may wish to run standalone mergers in order to reduce the load on executors. +Mergers need to be able to connect to the Gearman server (usually the +scheduler host) as well as any services for which connections are +configured (Gerrit, GitHub, etc). + Configuration ~~~~~~~~~~~~~ @@ -358,6 +398,11 @@ perform any tasks normally performed by standalone mergers. Because the executor performs both roles, small Zuul installations may not need to run standalone mergers. +Executors need to be able to connect to the Gearman server (usually +the scheduler host), any services for which connections are configured +(Gerrit, GitHub, etc), as well as directly to the hosts which Nodepool +provides. + Trusted and Untrusted Playbooks ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -508,7 +553,7 @@ The following sections of ``zuul.conf`` are used by the executor: significant protections against malicious users and accidental breakage in playbooks. As such, `nullwrap` is not recommended for use in production. - + This option, and thus, `nullwrap`, may be removed in the future. `bubblewrap` has become integral to securely operating Zuul. If you have a valid use case for it, we encourage you to let us know. @@ -577,6 +622,12 @@ The Zuul web server currently acts as a websocket interface to live log streaming. Eventually, it will serve as the single process handling all HTTP interactions with Zuul. +Web servers need to be able to connect to the Gearman server (usually +the scheduler host). If the SQL reporter is used, they need to be +able to connect to the database it reports to in order to support the +dashboard. If a GitHub connection is configured, they need to be +reachable by GitHub so they may receive notifications. + Configuration ~~~~~~~~~~~~~ @@ -636,6 +687,10 @@ For example:: The above would stream the logs for the build identified by `UUID`. +Finger gateway servers need to be able to connect to the Gearman +server (usually the scheduler host), as well as the console streaming +port on the executors (usually 7900). + Configuration ~~~~~~~~~~~~~ diff --git a/doc/source/admin/connections.rst b/doc/source/admin/connections.rst index 55ac629c1..b04dbb06a 100644 --- a/doc/source/admin/connections.rst +++ b/doc/source/admin/connections.rst @@ -33,6 +33,15 @@ may appear as: driver=gerrit server=review.example.com +Zuul needs to use a single connection to look up information about +changes hosted by a given system. When it looks up changes, it will +do so using the first connection it finds that matches the server name +it's looking for. It's generally best to use only a single connection +for a given server, however, if you need more than one (for example, +to satisfy unique reporting requirements) be sure to list the primary +connection first as that is what Zuul will use to look up all changes +for that server. + .. _drivers: Drivers diff --git a/doc/source/index.rst b/doc/source/index.rst index 677e9584c..6e1b52e21 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -12,6 +12,14 @@ are installing or operating a Zuul system, you will also find the :doc:`admin/index` useful. If you want help make Zuul itself better, take a look at the :doc:`developer/index`. +If you are looking for the Edge routing service named Zuul that is +related to Netflix, it can be found here: +https://github.com/Netflix/zuul + +If you are looking for the Javascript testing tool named Zuul, it +can be found here: +https://github.com/defunctzombie/zuul + Contents: .. toctree:: diff --git a/doc/source/user/config.rst b/doc/source/user/config.rst index fff673b55..525cb3892 100644 --- a/doc/source/user/config.rst +++ b/doc/source/user/config.rst @@ -539,6 +539,13 @@ Here is an example of two job definitions: specified in a project's pipeline, set this attribute to ``true``. + .. attr:: protected + :default: false + + When set to ``true`` only jobs defined in the same project may inherit + from this job. Once this is set to ``true`` it cannot be reset to + ``false``. + .. attr:: success-message :default: SUCCESS diff --git a/doc/source/user/gating.rst b/doc/source/user/gating.rst index 795df723d..543a8cccc 100644 --- a/doc/source/user/gating.rst +++ b/doc/source/user/gating.rst @@ -246,11 +246,25 @@ Zuul's cross-project dependencies behave like a directed acyclic graph between changes in different git repositories. Change A may depend on B, but B may not depend on A. -.. TODO: update for v3 crd syntax +To use them, include ``Depends-On: <change-url>`` in the footer of a +commit message. For example, a change which depends on a GitHub pull +request (PR #4) might have the following footer:: -To use them, include ``Depends-On: <gerrit-change-id>`` in the footer of -a commit message. Use the full Change-ID ('I' + 40 characters). + Depends-On: https://github.com/example/test/pull/4 +And a change which depends on a Gerrit change (change number 3):: + + Depends-On: https://review.example.com/3 + +Changes may depend on changes in any other project, even projects not +on the same system (i.e., a Gerrit change may depend on a GitHub pull +request). + +.. note:: + + An older syntax of specifying dependencies using Gerrit change-ids + is still supported, however it is deprecated and will be removed in + a future version. Dependent Pipeline ~~~~~~~~~~~~~~~~~~ @@ -277,7 +291,7 @@ dependent pipeline, B will appear first and A will follow: B_status [ class = greendot ] B_status -- A_status - 'Change B\nChange-Id: Iabc' <- 'Change A\nDepends-On: Iabc' + 'Change B\nURL: .../4' <- 'Change A\nDepends-On: .../4' } If tests for B fail, both B and A will be removed from the pipeline, and @@ -328,7 +342,7 @@ up as red or green (depending on the jobs results): B_status [class = "dot", color = grey] B_status -- A_status - "Change B" <- "Change A\nDepends-On: B" + "Change B\nURL: .../4" <- "Change A\nDepends-On: .../4" } This is to indicate that the grey changes are only there to establish @@ -337,56 +351,13 @@ will show up as a grey dot when used as a dependency, but separately and additionally will appear as its own red or green dot for its test. -.. TODO: relevant for v3? - Multiple Changes ~~~~~~~~~~~~~~~~ -A Gerrit change ID may refer to multiple changes (on multiple branches -of the same project, or even multiple projects). In these cases, Zuul -will treat all of the changes with that change ID as dependencies. So -if you say that change in project A Depends-On a change ID that has -changes in two branches of project B, then when testing the change to -project A, both project B changes will be applied, and when deciding -whether the project A change can merge, both changes must merge ahead -of it. - -.. blockdiag:: - :align: center - - blockdiag crdmultirepos { - orientation = portrait - span_width = 30 - class greendot [ - label = "", - shape = circle, - color = green, - width = 20, height = 20 - ] - - B_stable_status [ class = "greendot" ] - B_master_status [ class = "greendot" ] - A_status [ class = "greendot" ] - B_stable_status -- B_master_status -- A_status - - A [ label = "Repo A\nDepends-On: I123" ] - group { - orientation = portrait - label = "Dependencies" - color = "lightgray" - - B_stable [ label = "Repo B\nChange-Id: I123\nBranch: stable" ] - B_master [ label = "Repo B\nChange-Id: I123\nBranch: master" ] - } - B_master <- A - B_stable <- A - - } - -A change may depend on more than one Gerrit change ID as well. So it -is possible for a change in project A to depend on a change in project -B and a change in project C. Simply add more ``Depends-On:`` lines to -the commit message footer. +A change may list more than one dependency by simply adding more +``Depends-On:`` lines to the commit message footer. It is possible +for a change in project A to depend on a change in project B and a +change in project C. .. blockdiag:: :align: center @@ -406,20 +377,18 @@ the commit message footer. A_status [ class = "greendot" ] C_status -- B_status -- A_status - A [ label = "Repo A\nDepends-On: I123\nDepends-On: Iabc" ] + A [ label = "Repo A\nDepends-On: .../3\nDepends-On: .../4" ] group { orientation = portrait label = "Dependencies" color = "lightgray" - B [ label = "Repo B\nChange-Id: I123" ] - C [ label = "Repo C\nChange-Id: Iabc" ] + B [ label = "Repo B\nURL: .../3" ] + C [ label = "Repo C\nURL: .../4" ] } B, C <- A } -.. TODO: update for v3 - Cycles ~~~~~~ diff --git a/doc/source/user/jobs.rst b/doc/source/user/jobs.rst index 4b6255b20..9ec464671 100644 --- a/doc/source/user/jobs.rst +++ b/doc/source/user/jobs.rst @@ -63,12 +63,15 @@ in this format. Note that these git repositories are located on the executor; in order to be useful to most kinds of jobs, they will need to be present on -the test nodes. The ``base`` job in the standard library contains a +the test nodes. The ``base`` job in the standard library (see +`zuul-base-jobs documentation`_ for details) contains a pre-playbook which copies the repositories to all of the job's nodes. It is recommended to always inherit from this base job to ensure that behavior. -.. TODO: link to base job documentation and/or document src (and logs?) directory +.. _zuul-base-jobs documentation: https://docs.openstack.org/infra/zuul-base-jobs/jobs.html#job-base + +.. TODO: document src (and logs?) directory Variables --------- diff --git a/requirements.txt b/requirements.txt index 193c64e71..39a2b0268 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,5 @@ cryptography>=1.6 cachecontrol pyjwt iso8601 -yarl>=0.11,<1.0 aiohttp uvloop;python_version>='3.5' diff --git a/tests/fixtures/config/protected/git/common-config/zuul.yaml b/tests/fixtures/config/protected/git/common-config/zuul.yaml new file mode 100644 index 000000000..c941573e6 --- /dev/null +++ b/tests/fixtures/config/protected/git/common-config/zuul.yaml @@ -0,0 +1,16 @@ +- pipeline: + name: check + manager: independent + trigger: + gerrit: + - event: patchset-created + success: + gerrit: + Verified: 1 + failure: + gerrit: + Verified: -1 + +- job: + name: base + parent: null diff --git a/tests/fixtures/config/protected/git/org_project/playbooks/job-protected.yaml b/tests/fixtures/config/protected/git/org_project/playbooks/job-protected.yaml new file mode 100644 index 000000000..f679dceae --- /dev/null +++ b/tests/fixtures/config/protected/git/org_project/playbooks/job-protected.yaml @@ -0,0 +1,2 @@ +- hosts: all + tasks: [] diff --git a/tests/fixtures/config/protected/git/org_project/zuul.yaml b/tests/fixtures/config/protected/git/org_project/zuul.yaml new file mode 100644 index 000000000..95f33df6f --- /dev/null +++ b/tests/fixtures/config/protected/git/org_project/zuul.yaml @@ -0,0 +1,9 @@ +- job: + name: job-protected + protected: true + run: playbooks/job-protected.yaml + +- project: + name: org/project + check: + jobs: [] diff --git a/tests/fixtures/config/protected/git/org_project1/README b/tests/fixtures/config/protected/git/org_project1/README new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/tests/fixtures/config/protected/git/org_project1/README @@ -0,0 +1 @@ +test diff --git a/tests/fixtures/config/protected/git/org_project1/playbooks/job-child-notok.yaml b/tests/fixtures/config/protected/git/org_project1/playbooks/job-child-notok.yaml new file mode 100644 index 000000000..f679dceae --- /dev/null +++ b/tests/fixtures/config/protected/git/org_project1/playbooks/job-child-notok.yaml @@ -0,0 +1,2 @@ +- hosts: all + tasks: [] diff --git a/tests/fixtures/config/protected/git/org_project1/playbooks/placeholder b/tests/fixtures/config/protected/git/org_project1/playbooks/placeholder new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/fixtures/config/protected/git/org_project1/playbooks/placeholder diff --git a/tests/fixtures/config/protected/main.yaml b/tests/fixtures/config/protected/main.yaml new file mode 100644 index 000000000..5f57245cc --- /dev/null +++ b/tests/fixtures/config/protected/main.yaml @@ -0,0 +1,9 @@ +- tenant: + name: tenant-one + source: + gerrit: + config-projects: + - common-config + untrusted-projects: + - org/project + - org/project1 diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 197b5256d..c45da94cb 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -115,7 +115,7 @@ class TestSQLConnection(ZuulDBTestCase): 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['patchset']) self.assertEqual('SUCCESS', buildset0['result']) self.assertEqual('Build succeeded.', buildset0['message']) self.assertEqual('tenant-one', buildset0['tenant']) @@ -141,7 +141,7 @@ class TestSQLConnection(ZuulDBTestCase): 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['patchset']) self.assertEqual('FAILURE', buildset1['result']) self.assertEqual('Build failed.', buildset1['message']) @@ -194,7 +194,7 @@ class TestSQLConnection(ZuulDBTestCase): 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]['patchset']) self.assertEqual('SUCCESS', buildsets_resultsdb[0]['result']) self.assertEqual('Build succeeded.', buildsets_resultsdb[0]['message']) @@ -215,7 +215,7 @@ class TestSQLConnection(ZuulDBTestCase): 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]['patchset']) self.assertEqual('FAILURE', buildsets_resultsdb_failures[0]['result']) self.assertEqual( 'Build failed.', buildsets_resultsdb_failures[0]['message']) diff --git a/tests/unit/test_gerrit_crd.py b/tests/unit/test_gerrit_crd.py index 732bc3d60..a8924b902 100644 --- a/tests/unit/test_gerrit_crd.py +++ b/tests/unit/test_gerrit_crd.py @@ -90,6 +90,40 @@ class TestGerritCRD(ZuulTestCase): 'project-merge', 'org/project1').changes self.assertEqual(changes, '2,1 1,1') + def test_crd_gate_triangle(self): + A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A') + B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B') + C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C') + A.addApproval('Code-Review', 2) + B.addApproval('Code-Review', 2) + C.addApproval('Code-Review', 2) + A.addApproval('Approved', 1) + B.addApproval('Approved', 1) + + # C-->B + # \ / + # v + # A + + # C Depends-On: A + C.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % ( + C.subject, A.data['url']) + # B Depends-On: A + B.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % ( + B.subject, A.data['url']) + # C git-depends on B + C.setDependsOn(B, 1) + self.fake_gerrit.addEvent(C.addApproval('Approved', 1)) + self.waitUntilSettled() + + self.assertEqual(A.reported, 2) + self.assertEqual(B.reported, 2) + self.assertEqual(C.reported, 2) + self.assertEqual(A.data['status'], 'MERGED') + self.assertEqual(B.data['status'], 'MERGED') + self.assertEqual(C.data['status'], 'MERGED') + self.assertEqual(self.history[-1].changes, '1,1 2,1 3,1') + def test_crd_branch(self): "Test cross-repo dependencies in multiple branches" @@ -257,6 +291,7 @@ class TestGerritCRD(ZuulTestCase): B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B') A.addApproval('Code-Review', 2) B.addApproval('Code-Review', 2) + B.addApproval('Approved', 1) # A -> B -> A (via commit-depends) @@ -522,6 +557,30 @@ class TestGerritCRD(ZuulTestCase): for job in self.history: self.assertEqual(len(job.changes.split()), 1) + def test_crd_check_triangle(self): + A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A') + B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B') + C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C') + + # C-->B + # \ / + # v + # A + + # C Depends-On: A + C.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % ( + C.subject, A.data['url']) + # B Depends-On: A + B.data['commitMessage'] = '%s\n\nDepends-On: %s\n' % ( + B.subject, A.data['url']) + # C git-depends on B + C.setDependsOn(B, 1) + self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1)) + self.waitUntilSettled() + + self.assertEqual(C.reported, 1) + self.assertEqual(self.history[0].changes, '1,1 2,1 3,1') + @simple_layout('layouts/three-projects.yaml') def test_crd_check_transitive(self): "Test transitive cross-repo dependencies" diff --git a/tests/unit/test_gerrit_legacy_crd.py b/tests/unit/test_gerrit_legacy_crd.py index c711e4d95..90c93ecae 100644 --- a/tests/unit/test_gerrit_legacy_crd.py +++ b/tests/unit/test_gerrit_legacy_crd.py @@ -260,6 +260,7 @@ class TestGerritLegacyCRD(ZuulTestCase): B = self.fake_gerrit.addFakeChange('org/project2', 'master', 'B') A.addApproval('Code-Review', 2) B.addApproval('Code-Review', 2) + B.addApproval('Approved', 1) # A -> B -> A (via commit-depends) diff --git a/tests/unit/test_git_driver.py b/tests/unit/test_git_driver.py index b9e6c6e92..e8762d0d2 100644 --- a/tests/unit/test_git_driver.py +++ b/tests/unit/test_git_driver.py @@ -24,6 +24,11 @@ class TestGitDriver(ZuulTestCase): config_file = 'zuul-git-driver.conf' tenant_config_file = 'config/git-driver/main.yaml' + def setUp(self): + super(TestGitDriver, self).setUp() + self.git_connection = self.sched.connections.getSource('git').\ + connection + def setup_config(self): super(TestGitDriver, self).setup_config() self.config.set('connection git', 'baseurl', self.upstream_root) @@ -70,8 +75,8 @@ class TestGitDriver(ZuulTestCase): self.addCommitToRepo( 'common-config', 'Change zuul.yaml configuration', files) - # Let some time for the tenant reconfiguration to happen - time.sleep(2) + # Wait for the tenant reconfiguration to happen + count = self.waitForEvent() self.waitUntilSettled() A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A') @@ -109,8 +114,8 @@ class TestGitDriver(ZuulTestCase): # Restart the git watcher self.sched.connections.getSource('git').connection.w_pause = False - # Let some time for the tenant reconfiguration to happen - time.sleep(2) + # Wait for the tenant reconfiguration to happen + self.waitForEvent(count) self.waitUntilSettled() A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A') @@ -123,23 +128,33 @@ class TestGitDriver(ZuulTestCase): def ensure_watcher_has_context(self): # Make sure watcher have read initial refs shas - cnx = self.sched.connections.getSource('git').connection delay = 0.1 max_delay = 1 - while not cnx.projects_refs: + while not self.git_connection.projects_refs: time.sleep(delay) max_delay -= delay if max_delay <= 0: raise Exception("Timeout waiting for initial read") + return self.git_connection.watcher_thread._event_count + + def waitForEvent(self, initial_count=0): + delay = 0.1 + max_delay = 1 + while self.git_connection.watcher_thread._event_count <= initial_count: + time.sleep(delay) + max_delay -= delay + if max_delay <= 0: + raise Exception("Timeout waiting for event") + return self.git_connection.watcher_thread._event_count @simple_layout('layouts/basic-git.yaml', driver='git') def test_ref_updated_event(self): - self.ensure_watcher_has_context() + count = self.ensure_watcher_has_context() # Add a commit to trigger a ref-updated event self.addCommitToRepo( 'org/project', 'A change for ref-updated', {'f1': 'Content'}) - # Let some time for the git watcher to detect the ref-update event - time.sleep(0.2) + # Wait for the git watcher to detect the ref-update event + self.waitForEvent(count) self.waitUntilSettled() self.assertEqual(len(self.history), 1) self.assertEqual('SUCCESS', @@ -147,12 +162,12 @@ class TestGitDriver(ZuulTestCase): @simple_layout('layouts/basic-git.yaml', driver='git') def test_ref_created(self): - self.ensure_watcher_has_context() + count = self.ensure_watcher_has_context() # Tag HEAD to trigger a ref-updated event self.addTagToRepo( 'org/project', 'atag', 'HEAD') - # Let some time for the git watcher to detect the ref-update event - time.sleep(0.2) + # Wait for the git watcher to detect the ref-update event + self.waitForEvent(count) self.waitUntilSettled() self.assertEqual(len(self.history), 1) self.assertEqual('SUCCESS', @@ -160,12 +175,12 @@ class TestGitDriver(ZuulTestCase): @simple_layout('layouts/basic-git.yaml', driver='git') def test_ref_deleted(self): - self.ensure_watcher_has_context() + count = self.ensure_watcher_has_context() # Delete default tag init to trigger a ref-updated event self.delTagFromRepo( 'org/project', 'init') - # Let some time for the git watcher to detect the ref-update event - time.sleep(0.2) + # Wait for the git watcher to detect the ref-update event + self.waitForEvent(count) self.waitUntilSettled() # Make sure no job as run as ignore-delete is True by default self.assertEqual(len(self.history), 0) diff --git a/tests/unit/test_github_driver.py b/tests/unit/test_github_driver.py index 6ab1a2650..3942b0be8 100644 --- a/tests/unit/test_github_driver.py +++ b/tests/unit/test_github_driver.py @@ -50,6 +50,12 @@ class TestGithubDriver(ZuulTestCase): self.assertEqual(str(A.head_sha), zuulvars['patchset']) self.assertEqual('master', zuulvars['branch']) self.assertEqual(1, len(A.comments)) + self.assertThat( + A.comments[0], + MatchesRegex('.*\[project-test1 \]\(.*\).*', re.DOTALL)) + self.assertThat( + A.comments[0], + MatchesRegex('.*\[project-test2 \]\(.*\).*', re.DOTALL)) self.assertEqual(2, len(self.history)) # test_pull_unmatched_branch_event(self): diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py index 2779e6e66..163a58b90 100755 --- a/tests/unit/test_v3.py +++ b/tests/unit/test_v3.py @@ -73,6 +73,110 @@ class TestMultipleTenants(AnsibleZuulTestCase): "not affect tenant one") +class TestProtected(ZuulTestCase): + + tenant_config_file = 'config/protected/main.yaml' + + def test_protected_ok(self): + # test clean usage of final parent job + in_repo_conf = textwrap.dedent( + """ + - job: + name: job-protected + protected: true + run: playbooks/job-protected.yaml + + - project: + name: org/project + check: + jobs: + - job-child-ok + + - job: + name: job-child-ok + parent: job-protected + + - project: + name: org/project + check: + jobs: + - job-child-ok + + """) + + 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_protected_reset(self): + # try to reset protected flag + in_repo_conf = textwrap.dedent( + """ + - job: + name: job-protected + protected: true + run: playbooks/job-protected.yaml + + - job: + name: job-child-reset-protected + parent: job-protected + protected: false + + - project: + name: org/project + check: + jobs: + - job-child-reset-protected + + """) + + 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 reset protected attribute', A.messages[0]) + + def test_protected_inherit_not_ok(self): + # try to inherit from a protected job in different project + in_repo_conf = textwrap.dedent( + """ + - job: + name: job-child-notok + run: playbooks/job-child-notok.yaml + parent: job-protected + + - project: + name: org/project1 + check: + jobs: + - job-child-notok + + """) + + 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.assertEqual(A.reported, 1) + self.assertEqual(A.patchsets[-1]['approvals'][0]['value'], '-1') + self.assertIn( + "which is defined in review.example.com/org/project is protected " + "and cannot be inherited from other projects.", A.messages[0]) + + class TestFinal(ZuulTestCase): tenant_config_file = 'config/final/main.yaml' diff --git a/tools/encrypt_secret.py b/tools/encrypt_secret.py index c0ee9be64..4cb166631 100755 --- a/tools/encrypt_secret.py +++ b/tools/encrypt_secret.py @@ -46,6 +46,8 @@ def main(): # TODO(jeblair): Throw a fit if SSL is not used. parser.add_argument('project', help="The name of the project.") + parser.add_argument('--strip', action='store_true', default=False, + help="Strip whitespace from beginning/end of input.") parser.add_argument('--infile', default=None, help="A filename whose contents will be encrypted. " @@ -68,6 +70,8 @@ def main(): plaintext = sys.stdin.read() plaintext = plaintext.encode("utf-8") + if args.strip: + plaintext = plaintext.strip() pubkey_file = tempfile.NamedTemporaryFile(delete=False) try: diff --git a/tools/github-debugging.py b/tools/github-debugging.py index 171627ab9..101fd1118 100644..100755 --- a/tools/github-debugging.py +++ b/tools/github-debugging.py @@ -1,55 +1,71 @@ -import github3 +#!/usr/bin/env python3 + import logging -import time + +from zuul.driver.github.githubconnection import GithubConnection +from zuul.driver.github import GithubDriver +from zuul.model import Change, Project # This is a template with boilerplate code for debugging github issues # TODO: for real use override the following variables -url = 'https://example.com' +server = 'github.com' api_token = 'xxxx' -org = 'org' -project = 'project' -pull_nr = 3 +org = 'example' +repo = 'sandbox' +pull_nr = 8 + + +def configure_logging(context): + stream_handler = logging.StreamHandler() + logger = logging.getLogger(context) + logger.addHandler(stream_handler) + logger.setLevel(logging.DEBUG) + + +# uncomment for more logging +# configure_logging('urllib3') +# configure_logging('github3') +# configure_logging('cachecontrol') + + +# This is all that's needed for getting a usable github connection +def create_connection(server, api_token): + driver = GithubDriver() + connection_config = { + 'server': server, + 'api_token': api_token, + } + conn = GithubConnection(driver, 'github', connection_config) + conn._authenticateGithubAPI() + return conn -# Send the logs to stderr as well -stream_handler = logging.StreamHandler() +def get_change(connection: GithubConnection, + org: str, + repo: str, + pull: int) -> Change: + p = Project("%s/%s" % (org, repo), connection.source) + github = connection.getGithubClient(p) + pr = github.pull_request(org, repo, pull) + sha = pr.head.sha + return conn._getChange(p, pull, sha, True) -logger_urllib3 = logging.getLogger('requests.packages.logger_urllib3') -# logger_urllib3.addHandler(stream_handler) -logger_urllib3.setLevel(logging.DEBUG) -logger = logging.getLogger('github3') -# logger.addHandler(stream_handler) -logger.setLevel(logging.DEBUG) +# create github connection +conn = create_connection(server, api_token) -github = github3.GitHubEnterprise(url) +# Now we can do anything we want with the connection, e.g. check canMerge for +# a pull request. +change = get_change(conn, org, repo, pull_nr) +print(conn.canMerge(change, {'cc/gate2'})) -# This is the currently broken cache adapter, enable or replace it to debug -# caching -# import cachecontrol -# from cachecontrol.cache import DictCache -# cache_adapter = cachecontrol.CacheControlAdapter( -# DictCache(), -# cache_etags=True) +# Or just use the github object. +# github = conn.getGithubClient() # -# github.session.mount('http://', cache_adapter) -# github.session.mount('https://', cache_adapter) - - -github.login(token=api_token) - -i = 0 -while True: - pr = github.pull_request(org, project, pull_nr) - prdict = pr.as_dict() - issue = pr.issue() - labels = list(issue.labels()) - print(labels) - i += 1 - print(i) - time.sleep(1) +# repository = github.repository(org, repo) +# print(repository.as_dict()) diff --git a/zuul/cmd/__init__.py b/zuul/cmd/__init__.py index 07d4a8d08..bf11c6fc9 100755 --- a/zuul/cmd/__init__.py +++ b/zuul/cmd/__init__.py @@ -14,6 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. +import abc import argparse import configparser import daemon @@ -156,7 +157,7 @@ class ZuulApp(object): self.connections.configure(self.config, source_only) -class ZuulDaemonApp(ZuulApp): +class ZuulDaemonApp(ZuulApp, metaclass=abc.ABCMeta): def createParser(self): parser = super(ZuulDaemonApp, self).createParser() parser.add_argument('-d', dest='nodaemon', action='store_true', @@ -169,6 +170,21 @@ class ZuulDaemonApp(ZuulApp): expand_user=True) return pid_fn + @abc.abstractmethod + def exit_handler(self, signum, frame): + """ + This is a signal handler which is called on SIGINT and SIGTERM and must + take care of stopping the application. + """ + pass + + @abc.abstractmethod + def run(self): + """ + This is the main run method of the application. + """ + pass + def main(self): self.parseArguments() self.readConfig() @@ -176,7 +192,13 @@ class ZuulDaemonApp(ZuulApp): pid_fn = self.getPidFile() pid = pid_file_module.TimeoutPIDLockFile(pid_fn, 10) + # Early register the stack dump handler for all zuul apps. This makes + # it possible to also gather stack dumps during startup hangs. + signal.signal(signal.SIGUSR2, stack_dump_handler) + if self.args.nodaemon: + signal.signal(signal.SIGTERM, self.exit_handler) + signal.signal(signal.SIGINT, self.exit_handler) self.run() else: # Exercise the pidfile before we do anything else (including diff --git a/zuul/cmd/executor.py b/zuul/cmd/executor.py index ad7aaa837..5b06f0cca 100755 --- a/zuul/cmd/executor.py +++ b/zuul/cmd/executor.py @@ -17,7 +17,6 @@ import logging import os import sys -import signal import tempfile import zuul.cmd @@ -49,9 +48,8 @@ class Executor(zuul.cmd.ZuulDaemonApp): if self.args.command: self.args.nodaemon = True - def exit_handler(self): + def exit_handler(self, signum, frame): self.executor.stop() - self.executor.join() def start_log_streamer(self): pipe_read, pipe_write = os.pipe() @@ -108,18 +106,7 @@ class Executor(zuul.cmd.ZuulDaemonApp): log_streaming_port=self.finger_port) self.executor.start() - signal.signal(signal.SIGUSR2, zuul.cmd.stack_dump_handler) - - if self.args.nodaemon: - while True: - try: - signal.pause() - except KeyboardInterrupt: - print("Ctrl + C: asking executor to exit nicely...\n") - self.exit_handler() - sys.exit(0) - else: - self.executor.join() + self.executor.join() def main(): diff --git a/zuul/cmd/fingergw.py b/zuul/cmd/fingergw.py index 920eed8f2..0d47f0848 100644 --- a/zuul/cmd/fingergw.py +++ b/zuul/cmd/fingergw.py @@ -14,7 +14,6 @@ # under the License. import logging -import signal import sys import zuul.cmd @@ -47,6 +46,9 @@ class FingerGatewayApp(zuul.cmd.ZuulDaemonApp): if self.args.command: self.args.nodaemon = True + def exit_handler(self, signum, frame): + self.stop() + def run(self): ''' Main entry point for the FingerGatewayApp. @@ -84,19 +86,7 @@ class FingerGatewayApp(zuul.cmd.ZuulDaemonApp): self.log.info('Starting Zuul finger gateway app') self.gateway.start() - if self.args.nodaemon: - # NOTE(Shrews): When running in non-daemon mode, although sending - # the 'stop' command via the command socket will shutdown the - # gateway, it's still necessary to Ctrl+C to stop the app. - while True: - try: - signal.pause() - except KeyboardInterrupt: - print("Ctrl + C: asking gateway to exit nicely...\n") - self.stop() - break - else: - self.gateway.wait() + self.gateway.wait() self.log.info('Stopped Zuul finger gateway app') diff --git a/zuul/cmd/merger.py b/zuul/cmd/merger.py index 7db1beeaf..390191f4a 100755 --- a/zuul/cmd/merger.py +++ b/zuul/cmd/merger.py @@ -14,7 +14,6 @@ # License for the specific language governing permissions and limitations # under the License. -import signal import sys import zuul.cmd @@ -42,9 +41,8 @@ class Merger(zuul.cmd.ZuulDaemonApp): if self.args.command: self.args.nodaemon = True - def exit_handler(self): + def exit_handler(self, signum, frame): self.merger.stop() - self.merger.join() def run(self): # See comment at top of file about zuul imports @@ -61,18 +59,7 @@ class Merger(zuul.cmd.ZuulDaemonApp): self.connections) self.merger.start() - signal.signal(signal.SIGUSR2, zuul.cmd.stack_dump_handler) - - if self.args.nodaemon: - while True: - try: - signal.pause() - except KeyboardInterrupt: - print("Ctrl + C: asking merger to exit nicely...\n") - self.exit_handler() - sys.exit(0) - else: - self.merger.join() + self.merger.join() def main(): diff --git a/zuul/cmd/scheduler.py b/zuul/cmd/scheduler.py index 7722d6e9c..3cffa1010 100755 --- a/zuul/cmd/scheduler.py +++ b/zuul/cmd/scheduler.py @@ -61,14 +61,9 @@ class Scheduler(zuul.cmd.ZuulDaemonApp): self.log.exception("Reconfiguration failed:") signal.signal(signal.SIGHUP, self.reconfigure_handler) - def exit_handler(self): + def exit_handler(self, signum, frame): self.sched.exit() - self.sched.join() - self.stop_gear_server() - - def term_handler(self, signum, frame): self.stop_gear_server() - os._exit(0) def start_gear_server(self): pipe_read, pipe_write = os.pipe() @@ -126,7 +121,6 @@ class Scheduler(zuul.cmd.ZuulDaemonApp): import zuul.webapp import zuul.zk - signal.signal(signal.SIGUSR2, zuul.cmd.stack_dump_handler) if (self.config.has_option('gearman_server', 'start') and self.config.getboolean('gearman_server', 'start')): self.start_gear_server() @@ -179,16 +173,7 @@ class Scheduler(zuul.cmd.ZuulDaemonApp): signal.signal(signal.SIGHUP, self.reconfigure_handler) - if self.args.nodaemon: - while True: - try: - signal.pause() - except KeyboardInterrupt: - print("Ctrl + C: asking scheduler to exit nicely...\n") - self.exit_handler() - sys.exit(0) - else: - self.sched.join() + self.sched.join() def main(): diff --git a/zuul/cmd/web.py b/zuul/cmd/web.py index 4687de653..78392db51 100755 --- a/zuul/cmd/web.py +++ b/zuul/cmd/web.py @@ -89,12 +89,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp): name='web') self.thread.start() - try: - signal.pause() - except KeyboardInterrupt: - print("Ctrl + C: asking web server to exit nicely...\n") - self.exit_handler(signal.SIGINT, None) - self.thread.join() loop.stop() loop.close() @@ -106,8 +100,6 @@ class WebServer(zuul.cmd.ZuulDaemonApp): self.configure_connections() - signal.signal(signal.SIGUSR2, zuul.cmd.stack_dump_handler) - try: self._run() except Exception: diff --git a/zuul/configloader.py b/zuul/configloader.py index 3a7e9b970..d62237043 100644 --- a/zuul/configloader.py +++ b/zuul/configloader.py @@ -474,6 +474,7 @@ class JobParser(object): # Attributes of a job that can also be used in Project and ProjectTemplate job_attributes = {'parent': vs.Any(str, None), 'final': bool, + 'protected': bool, 'failure-message': str, 'success-message': str, 'failure-url': str, @@ -513,6 +514,7 @@ class JobParser(object): simple_attributes = [ 'final', + 'protected', 'timeout', 'workspace', 'voting', diff --git a/zuul/driver/git/gitconnection.py b/zuul/driver/git/gitconnection.py index 03b24cadc..1886cfcca 100644 --- a/zuul/driver/git/gitconnection.py +++ b/zuul/driver/git/gitconnection.py @@ -38,6 +38,8 @@ class GitWatcher(threading.Thread): self.poll_delay = poll_delay self._stopped = False self.projects_refs = self.git_connection.projects_refs + # This is used by the test framework + self._event_count = 0 def compareRefs(self, project, refs): partial_events = [] @@ -112,6 +114,7 @@ class GitWatcher(threading.Thread): self.git_connection.logEvent(event) # Pass the event to the scheduler self.git_connection.sched.addEvent(event) + self._event_count += 1 except Exception as e: self.log.debug("Unexpected issue in _run loop: %s" % str(e)) diff --git a/zuul/driver/github/githubconnection.py b/zuul/driver/github/githubconnection.py index a7aefe0cd..b766c6f55 100644 --- a/zuul/driver/github/githubconnection.py +++ b/zuul/driver/github/githubconnection.py @@ -684,8 +684,7 @@ class GithubConnection(BaseConnection): change.files = self.getPushedFileNames(event) return change - def _getChange(self, project, number, patchset=None, refresh=False, - history=None): + def _getChange(self, project, number, patchset=None, refresh=False): key = (project.name, number, patchset) change = self._change_cache.get(key) if change and not refresh: @@ -697,7 +696,7 @@ class GithubConnection(BaseConnection): change.patchset = patchset self._change_cache[key] = change try: - self._updateChange(change, history) + self._updateChange(change) except Exception: if key in self._change_cache: del self._change_cache[key] @@ -769,13 +768,7 @@ class GithubConnection(BaseConnection): return changes - def _updateChange(self, change, history=None): - # If this change is already in the history, we have a cyclic - # dependency loop and we do not need to update again, since it - # was done in a previous frame. - if history and (change.project.name, change.number) in history: - return change - + def _updateChange(self, change): self.log.info("Updating %s" % (change,)) change.pr = self.getPull(change.project.name, change.number) change.ref = "refs/pull/%s/head" % change.number @@ -794,12 +787,6 @@ class GithubConnection(BaseConnection): change.updated_at = self._ghTimestampToDate( change.pr.get('updated_at')) - if history is None: - history = [] - else: - history = history[:] - history.append((change.project.name, change.number)) - self.sched.onChangeUpdated(change) return change diff --git a/zuul/driver/github/githubreporter.py b/zuul/driver/github/githubreporter.py index 505757fa2..848ae1b3a 100644 --- a/zuul/driver/github/githubreporter.py +++ b/zuul/driver/github/githubreporter.py @@ -75,6 +75,14 @@ class GithubReporter(BaseReporter): msg = self._formatItemReportMergeFailure(item) self.addPullComment(item, msg) + def _formatItemReportJobs(self, item): + # Return the list of jobs portion of the report + ret = '' + jobs_fields = self._getItemReportJobsFields(item) + for job_fields in jobs_fields: + ret += '- [%s](%s) : %s%s%s%s\n' % job_fields + return ret + def addPullComment(self, item, comment=None): message = comment or self._formatItemReport(item) project = item.change.project.name diff --git a/zuul/driver/sql/alembic/versions/19d3a3ebfe1d_change_patchset_to_string.py b/zuul/driver/sql/alembic/versions/19d3a3ebfe1d_change_patchset_to_string.py new file mode 100644 index 000000000..505a1ed73 --- /dev/null +++ b/zuul/driver/sql/alembic/versions/19d3a3ebfe1d_change_patchset_to_string.py @@ -0,0 +1,29 @@ +"""Change patchset to string + +Revision ID: 19d3a3ebfe1d +Revises: cfc0dc45f341 +Create Date: 2018-01-10 07:42:16.546751 + +""" + +# revision identifiers, used by Alembic. +revision = '19d3a3ebfe1d' +down_revision = 'cfc0dc45f341' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +BUILDSET_TABLE = 'zuul_buildset' + + +def upgrade(table_prefix=''): + op.alter_column(table_prefix + BUILDSET_TABLE, + 'patchset', + type_=sa.String(255), + existing_nullable=True) + + +def downgrade(): + raise Exception("Downgrades not supported") diff --git a/zuul/driver/sql/sqlconnection.py b/zuul/driver/sql/sqlconnection.py index 285d0c23f..715d72bba 100644 --- a/zuul/driver/sql/sqlconnection.py +++ b/zuul/driver/sql/sqlconnection.py @@ -92,7 +92,7 @@ class SQLConnection(BaseConnection): sa.Column('pipeline', sa.String(255)), sa.Column('project', sa.String(255)), sa.Column('change', sa.Integer, nullable=True), - sa.Column('patchset', sa.Integer, nullable=True), + sa.Column('patchset', sa.String(255), nullable=True), sa.Column('ref', sa.String(255)), sa.Column('oldrev', sa.String(255)), sa.Column('newrev', sa.String(255)), diff --git a/zuul/manager/dependent.py b/zuul/manager/dependent.py index 20b376d6a..5ad761196 100644 --- a/zuul/manager/dependent.py +++ b/zuul/manager/dependent.py @@ -141,13 +141,16 @@ class DependentPipelineManager(PipelineManager): def enqueueChangesAhead(self, change, quiet, ignore_requirements, change_queue, history=None): - if history and change.number in history: + if history and change in history: # detected dependency cycle self.log.warn("Dependency cycle detected") return False if hasattr(change, 'number'): history = history or [] - history.append(change.number) + history = history + [change] + else: + # Don't enqueue dependencies ahead of a non-change ref. + return True ret = self.checkForChangesNeededBy(change, change_queue) if ret in [True, False]: diff --git a/zuul/manager/independent.py b/zuul/manager/independent.py index 0c2baf010..9da40d582 100644 --- a/zuul/manager/independent.py +++ b/zuul/manager/independent.py @@ -34,13 +34,13 @@ class IndependentPipelineManager(PipelineManager): def enqueueChangesAhead(self, change, quiet, ignore_requirements, change_queue, history=None): - if history and change.number in history: + if history and change in history: # detected dependency cycle self.log.warn("Dependency cycle detected") return False if hasattr(change, 'number'): history = history or [] - history.append(change.number) + history = history + [change] else: # Don't enqueue dependencies ahead of a non-change ref. return True diff --git a/zuul/model.py b/zuul/model.py index bac9e4cc8..29c5a9d7e 100644 --- a/zuul/model.py +++ b/zuul/model.py @@ -845,6 +845,7 @@ class Job(object): semaphore=None, attempts=3, final=False, + protected=None, roles=(), required_projects={}, allowed_projects=None, @@ -862,6 +863,7 @@ class Job(object): inheritance_path=(), parent_data=None, description=None, + protected_origin=None, ) self.inheritable_attributes = {} @@ -1039,12 +1041,21 @@ class Job(object): for k in self.execution_attributes: if (other._get(k) is not None and - k not in set(['final'])): + k not in set(['final', 'protected'])): if self.final: raise Exception("Unable to modify final job %s attribute " "%s=%s with variant %s" % ( repr(self), k, other._get(k), repr(other))) + if self.protected_origin: + # this is a protected job, check origin of job definition + this_origin = self.protected_origin + other_origin = other.source_context.project.canonical_name + if this_origin != other_origin: + raise Exception("Job %s which is defined in %s is " + "protected and cannot be inherited " + "from other projects." + % (repr(self), this_origin)) if k not in set(['pre_run', 'run', 'post_run', 'roles', 'variables', 'required_projects']): # TODO(jeblair): determine if deepcopy is required @@ -1055,6 +1066,17 @@ class Job(object): if other.final != self.attributes['final']: self.final = other.final + # Protected may only be set to true + if other.protected is not None: + # don't allow to reset protected flag + if not other.protected and self.protected_origin: + raise Exception("Unable to reset protected attribute of job" + " %s by job %s" % ( + repr(self), repr(other))) + if not self.protected_origin: + self.protected_origin = \ + other.source_context.project.canonical_name + # We must update roles before any playbook contexts if other._get('roles') is not None: self.addRoles(other.roles) diff --git a/zuul/reporter/__init__.py b/zuul/reporter/__init__.py index ecf88553a..1bff5cb94 100644 --- a/zuul/reporter/__init__.py +++ b/zuul/reporter/__init__.py @@ -109,12 +109,10 @@ class BaseReporter(object, metaclass=abc.ABCMeta): else: return self._formatItemReport(item) - def _formatItemReportJobs(self, item): - # Return the list of jobs portion of the report - ret = '' - + def _getItemReportJobsFields(self, item): + # Extract the report elements from an item config = self.connection.sched.config - + jobs_fields = [] for job in item.getJobs(): build = item.current_build_set.getBuild(job.name) (result, url) = item.formatJobResult(job) @@ -147,6 +145,13 @@ class BaseReporter(object, metaclass=abc.ABCMeta): else: error = '' name = job.name + ' ' - ret += '- %s%s : %s%s%s%s\n' % (name, url, result, error, - elapsed, voting) + jobs_fields.append((name, url, result, error, elapsed, voting)) + return jobs_fields + + def _formatItemReportJobs(self, item): + # Return the list of jobs portion of the report + ret = '' + jobs_fields = self._getItemReportJobsFields(item) + for job_fields in jobs_fields: + ret += '- %s%s : %s%s%s%s\n' % job_fields return ret |