From 91b309ac8c4cf6a709694144cb1e451ff1fa72f7 Mon Sep 17 00:00:00 2001 From: David Kubek Date: Mon, 13 May 2024 18:07:26 +0200 Subject: [PATCH 07/23] Extend information from leapp saved to leappdb (#847) 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. Jira: OAMG-8402 --- leapp/actors/__init__.py | 13 +- leapp/dialogs/dialog.py | 1 + leapp/messaging/answerstore.py | 3 + leapp/utils/audit/__init__.py | 199 +++++++++++++++- leapp/utils/audit/contextclone.py | 22 ++ leapp/workflows/__init__.py | 22 +- res/schema/audit-layout.sql | 26 +- .../0002-add-metadata-dialog-tables.sql | 27 +++ 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 | 223 ++++++++++++++++++ 24 files changed, 949 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 7ae18ea..9d83bf1 100644 --- a/leapp/actors/__init__.py +++ b/leapp/actors/__init__.py @@ -10,6 +10,7 @@ from leapp.models import DialogModel, Model 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 @@ class Actor(object): :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 @@ class Actor(object): 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 3ead810..320be1b 100644 --- a/leapp/dialogs/dialog.py +++ b/leapp/dialogs/dialog.py @@ -114,4 +114,5 @@ class Dialog(object): 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 3e55e8a..b2c707d 100644 --- a/leapp/messaging/answerstore.py +++ b/leapp/messaging/answerstore.py @@ -117,6 +117,9 @@ class AnswerStore(object): # 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 6b00413..16db107 100644 --- a/leapp/utils/audit/__init__.py +++ b/leapp/utils/audit/__init__.py @@ -3,6 +3,7 @@ import datetime import json import os import sqlite3 +import hashlib from leapp.config import get_config from leapp.compat import string_types @@ -221,6 +222,72 @@ class DataSource(Host): self._data_source_id = cursor.fetchone()[0] +class Metadata(Storable): + """ + Metadata of an Entity + """ + + def __init__(self, metadata=None, hash_id=None): + """ + :param metadata: Entity metadata + :type metadata: str + :param hash_id: SHA256 hash in hexadecimal representation of data + :type hash_id: str + """ + super(Metadata, self).__init__() + self.metadata = metadata + self.hash_id = hash_id + + def do_store(self, connection): + super(Metadata, self).do_store(connection) + connection.execute('INSERT OR IGNORE INTO metadata (hash, metadata) VALUES(?, ?)', + (self.hash_id, self.metadata)) + + +class Entity(Host): + """ + Leapp framework 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: Entity metadata + :type metadata: :py:class:`leapp.utils.audit.Metadata` + :param name: Name of the entity + :type name: str + """ + super(Entity, self).__init__(context=context, hostname=hostname) + self.kind = kind + self.name = name + self.metadata = metadata + self._entity_id = None + + @property + def entity_id(self): + """ + Returns the id of the entry, which is only set when already stored. + :return: Integer id or None + """ + return self._entity_id + + def do_store(self, connection): + super(Entity, self).do_store(connection) + self.metadata.do_store(connection) + connection.execute( + 'INSERT OR IGNORE INTO entity (context, kind, name, metadata_hash) VALUES(?, ?, ?, ?)', + (self.context, self.kind, self.name, self.metadata.hash_id)) + cursor = connection.execute( + 'SELECT id FROM entity WHERE context = ? AND kind = ? AND name = ?', + (self.context, self.kind, self.name)) + self._entity_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 +334,47 @@ class Message(DataSource): 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 +399,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 +578,90 @@ 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 = json.dumps(type(workflow).serialize(), sort_keys=True) + metadata_hash_id = hashlib.sha256(metadata.encode('utf-8')).hexdigest() + + md = Metadata(metadata=metadata, hash_id=metadata_hash_id) + ent = Entity(kind='workflow', + name=workflow.name, + context=os.environ['LEAPP_EXECUTION_ID'], + hostname=os.environ['LEAPP_HOSTNAME'], + metadata=md) + ent.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': sorted(model.__name__ for model in _metadata.get('consumes', ())), + 'produces': sorted(model.__name__ for model in _metadata.get('produces', ())), + 'tags': sorted(tag.__name__ for tag in _metadata.get('tags', ())), + }) + _metadata['phase'] = phase + + actor_metadata_fields = ('class_name', 'name', 'description', 'phase', 'tags', 'consumes', 'produces', 'path') + metadata = json.dumps({field: _metadata[field] for field in actor_metadata_fields}, sort_keys=True) + metadata_hash_id = hashlib.sha256(metadata.encode('utf-8')).hexdigest() + + md = Metadata(metadata=metadata, hash_id=metadata_hash_id) + ent = Entity(kind='actor', + name=actor_definition.name, + context=os.environ['LEAPP_EXECUTION_ID'], + hostname=os.environ['LEAPP_HOSTNAME'], + metadata=md) + ent.store() diff --git a/leapp/utils/audit/contextclone.py b/leapp/utils/audit/contextclone.py index 2e28b70..1c80f2c 100644 --- a/leapp/utils/audit/contextclone.py +++ b/leapp/utils/audit/contextclone.py @@ -70,6 +70,26 @@ def _dup_audit(db, message, data_source, newcontext, oldcontext): return lookup +def _dup_metadata(db, newcontext, oldcontext): + for row in _fetch_table_for_context(db, 'metadata', oldcontext): + # id context kind name metadata + row_id, kind, name, metadata = _row_tuple(row, 'id', 'kind', 'name', 'metadata') + + db.execute( + 'INSERT INTO metadata (context, kind, name, metadata) VALUES(?, ?, ?, ?)', + (newcontext, kind, name, metadata)) + + +def _dup_dialog(db, data_source, newcontext, oldcontext): + for row in _fetch_table_for_context(db, 'dialog', oldcontext): + # id context scope data data_source_id + row_id, scope, data, data_source_id = _row_tuple(row, 'id', 'scope', 'data', 'data_source_id') + + db.execute( + 'INSERT INTO dialog (context, scope, data, data_source_id) VALUES(?, ?, ?, ?)', + (newcontext, scope, data, data_source[data_source_id])) + + def clone_context(oldcontext, newcontext, use_db=None): # Enter transaction - In case of any exception automatic rollback is issued # and it is automatically committed if there was no exception @@ -82,3 +102,5 @@ def clone_context(oldcontext, newcontext, use_db=None): message = _dup_message(db=db, data_source=data_source, newcontext=newcontext, oldcontext=oldcontext) # Last clone message entries and use the lookup table generated by the data_source and message duplications _dup_audit(db=db, data_source=data_source, message=message, newcontext=newcontext, oldcontext=oldcontext) + _dup_metadata(db=db, oldcontext=oldcontext, newcontext=newcontext) + _dup_dialog(db=db, data_source=data_source, oldcontext=oldcontext, newcontext=newcontext) diff --git a/leapp/workflows/__init__.py b/leapp/workflows/__init__.py index 7f01e0d..1b6fc98 100644 --- a/leapp/workflows/__init__.py +++ b/leapp/workflows/__init__.py @@ -11,7 +11,7 @@ from leapp.messaging.inprocess import InProcessMessaging 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 @@ class Workflow(with_metaclass(WorkflowMeta)): 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 @@ class Workflow(with_metaclass(WorkflowMeta)): 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 @@ class Workflow(with_metaclass(WorkflowMeta)): 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 @@ class Workflow(with_metaclass(WorkflowMeta)): 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 @@ class Workflow(with_metaclass(WorkflowMeta)): 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 dd88a45..d567ce4 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, @@ -42,6 +42,28 @@ CREATE TABLE IF NOT EXISTS message ( message_data_hash VARCHAR(64) NOT NULL REFERENCES message_data (hash) ); +CREATE TABLE IF NOT EXISTS metadata ( + hash VARCHAR(64) PRIMARY KEY NOT NULL, + metadata TEXT +); + +CREATE TABLE IF NOT EXISTS entity ( + 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_hash VARCHAR(64) NOT NULL REFERENCES metadata (hash), + 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, @@ -74,4 +96,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 0000000..476a0c3 --- /dev/null +++ b/res/schema/migrations/0002-add-metadata-dialog-tables.sql @@ -0,0 +1,27 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS metadata ( + hash VARCHAR(64) PRIMARY KEY NOT NULL, + metadata TEXT +); + +CREATE TABLE IF NOT EXISTS entity ( + 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_hash VARCHAR(64) NOT NULL REFERENCES metadata (hash), + 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) +); + +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 0000000..2c42aa6 --- /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 0000000..b459134 --- /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 0000000..985de52 --- /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 0000000..f9d5f77 --- /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 0000000..ae41aa5 --- /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 0000000..fd5b910 --- /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 0000000..10fad83 --- /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 0000000..e465892 --- /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 0000000..ead6c95 --- /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 0000000..4a45594 --- /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 0000000..9ed3140 --- /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 0000000..856d8e9 --- /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 f009e68..2e626da 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 0000000..e73eac8 --- /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 0000000..11e8583 --- /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 0000000..9b5c07f --- /dev/null +++ b/tests/scripts/test_metadata.py @@ -0,0 +1,223 @@ +import os +import json +import logging +import hashlib + +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, Entity, 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(): + hash_id = hashlib.sha256('test-empty-metadata'.encode('utf-8')).hexdigest() + md = Metadata(hash_id=hash_id, metadata='') + md.store() + + entry = None + with get_connection(None) as conn: + cursor = conn.execute('SELECT * FROM metadata WHERE hash = ?;', (hash_id,)) + cursor.row_factory = dict_factory + entry = cursor.fetchone() + + assert entry is not None + assert entry['metadata'] == '' + + +def test_save_empty_entity(): + hash_id = hashlib.sha256('test-empty-entity'.encode('utf-8')).hexdigest() + md = Metadata(hash_id=hash_id, metadata='') + e = Entity( + name='test-name', + metadata=md, + kind='test-kind', + context=_CONTEXT_NAME, + hostname=_HOSTNAME, + ) + e.store() + + assert e.entity_id + assert e.host_id + + entry = None + with get_connection(None) as conn: + cursor = conn.execute('SELECT * FROM entity WHERE id = ?;', (e.entity_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_hash'] == hash_id + + +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 entity ' + 'JOIN metadata ' + 'ON entity.metadata_hash = metadata.hash ' + '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 entity ' + 'JOIN metadata ' + 'ON entity.metadata_hash = metadata.hash ' + '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 -- 2.42.0