Merge #68 Add support for sending messages
				
					
				
			This commit is contained in:
		
						commit
						6f00f20b3d
					
				
							
								
								
									
										20
									
								
								bin/pungi-fedmsg-notification
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										20
									
								
								bin/pungi-fedmsg-notification
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | #!/usr/bin/env python | ||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | 
 | ||||||
|  | import argparse | ||||||
|  | import fedmsg | ||||||
|  | import json | ||||||
|  | import sys | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def send(cmd, data): | ||||||
|  |     topic = 'compose.%s' % cmd.replace('-', '.').lower() | ||||||
|  |     fedmsg.publish(topic=topic, modname='pungi', msg=data) | ||||||
|  | 
 | ||||||
|  | if __name__ == '__main__': | ||||||
|  |     parser = argparse.ArgumentParser() | ||||||
|  |     parser.add_argument('cmd') | ||||||
|  | 
 | ||||||
|  |     opts = parser.parse_args() | ||||||
|  |     data = json.load(sys.stdin) | ||||||
|  |     send(opts.cmd, data) | ||||||
| @ -26,6 +26,7 @@ locale.setlocale(locale.LC_ALL, "C") | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| COMPOSE = None | COMPOSE = None | ||||||
|  | NOTIFIER = None | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def main(): | def main(): | ||||||
| @ -187,8 +188,19 @@ def main(): | |||||||
| def run_compose(compose): | def run_compose(compose): | ||||||
|     import pungi.phases |     import pungi.phases | ||||||
|     import pungi.metadata |     import pungi.metadata | ||||||
|  |     import pungi.notifier | ||||||
|  | 
 | ||||||
|  |     errors = [] | ||||||
|  | 
 | ||||||
|  |     # initializer notifier | ||||||
|  |     compose.notifier = pungi.notifier.PungiNotifier(compose) | ||||||
|  |     try: | ||||||
|  |         compose.notifier.validate() | ||||||
|  |     except ValueError as ex: | ||||||
|  |         errors.extend(["NOTIFIER: %s" % m for m in ex.message.split('\n')]) | ||||||
| 
 | 
 | ||||||
|     compose.write_status("STARTED") |     compose.write_status("STARTED") | ||||||
|  |     compose.notifier.send('start') | ||||||
|     compose.log_info("Host: %s" % socket.gethostname()) |     compose.log_info("Host: %s" % socket.gethostname()) | ||||||
|     compose.log_info("User name: %s" % getpass.getuser()) |     compose.log_info("User name: %s" % getpass.getuser()) | ||||||
|     compose.log_info("Working directory: %s" % os.getcwd()) |     compose.log_info("Working directory: %s" % os.getcwd()) | ||||||
| @ -216,7 +228,6 @@ def run_compose(compose): | |||||||
|     test_phase = pungi.phases.TestPhase(compose) |     test_phase = pungi.phases.TestPhase(compose) | ||||||
| 
 | 
 | ||||||
