diff --git a/leapp/actors/__init__.py b/leapp/actors/__init__.py index 7ae18ea21..9d83bf105 100644 --- a/leapp/actors/__init__.py +++ b/leapp/actors/__init__.py @@ -10,6 +10,7 @@ from leapp.models.error_severity import ErrorSeverity from leapp.tags import Tag from leapp.utils import get_api_models, path +from leapp.utils.audit import store_dialog from leapp.utils.i18n import install_translation_for_actor from leapp.utils.meta import get_flattened_subclasses from leapp.workflows.api import WorkflowAPI @@ -122,12 +123,17 @@ def get_answers(self, dialog): :return: dictionary with the requested answers, None if not a defined dialog """ self._messaging.register_dialog(dialog, self) + answer = None if dialog in type(self).dialogs: if self.skip_dialogs: # non-interactive mode of operation - return self._messaging.get_answers(dialog) - return self._messaging.request_answers(dialog) - return None + answer = self._messaging.get_answers(dialog) + else: + answer = self._messaging.request_answers(dialog) + + store_dialog(dialog, answer) + + return answer def show_message(self, message): """ @@ -285,6 +291,7 @@ def get_actor_tool_path(self, name): def run(self, *args): """ Runs the actor calling the method :py:func:`process`. """ os.environ['LEAPP_CURRENT_ACTOR'] = self.name + try: self.process(*args) except StopActorExecution: diff --git a/leapp/dialogs/dialog.py b/leapp/dialogs/dialog.py index 3ead810a1..320be1bb4 100644 --- a/leapp/dialogs/dialog.py +++ b/leapp/dialogs/dialog.py @@ -114,4 +114,5 @@ def request_answers(self, store, renderer): self._store = store renderer.render(self) self._store = None + return store.get(self.scope, {}) diff --git a/leapp/messaging/answerstore.py b/leapp/messaging/answerstore.py index 3e55e8ae0..b2c707dca 100644 --- a/leapp/messaging/answerstore.py +++ b/leapp/messaging/answerstore.py @@ -117,6 +117,9 @@ def get(self, scope, fallback=None): # NOTE(ivasilev) self.storage.get() will return a DictProxy. To avoid TypeError during later # JSON serialization a copy() should be invoked to get a shallow copy of data answer = self._storage.get(scope, fallback).copy() + + # NOTE(dkubek): It is possible that we do not need to save the 'answer' + # here as it is being stored with dialog question right after query create_audit_entry('dialog-answer', {'scope': scope, 'fallback': fallback, 'answer': answer}) return answer diff --git a/leapp/utils/audit/__init__.py b/leapp/utils/audit/__init__.py index 6b0041322..fd3a0ef4b 100644 --- a/leapp/utils/audit/__init__.py +++ b/leapp/utils/audit/__init__.py @@ -221,6 +221,49 @@ def do_store(self, connection): self._data_source_id = cursor.fetchone()[0] +class Metadata(Host): + """ + Metadata of an entity (e.g. actor, workflow) + """ + + def __init__(self, context=None, hostname=None, kind=None, metadata=None, name=None): + """ + :param context: The execution context + :type context: str + :param hostname: Hostname of the system that produced the entry + :type hostname: str + :param kind: Kind of the entity for which metadata is stored + :type kind: str + :param metadata: Actual metadata + :type metadata: dict + :param name: Name of the entity + :type name: str + """ + super(Metadata, self).__init__(context=context, hostname=hostname) + self.kind = kind + self.name = name + self.metadata = metadata + self._metadata_id = None + + @property + def metadata_id(self): + """ + Returns the id of the entry, which is only set when already stored. + :return: Integer id or None + """ + return self._metadata_id + + def do_store(self, connection): + super(Metadata, self).do_store(connection) + connection.execute( + 'INSERT OR IGNORE INTO metadata (context, kind, name, metadata) VALUES(?, ?, ?, ?)', + (self.context, self.kind, self.name, json.dumps(self.metadata))) + cursor = connection.execute( + 'SELECT id FROM metadata WHERE context = ? AND kind = ? AND name = ?', + (self.context, self.kind, self.name)) + self._metadata_id = cursor.fetchone()[0] + + class Message(DataSource): def __init__(self, stamp=None, msg_type=None, topic=None, data=None, actor=None, phase=None, hostname=None, context=None): @@ -267,6 +310,47 @@ def do_store(self, connection): self._message_id = cursor.lastrowid +class Dialog(DataSource): + """ + Stores information about dialog questions and their answers + """ + + def __init__(self, scope=None, data=None, actor=None, phase=None, hostname=None, context=None): + """ + :param scope: Dialog scope + :type scope: str + :param data: Payload data + :type data: dict + :param actor: Name of the actor that triggered the entry + :type actor: str + :param phase: In which phase of the workflow execution the dialog was triggered + :type phase: str + :param hostname: Hostname of the system that produced the message + :type hostname: str + :param context: The execution context + :type context: str + """ + super(Dialog, self).__init__(actor=actor, phase=phase, hostname=hostname, context=context) + self.scope = scope or '' + self.data = data + self._dialog_id = None + + @property + def dialog_id(self): + """ + Returns the id of the entry, which is only set when already stored. + :return: Integer id or None + """ + return self._dialog_id + + def do_store(self, connection): + super(Dialog, self).do_store(connection) + cursor = connection.execute( + 'INSERT OR IGNORE INTO dialog (context, scope, data, data_source_id) VALUES(?, ?, ?, ?)', + (self.context, self.scope, json.dumps(self.data), self.data_source_id)) + self._dialog_id = cursor.lastrowid + + def create_audit_entry(event, data, message=None): """ Create an audit entry @@ -291,10 +375,10 @@ def get_audit_entry(event, context): """ Retrieve audit entries stored in the database for the given context - :param context: The execution context - :type context: str :param event: Event type identifier :type event: str + :param context: The execution context + :type context: str :return: list of dicts with id, time stamp, actor and phase fields """ with get_connection(None) as conn: @@ -470,3 +554,81 @@ def get_checkpoints(context): ''', (context, _AUDIT_CHECKPOINT_EVENT)) cursor.row_factory = dict_factory return cursor.fetchall() + + +def store_dialog(dialog, answer): + """ + Store ``dialog`` with accompanying ``answer``. + + :param dialog: instance of a workflow to store. + :type dialog: :py:class:`leapp.dialogs.Dialog` + :param answer: Answer to for each component of the dialog + :type answer: dict + """ + + component_keys = ('key', 'label', 'description', 'default', 'value', 'reason') + dialog_keys = ('title', 'reason') # + 'components' + + tmp = dialog.serialize() + data = { + 'components': [dict((key, component[key]) for key in component_keys) for component in tmp['components']], + + # NOTE(dkubek): Storing answer here is redundant as it is already + # being stored in audit when we query from the answerstore, however, + # this keeps the information coupled with the question more closely + 'answer': answer + } + data.update((key, tmp[key]) for key in dialog_keys) + + e = Dialog( + scope=dialog.scope, + data=data, + context=os.environ['LEAPP_EXECUTION_ID'], + actor=os.environ['LEAPP_CURRENT_ACTOR'], + phase=os.environ['LEAPP_CURRENT_PHASE'], + hostname=os.environ['LEAPP_HOSTNAME'], + ) + e.store() + + return e + + +def store_workflow_metadata(workflow): + """ + Store the metadata of the given ``workflow`` into the database. + + :param workflow: Workflow to store. + :type workflow: :py:class:`leapp.workflows.Workflow` + """ + + metadata = type(workflow).serialize() + md = Metadata(kind='workflow', name=workflow.name, context=os.environ['LEAPP_EXECUTION_ID'], + hostname=os.environ['LEAPP_HOSTNAME'], metadata=metadata) + md.store() + + +def store_actor_metadata(actor_definition, phase): + """ + Store the metadata of the given actor given as an ``actor_definition`` + object into the database. + + :param actor_definition: Actor to store + :type actor_definition: :py:class:`leapp.repository.actor_definition.ActorDefinition` + """ + + metadata = dict(actor_definition.discover()) + metadata.update({ + 'consumes': [model.__name__ for model in metadata.get('consumes', ())], + 'produces': [model.__name__ for model in metadata.get('produces', ())], + 'tags': [tag.__name__ for tag in metadata.get('tags', ())], + }) + metadata['phase'] = phase + + actor_metadata_fields = ( + 'class_name', 'name', 'description', 'phase', 'tags', 'consumes', 'produces', 'path' + ) + md = Metadata(kind='actor', name=actor_definition.name, + context=os.environ['LEAPP_EXECUTION_ID'], + hostname=os.environ['LEAPP_HOSTNAME'], + metadata={field: metadata[field] for field in actor_metadata_fields}) + md.store() diff --git a/leapp/workflows/__init__.py b/leapp/workflows/__init__.py index 7f01e0d3e..1b6fc9804 100644 --- a/leapp/workflows/__init__.py +++ b/leapp/workflows/__init__.py @@ -11,7 +11,7 @@ from leapp.messaging.commands import SkipPhasesUntilCommand from leapp.tags import ExperimentalTag from leapp.utils import reboot_system -from leapp.utils.audit import checkpoint, get_errors +from leapp.utils.audit import checkpoint, get_errors, create_audit_entry, store_workflow_metadata, store_actor_metadata from leapp.utils.meta import with_metaclass, get_flattened_subclasses from leapp.utils.output import display_status_current_phase, display_status_current_actor from leapp.workflows.phases import Phase @@ -165,7 +165,7 @@ def __init__(self, logger=None, auto_reboot=False): self.description = self.description or type(self).__doc__ for phase in self.phases: - phase.filter.tags += (self.tag,) + phase.filter.tags += (self.tag,) if self.tag not in phase.filter.tags else () self._phase_actors.append(( phase, # filters all actors with the give tags @@ -279,6 +279,8 @@ def run(self, context=None, until_phase=None, until_actor=None, skip_phases_unti self.log.info('Starting workflow execution: {name} - ID: {id}'.format( name=self.name, id=os.environ['LEAPP_EXECUTION_ID'])) + store_workflow_metadata(self) + skip_phases_until = (skip_phases_until or '').lower() needle_phase = until_phase or '' needle_stage = None @@ -295,6 +297,12 @@ def run(self, context=None, until_phase=None, until_actor=None, skip_phases_unti if phase and not self.is_valid_phase(phase): raise CommandError('Phase {phase} does not exist in the workflow'.format(phase=phase)) + # Save metadata of all discovered actors + for phase in self._phase_actors: + for stage in phase[1:]: + for actor in stage.actors: + store_actor_metadata(actor, phase[0].name) + self._stop_after_phase_requested = False for phase in self._phase_actors: os.environ['LEAPP_CURRENT_PHASE'] = phase[0].name @@ -332,10 +340,12 @@ def run(self, context=None, until_phase=None, until_actor=None, skip_phases_unti display_status_current_actor(actor, designation=designation) current_logger.info("Executing actor {actor} {designation}".format(designation=designation, actor=actor.name)) + messaging = InProcessMessaging(config_model=config_model, answer_store=self._answer_store) messaging.load(actor.consumes) instance = actor(logger=current_logger, messaging=messaging, config_model=config_model, skip_dialogs=skip_dialogs) + try: instance.run() except BaseException as exc: @@ -346,6 +356,14 @@ def run(self, context=None, until_phase=None, until_actor=None, skip_phases_unti current_logger.error('Actor {actor} has crashed: {trace}'.format(actor=actor.name, trace=exc.exception_info)) raise + finally: + # Set and unset the enviromental variable so that audit + # associates the entry with the correct data source + os.environ['LEAPP_CURRENT_ACTOR'] = actor.name + create_audit_entry( + event='actor-exit-status', + data={'exit_status': 1 if self._unhandled_exception else 0}) + os.environ.pop('LEAPP_CURRENT_ACTOR') self._stop_after_phase_requested = messaging.stop_after_phase or self._stop_after_phase_requested diff --git a/res/schema/audit-layout.sql b/res/schema/audit-layout.sql index dd88a4535..eb2b19f6a 100644 --- a/res/schema/audit-layout.sql +++ b/res/schema/audit-layout.sql @@ -1,6 +1,6 @@ BEGIN; -PRAGMA user_version = 2; +PRAGMA user_version = 3; CREATE TABLE IF NOT EXISTS execution ( id INTEGER PRIMARY KEY NOT NULL, @@ -43,6 +43,24 @@ CREATE TABLE IF NOT EXISTS message ( ); +CREATE TABLE IF NOT EXISTS metadata ( + id INTEGER PRIMARY KEY NOT NULL, + context VARCHAR(36) NOT NULL REFERENCES execution (context), + kind VARCHAR(256) NOT NULL DEFAULT '', + name VARCHAR(1024) NOT NULL DEFAULT '', + metadata TEXT DEFAULT NULL, + UNIQUE (context, kind, name) +); + +CREATE TABLE IF NOT EXISTS dialog ( + id INTEGER PRIMARY KEY NOT NULL, + context VARCHAR(36) NOT NULL REFERENCES execution (context), + scope VARCHAR(1024) NOT NULL DEFAULT '', + data TEXT DEFAULT NULL, + data_source_id INTEGER NOT NULL REFERENCES data_source (id) +); + + CREATE TABLE IF NOT EXISTS audit ( id INTEGER PRIMARY KEY NOT NULL, event VARCHAR(256) NOT NULL REFERENCES execution (context), @@ -74,4 +92,4 @@ CREATE VIEW IF NOT EXISTS messages_data AS host ON host.id = data_source.host_id ; -COMMIT; \ No newline at end of file +COMMIT; diff --git a/res/schema/migrations/0002-add-metadata-dialog-tables.sql b/res/schema/migrations/0002-add-metadata-dialog-tables.sql new file mode 100644 index 000000000..bf3260eb3 --- /dev/null +++ b/res/schema/migrations/0002-add-metadata-dialog-tables.sql @@ -0,0 +1,22 @@ +BEGIN; + +CREATE TABLE metadata ( + id INTEGER PRIMARY KEY NOT NULL, + context VARCHAR(36) NOT NULL REFERENCES execution (context), + kind VARCHAR(256) NOT NULL DEFAULT '', + name VARCHAR(1024) NOT NULL DEFAULT '', + metadata TEXT DEFAULT NULL, + UNIQUE (context, kind, name) +); + +CREATE TABLE dialog ( + id INTEGER PRIMARY KEY NOT NULL, + context VARCHAR(36) NOT NULL REFERENCES execution (context), + scope VARCHAR(1024) NOT NULL DEFAULT '', + data TEXT DEFAULT NULL, + data_source_id INTEGER NOT NULL REFERENCES data_source (id) +); + +PRAGMA user_version = 3; + +COMMIT; diff --git a/tests/data/leappdb-tests/.leapp/info b/tests/data/leappdb-tests/.leapp/info new file mode 100644 index 000000000..2c42aa62b --- /dev/null +++ b/tests/data/leappdb-tests/.leapp/info @@ -0,0 +1 @@ +{"name": "workflow-tests", "id": "07005707-67bc-46e5-9732-a10fb13d4e7d"} \ No newline at end of file diff --git a/tests/data/leappdb-tests/.leapp/leapp.conf b/tests/data/leappdb-tests/.leapp/leapp.conf new file mode 100644 index 000000000..b4591347f --- /dev/null +++ b/tests/data/leappdb-tests/.leapp/leapp.conf @@ -0,0 +1,6 @@ + +[repositories] +repo_path=${repository:root_dir} + +[database] +path=${repository:state_dir}/leapp.db diff --git a/tests/data/leappdb-tests/actors/configprovider/actor.py b/tests/data/leappdb-tests/actors/configprovider/actor.py new file mode 100644 index 000000000..985de523c --- /dev/null +++ b/tests/data/leappdb-tests/actors/configprovider/actor.py @@ -0,0 +1,17 @@ +from leapp.actors import Actor +from leapp.models import UnitTestConfig +from leapp.tags import UnitTestWorkflowTag + + +class ConfigProvider(Actor): + """ + No documentation has been provided for the config_provider actor. + """ + + name = 'config_provider' + consumes = () + produces = (UnitTestConfig,) + tags = (UnitTestWorkflowTag,) + + def process(self): + self.produce(UnitTestConfig()) diff --git a/tests/data/leappdb-tests/actors/dialogactor/actor.py b/tests/data/leappdb-tests/actors/dialogactor/actor.py new file mode 100644 index 000000000..f9d5f7785 --- /dev/null +++ b/tests/data/leappdb-tests/actors/dialogactor/actor.py @@ -0,0 +1,36 @@ +from leapp.actors import Actor +from leapp.tags import SecondPhaseTag, UnitTestWorkflowTag +from leapp.dialogs import Dialog +from leapp.dialogs.components import BooleanComponent, ChoiceComponent, NumberComponent, TextComponent + + +class DialogActor(Actor): + name = 'dialog_actor' + description = 'No description has been provided for the dialog_actor actor.' + consumes = () + produces = () + tags = (SecondPhaseTag, UnitTestWorkflowTag) + dialogs = (Dialog( + scope='unique_dialog_scope', + reason='Confirmation', + components=( + TextComponent( + key='text', + label='text', + description='a text value is needed', + ), + BooleanComponent(key='bool', label='bool', description='a boolean value is needed'), + NumberComponent(key='num', label='num', description='a numeric value is needed'), + ChoiceComponent( + key='choice', + label='choice', + description='need to choose one of these choices', + choices=('One', 'Two', 'Three', 'Four', 'Five'), + ), + ), + ),) + + def process(self): + from leapp.libraries.common.test_helper import log_execution + log_execution(self) + self.get_answers(self.dialogs[0]).get('confirm', False) diff --git a/tests/data/leappdb-tests/actors/exitstatusactor/actor.py b/tests/data/leappdb-tests/actors/exitstatusactor/actor.py new file mode 100644 index 000000000..ae41aa51a --- /dev/null +++ b/tests/data/leappdb-tests/actors/exitstatusactor/actor.py @@ -0,0 +1,31 @@ +import os + +from leapp.actors import Actor +from leapp.tags import FirstPhaseTag, UnitTestWorkflowTag +from leapp.exceptions import StopActorExecution, StopActorExecutionError + + +class ExitStatusActor(Actor): + name = 'exit_status_actor' + description = 'No description has been provided for the exit_status_actor actor.' + consumes = () + produces = () + tags = (FirstPhaseTag, UnitTestWorkflowTag) + + def process(self): + from leapp.libraries.common.test_helper import log_execution + log_execution(self) + if not self.configuration or self.configuration.value != 'unit-test': + self.report_error('Unit test failed due missing or invalid workflow provided configuration') + + if os.environ.get('ExitStatusActor-Error') == 'StopActorExecution': + self.report_error('Unit test requested StopActorExecution error') + raise StopActorExecution + + if os.environ.get('ExitStatusActor-Error') == 'StopActorExecutionError': + self.report_error('Unit test requested StopActorExecutionError error') + raise StopActorExecutionError('StopActorExecutionError message') + + if os.environ.get('ExitStatusActor-Error') == 'UnhandledError': + self.report_error('Unit test requested unhandled error') + assert 0 == 1, '0 == 1' diff --git a/tests/data/leappdb-tests/libraries/test_helper.py b/tests/data/leappdb-tests/libraries/test_helper.py new file mode 100644 index 000000000..fd5b9104e --- /dev/null +++ b/tests/data/leappdb-tests/libraries/test_helper.py @@ -0,0 +1,7 @@ +import os +import json + + +def log_execution(actor): + with open(os.environ['LEAPP_TEST_EXECUTION_LOG'], 'a+') as f: + f.write(json.dumps(dict(name=actor.name, class_name=type(actor).__name__)) + '\n') diff --git a/tests/data/leappdb-tests/models/unittestconfig.py b/tests/data/leappdb-tests/models/unittestconfig.py new file mode 100644 index 000000000..10fad83e3 --- /dev/null +++ b/tests/data/leappdb-tests/models/unittestconfig.py @@ -0,0 +1,7 @@ +from leapp.models import Model, fields +from leapp.topics import ConfigTopic + + +class UnitTestConfig(Model): + topic = ConfigTopic + value = fields.String(default='unit-test') diff --git a/tests/data/leappdb-tests/tags/firstphase.py b/tests/data/leappdb-tests/tags/firstphase.py new file mode 100644 index 000000000..e465892a1 --- /dev/null +++ b/tests/data/leappdb-tests/tags/firstphase.py @@ -0,0 +1,5 @@ +from leapp.tags import Tag + + +class FirstPhaseTag(Tag): + name = 'first_phase' diff --git a/tests/data/leappdb-tests/tags/secondphase.py b/tests/data/leappdb-tests/tags/secondphase.py new file mode 100644 index 000000000..ead6c9516 --- /dev/null +++ b/tests/data/leappdb-tests/tags/secondphase.py @@ -0,0 +1,5 @@ +from leapp.tags import Tag + + +class SecondPhaseTag(Tag): + name = 'second_phase' diff --git a/tests/data/leappdb-tests/tags/unittestworkflow.py b/tests/data/leappdb-tests/tags/unittestworkflow.py new file mode 100644 index 000000000..4a45594ec --- /dev/null +++ b/tests/data/leappdb-tests/tags/unittestworkflow.py @@ -0,0 +1,5 @@ +from leapp.tags import Tag + + +class UnitTestWorkflowTag(Tag): + name = 'unit_test_workflow' diff --git a/tests/data/leappdb-tests/topics/config.py b/tests/data/leappdb-tests/topics/config.py new file mode 100644 index 000000000..9ed3140b7 --- /dev/null +++ b/tests/data/leappdb-tests/topics/config.py @@ -0,0 +1,5 @@ +from leapp.topics import Topic + + +class ConfigTopic(Topic): + name = 'config_topic' diff --git a/tests/data/leappdb-tests/workflows/unit_test.py b/tests/data/leappdb-tests/workflows/unit_test.py new file mode 100644 index 000000000..856d8e93a --- /dev/null +++ b/tests/data/leappdb-tests/workflows/unit_test.py @@ -0,0 +1,37 @@ +from leapp.models import UnitTestConfig +from leapp.workflows import Workflow +from leapp.workflows.phases import Phase +from leapp.workflows.flags import Flags +from leapp.workflows.tagfilters import TagFilter +from leapp.workflows.policies import Policies +from leapp.tags import UnitTestWorkflowTag, FirstPhaseTag, SecondPhaseTag + + +class UnitTestWorkflow(Workflow): + name = 'LeappDBUnitTest' + tag = UnitTestWorkflowTag + short_name = 'unit_test' + description = '''No description has been provided for the UnitTest workflow.''' + configuration = UnitTestConfig + + class FirstPhase(Phase): + name = 'first-phase' + filter = TagFilter(FirstPhaseTag) + policies = Policies(Policies.Errors.FailImmediately, Policies.Retry.Phase) + flags = Flags() + + class SecondPhase(Phase): + name = 'second-phase' + filter = TagFilter(SecondPhaseTag) + policies = Policies(Policies.Errors.FailPhase, Policies.Retry.Phase) + flags = Flags() + + # Template for phase definition - The order in which the phase classes are defined + # within the Workflow class represents the execution order + # + # class PhaseName(Phase): + # name = 'phase_name' + # filter = TagFilter(PhaseTag) + # policies = Policies(Policies.Errors.FailPhase, + # Policies.Retry.Phase) + # flags = Flags() diff --git a/tests/scripts/test_actor_api.py b/tests/scripts/test_actor_api.py index f009e689d..2e626dabf 100644 --- a/tests/scripts/test_actor_api.py +++ b/tests/scripts/test_actor_api.py @@ -188,7 +188,13 @@ def test_actor_get_answers(monkeypatch, leapp_forked, setup_database, repository def mocked_input(title): return user_responses[title.split()[0].split(':')[0].lower()][0] + def mocked_store_dialog(dialog, answer): + # Silence warnings + dialog = answer + answer = dialog + monkeypatch.setattr('leapp.dialogs.renderer.input', mocked_input) + monkeypatch.setattr('leapp.actors.store_dialog', mocked_store_dialog) messaging = _TestableMessaging() with _with_loaded_actor(repository, actor_name, messaging) as (_unused, actor): diff --git a/tests/scripts/test_dialog_db.py b/tests/scripts/test_dialog_db.py new file mode 100644 index 000000000..e73eac848 --- /dev/null +++ b/tests/scripts/test_dialog_db.py @@ -0,0 +1,186 @@ +import os +import json +import tempfile + +import mock +import py +import pytest + +from leapp.repository.scan import scan_repo +from leapp.dialogs import Dialog +from leapp.dialogs.components import BooleanComponent, ChoiceComponent, NumberComponent, TextComponent +from leapp.utils.audit import get_connection, dict_factory, store_dialog +from leapp.utils.audit import Dialog as DialogDB +from leapp.config import get_config + +_HOSTNAME = 'test-host.example.com' +_CONTEXT_NAME = 'test-context-name-dialogdb' +_ACTOR_NAME = 'test-actor-name' +_PHASE_NAME = 'test-phase-name' +_DIALOG_SCOPE = 'test-dialog' + +_TEXT_COMPONENT_METADATA = { + 'default': None, + 'description': 'a text value is needed', + 'key': 'text', + 'label': 'text', + 'reason': None, + 'value': None +} +_BOOLEAN_COMPONENT_METADATA = { + 'default': None, + 'description': 'a boolean value is needed', + 'key': 'bool', + 'label': 'bool', + 'reason': None, + 'value': None +} + +_NUMBER_COMPONENT_METADATA = { + 'default': -1, + 'description': 'a numeric value is needed', + 'key': 'num', + 'label': 'num', + 'reason': None, + 'value': None +} +_CHOICE_COMPONENT_METADATA = { + 'default': None, + 'description': 'need to choose one of these choices', + 'key': 'choice', + 'label': 'choice', + 'reason': None, + 'value': None +} +_COMPONENT_METADATA = [ + _TEXT_COMPONENT_METADATA, _BOOLEAN_COMPONENT_METADATA, _NUMBER_COMPONENT_METADATA, _CHOICE_COMPONENT_METADATA +] +_COMPONENT_METADATA_FIELDS = ('default', 'description', 'key', 'label', 'reason', 'value') +_DIALOG_METADATA_FIELDS = ('answer', 'title', 'reason', 'components') + +_TEST_DIALOG = Dialog( + scope=_DIALOG_SCOPE, + reason='need to test dialogs', + components=( + TextComponent( + key='text', + label='text', + description='a text value is needed', + ), + BooleanComponent(key='bool', label='bool', description='a boolean value is needed'), + NumberComponent(key='num', label='num', description='a numeric value is needed'), + ChoiceComponent( + key='choice', + label='choice', + description='need to choose one of these choices', + choices=('One', 'Two', 'Three', 'Four', 'Five'), + ), + ), +) + + +@pytest.fixture(scope='module') +def repository(): + repository_path = py.path.local(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'leappdb-tests')) + with repository_path.as_cwd(): + repo = scan_repo('.') + repo.load(resolve=True) + yield repo + + +def setup_module(): + get_config().set('database', 'path', '/tmp/leapp-test.db') + + +def setup(): + path = get_config().get('database', 'path') + if os.path.isfile(path): + os.unlink(path) + + +def fetch_dialog(dialog_id=None): + entry = None + with get_connection(None) as conn: + + if dialog_id is not None: + cursor = conn.execute('SELECT * FROM dialog WHERE id = ?;', (dialog_id,)) + else: # Fetch last saved dialog + cursor = conn.execute('SELECT * FROM dialog ORDER BY id DESC LIMIT 1;',) + + cursor.row_factory = dict_factory + entry = cursor.fetchone() + + return entry + + +def test_save_empty_dialog(): + e = DialogDB( + scope=_DIALOG_SCOPE, + data=None, + context=_CONTEXT_NAME, + actor=_ACTOR_NAME, + phase=_PHASE_NAME, + hostname=_HOSTNAME, + ) + e.store() + + assert e.dialog_id + assert e.data_source_id + assert e.host_id + + entry = fetch_dialog(e.dialog_id) + assert entry is not None + assert entry['data_source_id'] == e.data_source_id + assert entry['context'] == _CONTEXT_NAME + assert entry['scope'] == _DIALOG_SCOPE + assert entry['data'] == 'null' + + +def test_save_dialog(monkeypatch): + monkeypatch.setenv('LEAPP_CURRENT_ACTOR', _ACTOR_NAME) + monkeypatch.setenv('LEAPP_CURRENT_PHASE', _PHASE_NAME) + monkeypatch.setenv('LEAPP_EXECUTION_ID', _CONTEXT_NAME) + monkeypatch.setenv('LEAPP_HOSTNAME', _HOSTNAME) + e = store_dialog(_TEST_DIALOG, {}) + monkeypatch.delenv('LEAPP_CURRENT_ACTOR') + monkeypatch.delenv('LEAPP_CURRENT_PHASE') + monkeypatch.delenv('LEAPP_EXECUTION_ID') + monkeypatch.delenv('LEAPP_HOSTNAME') + + entry = fetch_dialog(e.dialog_id) + assert entry is not None + assert entry['data_source_id'] == e.data_source_id + assert entry['context'] == _CONTEXT_NAME + assert entry['scope'] == _TEST_DIALOG.scope + + entry_data = json.loads(entry['data']) + + assert sorted(entry_data.keys()) == sorted(_DIALOG_METADATA_FIELDS) + + assert entry_data['answer'] == {} + assert entry_data['reason'] == 'need to test dialogs' + assert entry_data['title'] is None + for component_metadata in _COMPONENT_METADATA: + assert sorted(component_metadata.keys()) == sorted(_COMPONENT_METADATA_FIELDS) + assert component_metadata in entry_data['components'] + + +def test_save_dialog_workflow(monkeypatch, repository): + workflow = repository.lookup_workflow('LeappDBUnitTest')() + with tempfile.NamedTemporaryFile(mode='w') as stdin_dialog: + monkeypatch.setenv('LEAPP_TEST_EXECUTION_LOG', '/dev/null') + stdin_dialog.write('my answer\n') + stdin_dialog.write('yes\n') + stdin_dialog.write('42\n') + stdin_dialog.write('0\n') + stdin_dialog.seek(0) + with mock.patch('sys.stdin.fileno', return_value=stdin_dialog.fileno()): + workflow.run(skip_dialogs=False) + + monkeypatch.delenv('LEAPP_TEST_EXECUTION_LOG', '/dev/null') + + entry = fetch_dialog() + assert entry is not None + assert entry['scope'] == 'unique_dialog_scope' + data = json.loads(entry['data']) + assert data['answer'] == {'text': 'my answer', 'num': 42, 'bool': True, 'choice': 'One'} diff --git a/tests/scripts/test_exit_status.py b/tests/scripts/test_exit_status.py new file mode 100644 index 000000000..11e8583ff --- /dev/null +++ b/tests/scripts/test_exit_status.py @@ -0,0 +1,68 @@ +import os +import json +import tempfile + +import py +import pytest + +from leapp.repository.scan import scan_repo +from leapp.config import get_config +from leapp.utils.audit import get_audit_entry + +_HOSTNAME = 'test-host.example.com' +_CONTEXT_NAME = 'test-context-name-exit-status' +_ACTOR_NAME = 'test-actor-name' +_PHASE_NAME = 'test-phase-name' +_DIALOG_SCOPE = 'test-dialog' + + +@pytest.fixture(scope='module') +def repository(): + repository_path = py.path.local(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'leappdb-tests')) + with repository_path.as_cwd(): + repo = scan_repo('.') + repo.load(resolve=True) + yield repo + + +def setup_module(): + get_config().set('database', 'path', '/tmp/leapp-test.db') + + +def setup(): + path = get_config().get('database', 'path') + if os.path.isfile(path): + os.unlink(path) + + +@pytest.mark.parametrize('error, code', [(None, 0), ('StopActorExecution', 0), ('StopActorExecutionError', 0), + ('UnhandledError', 1)]) +def test_exit_status_stopactorexecution(monkeypatch, repository, error, code): + + workflow = repository.lookup_workflow('LeappDBUnitTest')() + + if error is not None: + os.environ['ExitStatusActor-Error'] = error + else: + os.environ.pop('ExitStatusActor-Error', None) + + with tempfile.NamedTemporaryFile() as test_log_file: + monkeypatch.setenv('LEAPP_TEST_EXECUTION_LOG', test_log_file.name) + monkeypatch.setenv('LEAPP_HOSTNAME', _HOSTNAME) + try: + workflow.run(skip_dialogs=True, context=_CONTEXT_NAME, until_actor='ExitStatusActor') + except BaseException: # pylint: disable=broad-except + pass + + ans = get_audit_entry('actor-exit-status', _CONTEXT_NAME).pop() + + assert ans is not None + assert ans['actor'] == 'exit_status_actor' + assert ans['context'] == _CONTEXT_NAME + assert ans['hostname'] == _HOSTNAME + data = json.loads(ans['data']) + assert data['exit_status'] == code + + +def teardown(): + os.environ.pop('ExitStatusActor-Error', None) diff --git a/tests/scripts/test_metadata.py b/tests/scripts/test_metadata.py new file mode 100644 index 000000000..c850fdb09 --- /dev/null +++ b/tests/scripts/test_metadata.py @@ -0,0 +1,195 @@ +import os +import json +import logging + +import mock +import py +import pytest + +from leapp.repository.scan import scan_repo +from leapp.repository.actor_definition import ActorDefinition +from leapp.utils.audit import get_connection, dict_factory, Metadata, store_actor_metadata, store_workflow_metadata +from leapp.config import get_config + +_HOSTNAME = 'test-host.example.com' +_CONTEXT_NAME = 'test-context-name-metadata' +_ACTOR_NAME = 'test-actor-name' +_PHASE_NAME = 'test-phase-name' +_DIALOG_SCOPE = 'test-dialog' + +_WORKFLOW_METADATA_FIELDS = ('description', 'name', 'phases', 'short_name', 'tag') +_ACTOR_METADATA_FIELDS = ('class_name', 'name', 'description', 'phase', 'tags', 'consumes', 'produces', 'path') + +_TEST_WORKFLOW_METADATA = { + 'description': 'No description has been provided for the UnitTest workflow.', + 'name': 'LeappDBUnitTest', + 'phases': [{ + 'class_name': 'FirstPhase', + 'filter': { + 'phase': 'FirstPhaseTag', + 'tags': ['UnitTestWorkflowTag'] + }, + 'flags': { + 'is_checkpoint': False, + 'request_restart_after_phase': False, + 'restart_after_phase': False + }, + 'index': 4, + 'name': 'first-phase', + 'policies': { + 'error': 'FailImmediately', + 'retry': 'Phase' + } + }, { + 'class_name': 'SecondPhase', + 'filter': { + 'phase': 'SecondPhaseTag', + 'tags': ['UnitTestWorkflowTag'] + }, + 'flags': { + 'is_checkpoint': False, + 'request_restart_after_phase': False, + 'restart_after_phase': False + }, + 'index': 5, + 'name': 'second-phase', + 'policies': { + 'error': 'FailPhase', + 'retry': 'Phase' + } + }], + 'short_name': 'unit_test', + 'tag': 'UnitTestWorkflowTag' +} +_TEST_ACTOR_METADATA = { + 'description': 'Test Description', + 'class_name': 'TestActor', + 'name': 'test-actor', + 'path': 'actors/test', + 'tags': (), + 'consumes': (), + 'produces': (), + 'dialogs': (), + 'apis': () +} + + +@pytest.fixture(scope='module') +def repository(): + repository_path = py.path.local(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'leappdb-tests')) + with repository_path.as_cwd(): + repo = scan_repo('.') + repo.load(resolve=True) + yield repo + + +def setup_module(): + get_config().set('database', 'path', '/tmp/leapp-test.db') + + +def setup(): + path = get_config().get('database', 'path') + if os.path.isfile(path): + os.unlink(path) + + +def test_save_empty_metadata(): + e = Metadata( + name='test-name', + metadata=None, + kind='test-kind', + context=_CONTEXT_NAME, + hostname=_HOSTNAME, + ) + e.store() + + assert e.metadata_id + assert e.host_id + + entry = None + with get_connection(None) as conn: + cursor = conn.execute('SELECT * FROM metadata WHERE id = ?;', (e.metadata_id,)) + cursor.row_factory = dict_factory + entry = cursor.fetchone() + + assert entry is not None + assert entry['kind'] == 'test-kind' + assert entry['name'] == 'test-name' + assert entry['context'] == _CONTEXT_NAME + assert entry['metadata'] == 'null' + + +def test_store_actor_metadata(monkeypatch, repository_dir): + # --- + # Test store actor metadata without error + # --- + with repository_dir.as_cwd(): + logger = logging.getLogger('leapp.actor.test') + with mock.patch.object(logger, 'log') as log_mock: + definition = ActorDefinition('actors/test', '.', log=log_mock) + with mock.patch('leapp.repository.actor_definition.get_actor_metadata', return_value=_TEST_ACTOR_METADATA): + with mock.patch('leapp.repository.actor_definition.get_actors', return_value=[True]): + definition._module = True + + monkeypatch.setenv('LEAPP_EXECUTION_ID', _CONTEXT_NAME) + monkeypatch.setenv('LEAPP_HOSTNAME', _HOSTNAME) + store_actor_metadata(definition, 'test-phase') + monkeypatch.delenv('LEAPP_EXECUTION_ID') + monkeypatch.delenv('LEAPP_HOSTNAME') + + # --- + # Test retrieve correct actor metadata + # --- + entry = None + with get_connection(None) as conn: + cursor = conn.execute('SELECT * FROM metadata WHERE name="test-actor";') + cursor.row_factory = dict_factory + entry = cursor.fetchone() + + assert entry is not None + assert entry['kind'] == 'actor' + assert entry['name'] == _TEST_ACTOR_METADATA['name'] + assert entry['context'] == _CONTEXT_NAME + + metadata = json.loads(entry['metadata']) + assert sorted(metadata.keys()) == sorted(_ACTOR_METADATA_FIELDS) + assert metadata['class_name'] == _TEST_ACTOR_METADATA['class_name'] + assert metadata['name'] == _TEST_ACTOR_METADATA['name'] + assert metadata['description'] == _TEST_ACTOR_METADATA['description'] + assert metadata['phase'] == 'test-phase' + assert sorted(metadata['tags']) == sorted(_TEST_ACTOR_METADATA['tags']) + assert sorted(metadata['consumes']) == sorted(_TEST_ACTOR_METADATA['consumes']) + assert sorted(metadata['produces']) == sorted(_TEST_ACTOR_METADATA['produces']) + + +def test_workflow_metadata(monkeypatch, repository): + # --- + # Test store workflow metadata without error + # --- + workflow = repository.lookup_workflow('LeappDBUnitTest')() + + monkeypatch.setenv('LEAPP_EXECUTION_ID', _CONTEXT_NAME) + monkeypatch.setenv('LEAPP_HOSTNAME', _HOSTNAME) + store_workflow_metadata(workflow) + monkeypatch.delenv('LEAPP_EXECUTION_ID') + monkeypatch.delenv('LEAPP_HOSTNAME') + + # --- + # Test retrieve correct workflow metadata + # --- + entry = None + with get_connection(None) as conn: + cursor = conn.execute( + 'SELECT * FROM metadata WHERE kind == "workflow" AND context = ? ORDER BY id DESC LIMIT 1;', + (_CONTEXT_NAME,)) + cursor.row_factory = dict_factory + entry = cursor.fetchone() + + assert entry is not None + assert entry['kind'] == 'workflow' + assert entry['name'] == 'LeappDBUnitTest' + assert entry['context'] == _CONTEXT_NAME + + metadata = json.loads(entry['metadata']) + assert sorted(metadata.keys()) == sorted(_WORKFLOW_METADATA_FIELDS) + assert metadata == _TEST_WORKFLOW_METADATA