From a079b948715c430eb6150a5aca09cdb9facc127c Mon Sep 17 00:00:00 2001 From: David Kubek Date: Wed, 27 Sep 2023 13:45:01 +0200 Subject: [PATCH] Extend leappdb for leapp-inspector This commit combines multiple additions to the leapp database. First addition is tracking of entity metadata. The `Metadata` model stores the metadata of entities such as `Actor` or `Workflow`. This data is stored in a new table `metadata` of the `leapp.db` file. 1. metadata of *discovered* actors. For an actor, the metadata stored contain: `class_name` - the name of the actor class `name` - the name given to the actor `description` - the actor's description `phase` - phase of execution of the actor `tags` - names of any tags associated with an actor `consumes` - list of all messages the actor consumes `produces` - list of all messages the actor produces `path` - the path to the actor source file 2. workflow metadata. For a workflow, the metadata stored contain: `name` - name of the workflow `short_name` - short name of the workflow `tag` - workflow tag `description` - workflow description `phases` - all phases associated with the workflow Next addition is tracking of dialog question. Previously leapp was not able to detect the actual question asked from the user as it could be generated dynamically when actor is called and depend on the configuration of the user's system. Last addition includes storing the actor exit status. Exit status is now saved as an audit event `actor-exit-status`. Exit status 0 represents successful execution or `StopActorExecution`/`StopActorExecutionError`, while 1 indicates an unexpected and unhandled exception. These changes collectively improve the metadata handling capabilities of, ensuring accurate storage and retrieval of essential information for various entities. --- leapp/actors/__init__.py | 13 +- leapp/dialogs/dialog.py | 1 + leapp/messaging/answerstore.py | 3 + leapp/utils/audit/__init__.py | 166 ++++++++++++++- leapp/workflows/__init__.py | 22 +- res/schema/audit-layout.sql | 22 +- .../0002-add-metadata-dialog-tables.sql | 22 ++ tests/data/leappdb-tests/.leapp/info | 1 + tests/data/leappdb-tests/.leapp/leapp.conf | 6 + .../actors/configprovider/actor.py | 17 ++ .../leappdb-tests/actors/dialogactor/actor.py | 36 ++++ .../actors/exitstatusactor/actor.py | 31 +++ .../leappdb-tests/libraries/test_helper.py | 7 + .../leappdb-tests/models/unittestconfig.py | 7 + tests/data/leappdb-tests/tags/firstphase.py | 5 + tests/data/leappdb-tests/tags/secondphase.py | 5 + .../leappdb-tests/tags/unittestworkflow.py | 5 + tests/data/leappdb-tests/topics/config.py | 5 + .../data/leappdb-tests/workflows/unit_test.py | 37 ++++ tests/scripts/test_actor_api.py | 6 + tests/scripts/test_dialog_db.py | 186 +++++++++++++++++ tests/scripts/test_exit_status.py | 68 ++++++ tests/scripts/test_metadata.py | 195 ++++++++++++++++++ 23 files changed, 857 insertions(+), 9 deletions(-) create mode 100644 res/schema/migrations/0002-add-metadata-dialog-tables.sql create mode 100644 tests/data/leappdb-tests/.leapp/info create mode 100644 tests/data/leappdb-tests/.leapp/leapp.conf create mode 100644 tests/data/leappdb-tests/actors/configprovider/actor.py create mode 100644 tests/data/leappdb-tests/actors/dialogactor/actor.py create mode 100644 tests/data/leappdb-tests/actors/exitstatusactor/actor.py create mode 100644 tests/data/leappdb-tests/libraries/test_helper.py create mode 100644 tests/data/leappdb-tests/models/unittestconfig.py create mode 100644 tests/data/leappdb-tests/tags/firstphase.py create mode 100644 tests/data/leappdb-tests/tags/secondphase.py create mode 100644 tests/data/leappdb-tests/tags/unittestworkflow.py create mode 100644 tests/data/leappdb-tests/topics/config.py create mode 100644 tests/data/leappdb-tests/workflows/unit_test.py create mode 100644 tests/scripts/test_dialog_db.py create mode 100644 tests/scripts/test_exit_status.py create mode 100644 tests/scripts/test_metadata.py 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