|     # check if all config options are set |     # check if all config options are set | ||||||
|     errors = [] |  | ||||||
|     for phase in (init_phase, pkgset_phase, createrepo_phase, |     for phase in (init_phase, pkgset_phase, createrepo_phase, | ||||||
|                   buildinstall_phase, productimg_phase, gather_phase, |                   buildinstall_phase, productimg_phase, gather_phase, | ||||||
|                   extrafiles_phase, createiso_phase, liveimages_phase, |                   extrafiles_phase, createiso_phase, liveimages_phase, | ||||||
| @ -230,6 +241,7 @@ def run_compose(compose): | |||||||
|                 errors.append("%s: %s" % (phase.name.upper(), i)) |                 errors.append("%s: %s" % (phase.name.upper(), i)) | ||||||
|     if errors: |     if errors: | ||||||
|         for i in errors: |         for i in errors: | ||||||
|  |             compose.notifier.send('abort') | ||||||
|             compose.log_error(i) |             compose.log_error(i) | ||||||
|             print(i) |             print(i) | ||||||
|         sys.exit(1) |         sys.exit(1) | ||||||
| @ -319,6 +331,7 @@ def run_compose(compose): | |||||||
| 
 | 
 | ||||||
|     compose.log_info("Compose finished: %s" % compose.topdir) |     compose.log_info("Compose finished: %s" % compose.topdir) | ||||||
|     compose.write_status("FINISHED") |     compose.write_status("FINISHED") | ||||||
|  |     compose.notifier.send('finish') | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
| @ -333,6 +346,8 @@ if __name__ == "__main__": | |||||||
|             COMPOSE.write_status("DOOMED") |             COMPOSE.write_status("DOOMED") | ||||||
|             import kobo.tback |             import kobo.tback | ||||||
|             open(tb_path, "w").write(kobo.tback.Traceback().get_traceback()) |             open(tb_path, "w").write(kobo.tback.Traceback().get_traceback()) | ||||||
|  |             if COMPOSE.notifier: | ||||||
|  |                 COMPOSE.notifier.send('doomed') | ||||||
|         else: |         else: | ||||||
|             print("Exception: %s" % ex) |             print("Exception: %s" % ex) | ||||||
|             raise |             raise | ||||||
|  | |||||||
| @ -561,3 +561,39 @@ Example usage | |||||||
|     >>> from pungi.paths import translate_paths |     >>> from pungi.paths import translate_paths | ||||||
|     >>> print translate_paths(compose_object_with_mapping, "/mnt/a/c/somefile") |     >>> print translate_paths(compose_object_with_mapping, "/mnt/a/c/somefile") | ||||||
|     http://b/dir/c/somefile |     http://b/dir/c/somefile | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | Progress notification | ||||||
|  | ===================== | ||||||
|  | 
 | ||||||
|  | *Pungi* has the ability to emit notification messages about progress and | ||||||
|  | status. These can be used to e.g. send messages to *fedmsg*. This is | ||||||
|  | implemented by actually calling a separate script. | ||||||
|  | 
 | ||||||
|  | The script will be called with one argument describing action that just | ||||||
|  | happened. A JSON-encoded object will be passed to standard input to provide | ||||||
|  | more information about the event. At least, the object will contain a | ||||||
|  | ``compose_id`` key. | ||||||
|  | 
 | ||||||
|  | Currently these messages are sent: | ||||||
|  | 
 | ||||||
|  |  * ``start`` -- when composing starts | ||||||
|  |  * ``abort`` -- when compose is aborted due to incorrect configuration | ||||||
|  |  * ``finish`` -- on successful finish of compose | ||||||
|  |  * ``doomed`` -- when an error happens | ||||||
|  |  * ``phase-start`` -- on start of a phase | ||||||
|  |  * ``phase-stop`` -- when phase is finished | ||||||
|  | 
 | ||||||
|  | For phase related messages ``phase_name`` key is provided as well. | ||||||
|  | 
 | ||||||
|  | The script is invoked in compose directory and can read other information | ||||||
|  | there. | ||||||
|  | 
 | ||||||
|  | A ``pungi-fedmsg-notification`` script is provided and understands this | ||||||
|  | interface. | ||||||
|  | 
 | ||||||
|  | Config options | ||||||
|  | -------------- | ||||||
|  | 
 | ||||||
|  | **notification_script** | ||||||
|  |     (*str*) -- executable to be invoked to send the message | ||||||
|  | |||||||
| @ -102,6 +102,7 @@ class Compose(kobo.log.LoggingBase): | |||||||
|         self.just_phases = just_phases or [] |         self.just_phases = just_phases or [] | ||||||
|         self.old_composes = old_composes or [] |         self.old_composes = old_composes or [] | ||||||
|         self.koji_event = koji_event |         self.koji_event = koji_event | ||||||
|  |         self.notifier = None | ||||||
| 
 | 
 | ||||||
|         # intentionally upper-case (visible in the code) |         # intentionally upper-case (visible in the code) | ||||||
|         self.DEBUG = debug |         self.DEBUG = debug | ||||||
|  | |||||||
							
								
								
									
										74
									
								
								pungi/notifier.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								pungi/notifier.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | |||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | 
 | ||||||
|  | # This program is free software; you can redistribute it and/or modify | ||||||
|  | # it under the terms of the GNU General Public License as published by | ||||||
|  | # the Free Software Foundation; version 2 of the License. | ||||||
|  | # | ||||||
|  | # This program is distributed in the hope that it will be useful, | ||||||
|  | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | # GNU Library General Public License for more details. | ||||||
|  | # | ||||||
|  | # You should have received a copy of the GNU General Public License | ||||||
|  | # along with this program; if not, write to the Free Software | ||||||
|  | # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. | ||||||
|  | 
 | ||||||
|  | import json | ||||||
|  | import threading | ||||||
|  | 
 | ||||||
|  | from kobo import shortcuts | ||||||
|  | from pungi.checks import validate_options | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class PungiNotifier(object): | ||||||
|  |     """Wrapper around an external script for sending messages. | ||||||
|  | 
 | ||||||
|  |     If no script is configured, the messages are just silently ignored. If the | ||||||
|  |     script fails, a warning will be logged, but the compose process will not be | ||||||
|  |     interrupted. | ||||||
|  |     """ | ||||||
|  |     config_options = ( | ||||||
|  |         { | ||||||
|  |             "name": "notification_script", | ||||||
|  |             "expected_types": [str], | ||||||
|  |             "optional": True | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     def __init__(self, compose): | ||||||
|  |         self.compose = compose | ||||||
|  |         self.lock = threading.Lock() | ||||||
|  | 
 | ||||||
|  |     def validate(self): | ||||||
|  |         errors = validate_options(self.compose.conf, self.config_options) | ||||||
|  |         if errors: | ||||||
|  |             raise ValueError("\n".join(errors)) | ||||||
|  | 
 | ||||||
|  |     def _update_args(self, data): | ||||||
|  |         """Add compose related information to the data.""" | ||||||
|  |         data.setdefault('compose_id', self.compose.compose_id) | ||||||
|  | 
 | ||||||
|  |     def send(self, msg, **kwargs): | ||||||
|  |         """Send a message. | ||||||
|  | 
 | ||||||
|  |         The actual meaning of ``msg`` depends on what the notification script | ||||||
|  |         will be doing. The keyword arguments will be JSON-encoded and passed on | ||||||
|  |         to standard input of the notification process. | ||||||
|  | 
 | ||||||
|  |         Unless you specify it manually, a ``compose_id`` key with appropriate | ||||||
|  |         value will be automatically added. | ||||||
|  |         """ | ||||||
|  |         script = self.compose.conf.get('notification_script', None) | ||||||
|  |         if not script: | ||||||
|  |             return | ||||||
|  | 
 | ||||||
|  |         self._update_args(kwargs) | ||||||
|  | 
 | ||||||
|  |         with self.lock: | ||||||
|  |             ret, _ = shortcuts.run((script, msg), | ||||||
|  |                                    stdin_data=json.dumps(kwargs), | ||||||
|  |                                    can_fail=True, | ||||||
|  |                                    workdir=self.compose.paths.compose.topdir(), | ||||||
|  |                                    return_stdout=False) | ||||||
|  |             if ret != 0: | ||||||
|  |                 self.compose.log_warning('Failed to invoke notification script.') | ||||||
| @ -14,7 +14,6 @@ | |||||||
| # along with this program; if not, write to the Free Software | # along with this program; if not, write to the Free Software | ||||||
| # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. | # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| from pungi.checks import validate_options | from pungi.checks import validate_options | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -59,6 +58,7 @@ class PhaseBase(object): | |||||||
|             self.finished = True |             self.finished = True | ||||||
|             return |             return | ||||||
|         self.compose.log_info("[BEGIN] %s" % self.msg) |         self.compose.log_info("[BEGIN] %s" % self.msg) | ||||||
|  |         self.compose.notifier.send('phase-start', phase_name=self.name) | ||||||
|         self.run() |         self.run() | ||||||
| 
 | 
 | ||||||
|     def stop(self): |     def stop(self): | ||||||
| @ -68,6 +68,7 @@ class PhaseBase(object): | |||||||
|             self.pool.stop() |             self.pool.stop() | ||||||
|         self.finished = True |         self.finished = True | ||||||
|         self.compose.log_info("[DONE ] %s" % self.msg) |         self.compose.log_info("[DONE ] %s" % self.msg) | ||||||
|  |         self.compose.notifier.send('phase-stop', phase_name=self.name) | ||||||
| 
 | 
 | ||||||
|     def run(self): |     def run(self): | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  | |||||||
| @ -54,6 +54,7 @@ class CreateisoPhase(PhaseBase): | |||||||
|     def run(self): |     def run(self): | ||||||
|         iso = IsoWrapper(logger=self.compose._logger) |         iso = IsoWrapper(logger=self.compose._logger) | ||||||
|         symlink_isos_to = self.compose.conf.get("symlink_isos_to", None) |         symlink_isos_to = self.compose.conf.get("symlink_isos_to", None) | ||||||
|  |         deliverables = [] | ||||||
| 
 | 
 | ||||||
|         commands = [] |         commands = [] | ||||||
|         for variant in self.compose.get_variants(types=["variant", "layered-product", "optional"], recursive=True): |         for variant in self.compose.get_variants(types=["variant", "layered-product", "optional"], recursive=True): | ||||||
| @ -96,6 +97,7 @@ class CreateisoPhase(PhaseBase): | |||||||
|                         self.compose.log_warning("Skipping mkisofs, image already exists: %s" % iso_path) |                         self.compose.log_warning("Skipping mkisofs, image already exists: %s" % iso_path) | ||||||
|                         continue |                         continue | ||||||
|                     iso_name = os.path.basename(iso_path) |                     iso_name = os.path.basename(iso_path) | ||||||
|  |                     deliverables.append(iso_path) | ||||||
| 
 | 
 | ||||||
|                     graft_points = prepare_iso(self.compose, arch, variant, disc_num=disc_num, disc_count=disc_count, split_iso_data=iso_data) |                     graft_points = prepare_iso(self.compose, arch, variant, disc_num=disc_num, disc_count=disc_count, split_iso_data=iso_data) | ||||||
| 
 | 
 | ||||||
| @ -176,6 +178,8 @@ class CreateisoPhase(PhaseBase): | |||||||
|                         cmd["cmd"] = " && ".join(cmd["cmd"]) |                         cmd["cmd"] = " && ".join(cmd["cmd"]) | ||||||
|                         commands.append(cmd) |                         commands.append(cmd) | ||||||
| 
 | 
 | ||||||
|  |         self.compose.notifier.send('createiso-targets', deliverables=deliverables) | ||||||
|  | 
 | ||||||
|         for cmd in commands: |         for cmd in commands: | ||||||
|             self.pool.add(CreateIsoThread(self.pool)) |             self.pool.add(CreateIsoThread(self.pool)) | ||||||
|             self.pool.queue_put((self.compose, cmd)) |             self.pool.queue_put((self.compose, cmd)) | ||||||
| @ -197,6 +201,10 @@ class CreateIsoThread(WorkerThread): | |||||||
|             # TODO: remove jigdo & template |             # TODO: remove jigdo & template | ||||||
|         except OSError: |         except OSError: | ||||||
|             pass |             pass | ||||||
|  |         compose.notifier.send('createiso-imagefail', | ||||||
|  |                               file=cmd['iso_path'], | ||||||
|  |                               arch=cmd['arch'], | ||||||
|  |                               variant=str(cmd['variant'])) | ||||||
| 
 | 
 | ||||||
|     def process(self, item, num): |     def process(self, item, num): | ||||||
|         compose, cmd = item |         compose, cmd = item | ||||||
| @ -281,6 +289,10 @@ class CreateIsoThread(WorkerThread): | |||||||
|         # add: boot.iso |         # add: boot.iso | ||||||
| 
 | 
 | ||||||
|         self.pool.log_info("[DONE ] %s" % msg) |         self.pool.log_info("[DONE ] %s" % msg) | ||||||
|  |         compose.notifier.send('createiso-imagedone', | ||||||
|  |                               file=cmd['iso_path'], | ||||||
|  |                               arch=cmd['arch'], | ||||||
|  |                               variant=str(cmd['variant'])) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def split_iso(compose, arch, variant): | def split_iso(compose, arch, variant): | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								setup.py
									
									
									
									
									
								
							| @ -37,6 +37,7 @@ setup( | |||||||
|         'bin/pungi', |         'bin/pungi', | ||||||
|         'bin/pungi-koji', |         'bin/pungi-koji', | ||||||
|         'bin/comps_filter', |         'bin/comps_filter', | ||||||
|  |         'bin/pungi-fedmsg-notifier', | ||||||
|     ], |     ], | ||||||
|     data_files      = [ |     data_files      = [ | ||||||
|         ('/usr/share/pungi', glob.glob('share/*.xsl')), |         ('/usr/share/pungi', glob.glob('share/*.xsl')), | ||||||
|  | |||||||
							
								
								
									
										82
									
								
								tests/test_notifier.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										82
									
								
								tests/test_notifier.py
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,82 @@ | |||||||
|  | #!/usr/bin/python | ||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | 
 | ||||||
|  | import json | ||||||
|  | import mock | ||||||
|  | import os | ||||||
|  | import sys | ||||||
|  | import unittest | ||||||
|  | 
 | ||||||
|  | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | ||||||
|  | 
 | ||||||
|  | from pungi.notifier import PungiNotifier | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestNotifier(unittest.TestCase): | ||||||
|  |     def test_incorrect_config(self): | ||||||
|  |         compose = mock.Mock( | ||||||
|  |             conf={'notification_script': [1, 2]} | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         n = PungiNotifier(compose) | ||||||
|  |         with self.assertRaises(ValueError) as err: | ||||||
|  |             n.validate() | ||||||
|  |             self.assertIn('notification_script', err.message) | ||||||
|  | 
 | ||||||
|  |     @mock.patch('kobo.shortcuts.run') | ||||||
|  |     def test_invokes_script(self, run): | ||||||
|  |         compose = mock.Mock( | ||||||
|  |             compose_id='COMPOSE_ID', | ||||||
|  |             conf={'notification_script': 'run-notify'}, | ||||||
|  |             paths=mock.Mock( | ||||||
|  |                 compose=mock.Mock( | ||||||
|  |                     topdir=mock.Mock(return_value='/a/b') | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         run.return_value = (0, None) | ||||||
|  | 
 | ||||||
|  |         n = PungiNotifier(compose) | ||||||
|  |         data = {'foo': 'bar', 'baz': 'quux'} | ||||||
|  |         n.send('cmd', **data) | ||||||
|  | 
 | ||||||
|  |         data['compose_id'] = 'COMPOSE_ID' | ||||||
|  |         run.assert_called_once_with(('run-notify', 'cmd'), | ||||||
|  |                                     stdin_data=json.dumps(data), | ||||||
|  |                                     can_fail=True, return_stdout=False, workdir='/a/b') | ||||||
|  | 
 | ||||||
|  |     @mock.patch('kobo.shortcuts.run') | ||||||
|  |     def test_does_not_run_without_config(self, run): | ||||||
|  |         compose = mock.Mock(conf={}) | ||||||
|  | 
 | ||||||
|  |         n = PungiNotifier(compose) | ||||||
|  |         n.send('cmd', foo='bar', baz='quux') | ||||||
|  |         self.assertFalse(run.called) | ||||||
|  | 
 | ||||||
|  |     @mock.patch('kobo.shortcuts.run') | ||||||
|  |     def test_logs_warning_on_failure(self, run): | ||||||
|  |         compose = mock.Mock( | ||||||
|  |             compose_id='COMPOSE_ID', | ||||||
|  |             log_warning=mock.Mock(), | ||||||
|  |             conf={'notification_script': 'run-notify'}, | ||||||
|  |             paths=mock.Mock( | ||||||
|  |                 compose=mock.Mock( | ||||||
|  |                     topdir=mock.Mock(return_value='/a/b') | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         run.return_value = (1, None) | ||||||
|  | 
 | ||||||
|  |         n = PungiNotifier(compose) | ||||||
|  |         n.send('cmd') | ||||||
|  | 
 | ||||||
|  |         run.assert_called_once_with(('run-notify', 'cmd'), | ||||||
|  |                                     stdin_data=json.dumps({'compose_id': 'COMPOSE_ID'}), | ||||||
|  |                                     can_fail=True, return_stdout=False, workdir='/a/b') | ||||||
|  |         self.assertTrue(compose.log_warning.called) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     unittest.main() | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user