from __future__ import absolute_import import mock import os import six import unittest import koji from koji_cli.commands import handle_import from . import utils def md5_to_bytes(s): if six.PY2: return bytearray.fromhex(unicode(s)) else: return bytearray.fromhex(s) class TestImport(utils.CliTestCase): def setUp(self): self.maxDiff = None self.huburl = "https://%s.local/%shub" % (self.progname, self.progname) self.md5 = '00112233445566778899aabbccddeeff' self.fake_srv_dir = '/path/to/server/import' # # RPM header example (bash-4.4.12-5.fc26.x86_64.rpm): # { # 'sourcepackage': None, # 'name': 'bash', # 'sigmd5': 'J\x15\xca\x94\xb1\xacY\xd5\xef\x9f\xc6\xd5\n\xd7?>', # 'epoch': None, # 'version': '4.4.12', # 'release': '5.fc26', # 'sourcerpm': 'bash-4.4.12-5.fc26.src.rpm', # 'arch': 'x86_64' # } # # SRPM header example (bash-4.4.12-5.fc26.src.rpm): # { # 'sourcepackage': 1, # 'name': 'bash', # 'sigmd5': '\x8a\x17\x05\xe8k\xef\x15\x16V[\x02\x9cs\xab\x7f\xdd', # 'epoch': None, # 'version': '4.4.12', # 'release': '5.fc26', # 'sourcerpm': None, # 'arch': 'x86_64' # } self.srpm_header = { 'sourcepackage': 1, 'name': 'bash', 'sigmd5': md5_to_bytes(self.md5), 'epoch': None, 'version': '4.4.12', 'release': '5.fc26', 'sourcerpm': None, 'arch': 'x86_64' } self.rpm_header = { 'sourcepackage': None, 'name': 'bash', 'sigmd5': md5_to_bytes(self.md5), 'epoch': None, 'version': '4.4.12', 'release': '5.fc26', 'sourcerpm': 'bash-4.4.12-5.fc26.src.rpm', 'arch': 'x86_64' } # koji.BUILD_STATES self.bstate = { 'BUILDING': 0, 'COMPLETE': 1, 'DELETED': 2, 'FAILED': 3, 'CANCELED': 4, } self.error_format = """Usage: %s import [options] [ ...] (Specify the --help global option for a list of other help options) %s: error: {message} """ % (self.progname, self.progname) def __do_import_test(self, options, session, arguments, **kwargs): expected = kwargs.get('expected', None) rpm_header = kwargs.get('rpm_header', {}) fake_srv_path = kwargs.get('srv_path', '/path/to/server/import') upload_rpm_mock = kwargs.get('upload_rpm_mock', session.uploadWrapper) with mock.patch('koji.get_header_fields') as get_header_fields_mock: with mock.patch('koji_cli.commands.unique_path') as unique_path_mock: with mock.patch('koji_cli.commands.activate_session') as activate_session_mock: with mock.patch('sys.stdout', new_callable=six.StringIO) as stdout: with upload_rpm_mock: get_header_fields_mock.return_value = rpm_header unique_path_mock.return_value = fake_srv_path handle_import(options, session, arguments) # check output message self.assert_console_message(stdout, expected) # check mock calls activate_session_mock.assert_called_with(session, options) get_header_fields_mock.assert_called_with( arguments[0], ('name', 'version', 'release', 'epoch', 'arch', 'sigmd5', 'sourcepackage', 'sourcerpm') ) session.getRPM.assert_called_with( dict((k, rpm_header.get(k, '')) for k in ['release', 'version', 'arch', 'name']) ) unique_path_mock.assert_called_with('cli-import') upload_rpm_mock.assert_called_with(arguments[0], self.fake_srv_dir) session.importRPM.assert_called_with( self.fake_srv_dir, os.path.basename(arguments[0])) # reset for next test activate_session_mock.reset_mock() get_header_fields_mock.reset_mock() unique_path_mock.reset_mock() upload_rpm_mock.reset_mock() session.getRPM.reset_mock() session.importRPM.reset_mock() @mock.patch('sys.stdout', new_callable=six.StringIO) @mock.patch('sys.stderr', new_callable=six.StringIO) @mock.patch('koji_cli.commands.activate_session') def __skip_import_test(self, options, session, arguments, activate_session_mock, stderr, stdout, **kwargs): expected = kwargs.get('expected', None) expected_warn = kwargs.get('expected_warn', None) rpm_header = kwargs.get('rpm_header', {}) with mock.patch('koji.get_header_fields') as get_header_fields_mock: get_header_fields_mock.return_value = rpm_header handle_import(options, session, arguments) # check output message self.assert_console_message(stdout, expected) self.assert_console_message(stderr, expected_warn) # check mock calls activate_session_mock.assert_called_with(session, options) get_header_fields_mock.assert_called_with( arguments[0], ('name', 'version', 'release', 'epoch', 'arch', 'sigmd5', 'sourcepackage', 'sourcerpm') ) session.getRPM.assert_called_with( dict((k, rpm_header.get(k, '')) for k in ['release', 'version', 'arch', 'name']) ) session.uploadWrapper.assert_not_called() session.importRPM.assert_not_called() def test_handle_import_src_rpm_import_with_no_exist_build(self): """Test handle_import source RPM import. No build is on the server case, this tests are focusing on do_import() function coverage. """ arguments = ['/path/to/bash-4.4.12-5.fc26.src.rpm', '--src-epoch', 'None'] options = mock.MagicMock() session = mock.MagicMock() # No exist build test case # import general case session.getBuild.return_value = None session.getRPM.return_value = None expected = "uploading %s... done\n" % arguments[0] expected += "importing %s... done\n" % arguments[0] self.__do_import_test( options, session, arguments, rpm_header=self.srpm_header, expected=expected) # import error case session.importRPM.side_effect = koji.GenericError('fake-import-error') expected = "uploading %s... done\n" % arguments[0] expected += "importing %s... \n" % arguments[0] expected += "Error importing: fake-import-error\n" self.__do_import_test( options, session, arguments, rpm_header=self.srpm_header, expected=expected) # import with --link (hardlink) option session.importRPM.side_effect = None expected = "importing %s... done\n" % arguments[0] self.__do_import_test( options, session, arguments + ['--link'], upload_rpm_mock=mock.patch('koji_cli.commands.linked_upload').start(), rpm_header=self.srpm_header, expected=expected) session.uploadWrapper.assert_not_called() def test_handle_import_src_rpm_import_with_exist_build(self): """Test handle_import source RPM import with exist build case.""" arguments = ['/path/to/bash-4.4.12-5.fc26.src.rpm'] options = mock.MagicMock() session = mock.MagicMock() false_md5 = 'ffeeddccbbaa99887766554433221100' # # getBuild return example (bash-4.4.12-5.fc26.src.rpm) # { # 'package_name': 'bash', # 'extra': None, # 'creation_time': '2017-11-08 18:56:31.359440', # 'completion_time': '2017-11-08 18:56:31.359440', # 'package_id': 1, # 'id': 1, # 'build_id': 1, # 'state': 1, # 'source': None, # 'epoch': None, # 'version': '4.4.12', # 'completion_ts': 1510167391.35944, # 'owner_id': 1, # 'owner_name': 'kojiadmin', # 'nvr': 'bash-4.4.12-5.fc26', # 'start_time': '2017-11-08 18:56:31.359440', # 'creation_event_id': 81, # 'start_ts': 1510167391.35944, # 'volume_id': 0, # 'creation_ts': 1510167391.35944, # 'name': 'bash', # 'task_id': None, # 'volume_name': 'DEFAULT', # 'release': '5.fc26' # } # # # getRPM return example: # { # 'build_id': 1, # 'name': 'bash', # 'extra': None, # 'external_repo_id': 0, # 'buildtime': 1496119944, # 'id': 1, # 'epoch': None, # 'version': '4.4.12', # 'buildroot_id': None, # 'metadata_only': False, # 'release': '5.fc26', # 'arch': 'src', # 'payloadhash': '8a1705e86bef1516565b029c73ab7fdd', # 'external_repo_name': 'INTERNAL', # 'size': 9462614 # } # # Case 1: build exists and status is 'COMPLETE', md5 matched # reseult: import skipped session.getBuild.return_value = {'state': self.bstate['COMPLETE']} session.getRPM.return_value = { 'external_repo_id': 0, 'external_repo_name': 'INTERNAL', 'payloadhash': self.md5 } expected = "RPM already imported: %s\n" % arguments[0] expected += "Skipping import\n" self.__skip_import_test( options, session, arguments, rpm_header=self.srpm_header, expected=expected) # Case 2: build exists and status is 'COMPLETE', md5 mismatched # reseult: import skipped session.getRPM.return_value['payloadhash'] = false_md5 expected_warn = "md5sum mismatch for %s\n" % arguments[0] expected_warn += " A different rpm with the same name has already been imported\n" expected_warn += " Existing sigmd5 is %r, your import has %r\n" % (false_md5, self.md5) expected = "Skipping import\n" self.__skip_import_test( options, session, arguments, rpm_header=self.srpm_header, expected=expected, expected_warn=expected_warn) # Case 3: build exists and status is 'COMPLETE', has external_repo_id # reseult: import will be performed session.getRPM.return_value['external_repo_id'] = 1 expected = "uploading %s... done\n" % arguments[0] expected += "importing %s... done\n" % arguments[0] self.__do_import_test( options, session, arguments, rpm_header=self.srpm_header, expected=expected) nvr = '%(name)s-%(version)s-%(release)s' % self.srpm_header # Case 4: build exists and the status is FAILED or CANCELED # without --create-build option. # result: import skipped session.getBuild.return_value = { 'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['FAILED']} session.getRPM.return_value = { 'external_repo_name': 'INTERNAL', 'payloadhash': self.md5 } expected = "Build %s state is %s. Skipping import\n" % (nvr, 'FAILED') with mock.patch('koji.get_header_fields') as get_header_fields_mock: with mock.patch('koji_cli.commands.activate_session') as activate_session_mock: with mock.patch('sys.stdout', new_callable=six.StringIO) as stdout: get_header_fields_mock.return_value = self.srpm_header handle_import(options, session, arguments) activate_session_mock.assert_called_with(session, options) self.assert_console_message(stdout, expected) # Case 5: build exists and the status is FAILED or CANCELED # with --create-build option. # result: empty build will be created, # import will be performed. expected = "Creating empty build: %s\n" % nvr expected += "uploading %s... done\n" % arguments[0] expected += "importing %s... done\n" % arguments[0] session.getBuild.side_effect = [ # simulate build fail case {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['FAILED']}, # after calling createEmptyBuild, getBuild should return: {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['COMPLETE']}, ] # no RPM info is returned session.getRPM.return_value = {} self.__do_import_test( options, session, arguments + ['--create-build'], rpm_header=self.srpm_header, expected=expected) kwarg = dict((k, self.rpm_header.get(k, None)) for k in ['name', 'version', 'release', 'epoch']) session.createEmptyBuild.assert_called_with(**kwarg) @mock.patch('sys.stdout', new_callable=six.StringIO) @mock.patch('sys.stderr', new_callable=six.StringIO) @mock.patch('koji.get_header_fields') @mock.patch('koji_cli.commands.activate_session') def test_handle_import_binary_rpm_import_with_no_build_exist( self, activate_session_mock, get_header_fields_mock, stderr, stdout): """Test handle_import binary RPM import with no exist build case.""" arguments = ['/path/to/bash-4.4.12-5.fc26.rpm'] options = mock.MagicMock() session = mock.MagicMock() get_header_fields_mock.return_value = self.rpm_header nvr = '%(name)s-%(version)s-%(release)s' % get_header_fields_mock.return_value # Case 1. without exist build on server # result: abort session.getBuild.return_value = None handle_import(options, session, arguments) expected = "Missing build or srpm: %s\n" % nvr expected += "Aborting import\n" self.assert_console_message(stdout, expected) # Case 2. without exist build on server, # with --create-build option # result: import will be performed session.getBuild.return_value = None session.getRPM.return_value = None expected = "Missing build or srpm: %s\n" % nvr expected += "Creating empty build: %s\n" % nvr expected += "uploading %s... done\n" % arguments[0] expected += "importing %s... done\n" % arguments[0] self.__do_import_test( options, session, arguments + ['--create-build'], rpm_header=self.rpm_header, expected=expected) kwarg = dict((k, self.rpm_header.get(k, None)) for k in ['name', 'version', 'release', 'epoch']) session.createEmptyBuild.assert_called_with(**kwarg) @mock.patch('sys.stdout', new_callable=six.StringIO) @mock.patch('sys.stderr', new_callable=six.StringIO) @mock.patch('koji_cli.commands.activate_session') def test_handle_import_binary_rpm_with_exist_build( self, activate_session_mock, stderr, stdout): """Test handle_import binary RPM import with exist build case.""" arguments = ['/path/to/bash-4.4.12-5.fc26.rpm'] options = mock.MagicMock() session = mock.MagicMock() nvr = '%(name)s-%(version)s-%(release)s' % self.rpm_header # Case 1. have exist build on server, build status is COMPLETE case. # result: skip import session.getBuild.return_value = {'state': self.bstate['COMPLETE']} session.getRPM.return_value = { 'external_repo_name': 'INTERNAL', 'payloadhash': self.md5, } expected = "RPM already imported: %s\n" % arguments[0] expected += "Skipping import\n" self.__skip_import_test( options, session, arguments, rpm_header=self.rpm_header, expected=expected) # Case 2. have exist build on server, build status is failed, # without --create-build case. # result: import will be skipped session.getBuild.return_value = {'state': self.bstate['FAILED']} expected = "Build %s state is %s. Skipping import\n" % (nvr, 'FAILED') self.__skip_import_test( options, session, arguments, rpm_header=self.rpm_header, expected=expected) # Case 3. have exist build on server, build status is failed, # with --create-build, # result: # empty build will be created, # import will be performed. expected = "Creating empty build: %s\n" % nvr expected += "uploading %s... done\n" % arguments[0] expected += "importing %s... done\n" % arguments[0] session.getBuild.side_effect = [ # binary rpm case, # getBuild is called to check build. {'state': self.bstate['FAILED']}, # simulate build fail case {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['FAILED']}, # after calling createEmptyBuild, getBuild should return: {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['COMPLETE']}, ] # no RPM info is returned session.getRPM.return_value = {} self.__do_import_test( options, session, arguments + ['--create-build'], rpm_header=self.rpm_header, expected=expected) kwarg = dict((k, self.rpm_header.get(k, None)) for k in ['name', 'version', 'release', 'epoch']) session.createEmptyBuild.assert_called_with(**kwarg) @mock.patch('sys.stdout', new_callable=six.StringIO) @mock.patch('koji.get_header_fields') @mock.patch('koji_cli.commands.unique_path') @mock.patch('koji_cli.commands.activate_session') def test_handle_import_with_test_option( self, activate_session_mock, unique_path_mock, get_header_fields_mock, stdout): """Test handle_import RPM with --test option""" options = mock.MagicMock() session = mock.MagicMock() nvr = lambda: '%(name)s-%(version)s-%(release)s' % get_header_fields_mock.return_value # Case 1. SRPM case # result: skip because of --test get_header_fields_mock.return_value = self.srpm_header session.getBuild.return_value = {} session.getRPM.return_value = {} arguments = ['/path/to/bash-4.4.12-5.fc26.src.rpm', '--test'] expected = "Test mode -- skipping import for %s\n" % arguments[0] handle_import(options, session, arguments) self.assert_console_message(stdout, expected) unique_path_mock.assert_not_called() session.importRPM.assert_not_called() # Case 2. Binary RPM case (need --create-build option) # result: skip because of --test session.getBuild.side_effect = [ # binary rpm case, # getBuild is called to check build. {'state': self.bstate['FAILED']}, # simulate build fail case {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['FAILED']}, ] get_header_fields_mock.return_value = self.rpm_header arguments = ['/path/to/bash-4.4.12-5.fc26.rpm', '--test', '--create-build'] expected = "Test mode -- would have created empty build: %s\n" % nvr() expected += "Test mode -- skipping import for %s\n" % arguments[0] with mock.patch('koji_cli.commands.unique_path') as unique_path_mock: handle_import(options, session, arguments) self.assert_console_message(stdout, expected) unique_path_mock.assert_not_called() session.createEmptyBuild.assert_not_called() session.importRPM.assert_not_called() @mock.patch('sys.stdout', new_callable=six.StringIO) @mock.patch('koji.get_header_fields') @mock.patch('koji_cli.commands.unique_path') @mock.patch('koji_cli.commands.activate_session') def test_handle_import_with_epoch_option( self, activate_session_mock, unique_path_mock, get_header_fields_mock, stdout): """Test handle_import RPM with --src-epoch option""" options = mock.MagicMock() session = mock.MagicMock() epoch = "1" nvr = '%(name)s-%(version)s-%(release)s' % self.rpm_header # Binary RPM case (need --create-build option) # result: skip because of --test arguments = ['/path/to/bash-4.4.12-5.fc26.rpm', '--create-build', '--src-epoch', epoch] expected = "Creating empty build: %s\n" % nvr expected += "uploading %s... done\n" % arguments[0] expected += "importing %s... done\n" % arguments[0] session.getBuild.side_effect = [ # binary rpm case, # getBuild is called to check build. {'state': self.bstate['FAILED']}, # simulate build fail case {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['FAILED']}, # after calling createEmptyBuild, getBuild should return: {'name': 'bash', 'version': '4.4.12', 'release': '5.fc26', 'state': self.bstate['COMPLETE']}, ] # no RPM info is returned session.getRPM.return_value = {} self.__do_import_test( options, session, arguments + ['--create-build'], rpm_header=self.rpm_header, expected=expected) kwarg = dict((k, self.rpm_header.get(k, None)) for k in ['name', 'version', 'release', 'epoch']) kwarg['epoch'] = int(epoch) session.createEmptyBuild.assert_called_with(**kwarg) @mock.patch('sys.stdout', new_callable=six.StringIO) @mock.patch('sys.stderr', new_callable=six.StringIO) @mock.patch('koji.get_header_fields') @mock.patch('koji_cli.commands.activate_session') def test_handle_import_has_build_states_test( self, activate_session_mock, get_header_fields_mock, stderr, stdout): """Test handle_import RPM with existing build but status not COMPLETE case""" arguments = ['/path/to/bash-4.4.12-5.fc26.src.rpm'] options = mock.MagicMock() session = mock.MagicMock() get_header_fields_mock.return_value = self.srpm_header test_cases = [ { 'state': 'FAILED', 'msg': "Build %s state is %s. Skipping import" }, { 'state': 'CANCELED', 'msg': "Build %s state is %s. Skipping import" }, { 'state': 'DELETED', 'msg': 'Build %s exists with state=%s. Skipping import' }, { 'state': 'BUILDING', 'msg': 'Build %s exists with state=%s. Skipping import' } ] nvr = '%(name)s-%(version)s-%(release)s' % \ get_header_fields_mock.return_value # build failed cases for case in test_cases: session.getBuild.return_value = { 'state': self.bstate[case['state']]} handle_import(options, session, arguments) expected = case['msg'] % (nvr, case['state']) + "\n" self.assert_console_message(stdout, expected) get_header_fields_mock.assert_called_with( arguments[0], ('name', 'version', 'release', 'epoch', 'arch', 'sigmd5', 'sourcepackage', 'sourcerpm') ) activate_session_mock.assert_called_with(session, options) session.getRPM.assert_not_called() session.importRPM.assert_not_called() @mock.patch('sys.stderr', new_callable=six.StringIO) @mock.patch('koji_cli.commands.activate_session') def test_handle_import_argument_error( self, activate_session_mock, stderr): """Test handle_import function with error arguments""" options = mock.MagicMock() session = mock.MagicMock() epoch = 'zzz' test_cases = [ # empty argument error { 'arguments': [], 'error': "At least one package must be specified" }, # invalid epoch number test (epoch must be integer) { 'arguments': ['pkg', '--src-epoch', epoch], 'error': "Invalid value for epoch: %s" % epoch } ] for case in test_cases: expect = self.format_error_message(case['error']) self.assert_system_exit( handle_import, options, session, case['arguments'], stderr=expect, activate_session=None) activate_session_mock.assert_not_called() def test_handle_import_help(self): """Test handle_import function help message""" self.assert_help( handle_import, """Usage: %s import [options] [ ...] (Specify the --help global option for a list of other help options) Options: -h, --help show this help message and exit --link Attempt to hardlink instead of uploading --test Don't actually import --create-build Auto-create builds as needed --src-epoch=SRC_EPOCH When auto-creating builds, use this epoch """ % self.progname) if __name__ == '__main__': unittest.main()