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