diff --git a/src/aap_eda/core/exceptions.py b/src/aap_eda/core/exceptions.py index 8726a16c7..ce067e53c 100644 --- a/src/aap_eda/core/exceptions.py +++ b/src/aap_eda/core/exceptions.py @@ -39,3 +39,7 @@ class DuplicateFileTemplateKeyError(Exception): class DuplicateEnvKeyError(Exception): pass + + +class InvalidEnvKeyError(Exception): + pass diff --git a/src/aap_eda/core/utils/credentials.py b/src/aap_eda/core/utils/credentials.py index 8c6c5c92f..93e1ff766 100644 --- a/src/aap_eda/core/utils/credentials.py +++ b/src/aap_eda/core/utils/credentials.py @@ -33,6 +33,7 @@ if typing.TYPE_CHECKING: from aap_eda.core import models + from aap_eda.core.utils.awx import validate_ssh_private_key ENCRYPTED_STRING = "$encrypted$" @@ -563,3 +564,18 @@ def _add_file_template_keys(context: dict, files: dict): context["eda"]["filename"][parts[1]] = "" else: context["eda"] = {"filename": {parts[1]: ""}} + + +def add_default_values_to_user_inputs(schema: dict, inputs: dict) -> dict: + for field in schema.get("fields", []): + key = field.get("id") + field_type = field.get("type", "string") + default_value = field.get("default") + + if key not in inputs: + if field_type == "string": + inputs[key] = default_value or "" + if field_type == "boolean": + inputs[key] = default_value or False + + return inputs diff --git a/src/aap_eda/wsapi/consumers.py b/src/aap_eda/wsapi/consumers.py index a91a6e3cc..b79507edf 100644 --- a/src/aap_eda/wsapi/consumers.py +++ b/src/aap_eda/wsapi/consumers.py @@ -19,9 +19,13 @@ from aap_eda.core.exceptions import ( DuplicateEnvKeyError, DuplicateFileTemplateKeyError, + InvalidEnvKeyError, ) from aap_eda.core.models.activation import ActivationStatus -from aap_eda.core.utils.credentials import get_secret_fields +from aap_eda.core.utils.credentials import ( + add_default_values_to_user_inputs, + get_secret_fields, +) from aap_eda.core.utils.strings import extract_variables, substitute_variables from aap_eda.tasks import orchestrator @@ -118,6 +122,8 @@ async def receive(self, text_data=None, bytes_data=None): logger.warning(f"Unsupported message received: {data}") except DatabaseError as err: logger.error(f"Failed to parse {data} due to DB error: {err}") + except InvalidEnvKeyError as err: + logger.error(f"Failed to parse {data} due to Env error: {err}") async def handle_workers(self, message: WorkerMessage): logger.info(f"Start to handle workers: {message}") @@ -531,39 +537,41 @@ def get_file_contents_from_credentials( def get_env_vars_from_credentials( self, activation: models.Activation ) -> tp.Optional[str]: - vault_password, vault_id = self.get_vault_password_and_id(activation) - env_vars = {} - - for eda_credential in activation.eda_credentials.all(): - schema_inputs = eda_credential.credential_type.inputs - secret_fields = get_secret_fields(schema_inputs) - injectors = eda_credential.credential_type.injectors - user_inputs = yaml.safe_load( - eda_credential.inputs.get_secret_value() + try: + vault_password, vault_id = self.get_vault_password_and_id( + activation ) - if "env" not in injectors: - continue - - if secret_fields: - self.encrypt_user_inputs( - secret_fields=secret_fields, - user_inputs=user_inputs, - password=vault_password, - vault_id=vault_id, - ) + env_vars = {} - for key, value in injectors["env"].items(): - if key in env_vars: - raise DuplicateEnvKeyError(f"env {key} already exists") - env_vars[key] = ( - value - if not isinstance(value, str) or "eda.filename" in value - else substitute_variables(value, user_inputs) + for eda_credential in activation.eda_credentials.all(): + injectors = eda_credential.credential_type.injectors + if "env" not in injectors: + continue + + schema_inputs = eda_credential.credential_type.inputs + secret_fields = get_secret_fields(schema_inputs) + user_inputs = yaml.safe_load( + eda_credential.inputs.get_secret_value() ) - if not env_vars: - return None - return yaml.dump(env_vars) + add_default_values_to_user_inputs(schema_inputs, user_inputs) + + if secret_fields: + self.encrypt_user_inputs( + secret_fields=secret_fields, + user_inputs=user_inputs, + password=vault_password, + vault_id=vault_id, + ) + + self.substitute_envs(env_vars, injectors, user_inputs) + + if not env_vars: + return None + + return yaml.dump(env_vars) + except TypeError as err: + raise InvalidEnvKeyError(str(err)) from err @staticmethod def encrypt_user_inputs( @@ -580,6 +588,20 @@ def encrypt_user_inputs( vault_id=vault_id, ) + @staticmethod + def substitute_envs( + envs: dict, injectors: dict, user_inputs: dict + ) -> None: + for key, value in injectors["env"].items(): + if key in envs: + raise DuplicateEnvKeyError(f"env {key} already exists") + + envs[key] = ( + value + if not isinstance(value, str) or "eda.filename" in value + else substitute_variables(value, user_inputs) + ) + @staticmethod def get_vault_password_and_id( activation: models.Activation, diff --git a/tests/integration/wsapi/test_consumer.py b/tests/integration/wsapi/test_consumer.py index efbcdfdaf..fc3840a27 100644 --- a/tests/integration/wsapi/test_consumer.py +++ b/tests/integration/wsapi/test_consumer.py @@ -738,6 +738,7 @@ def get_job_instance_event_count(): def _prepare_activation_instance_with_credentials( default_organization: models.Organization, credentials: list[models.EdaCredential], + system_vault_credential: models.EdaCredential = None, ): project, _ = models.Project.objects.get_or_create( name="test-project", @@ -774,6 +775,7 @@ def _prepare_activation_instance_with_credentials( project=project, user=user, decision_environment=decision_environment, + eda_system_vault_credential=system_vault_credential, organization=default_organization, ) activation.eda_credentials.add(*credentials) @@ -1237,6 +1239,41 @@ async def test_handle_workers_with_file_contents( assert response[key] == value +@pytest.mark.django_db(transaction=True) +async def test_handle_workers_with_env_vars( + ws_communicator: WebsocketCommunicator, + preseed_credential_types, + default_organization: models.Organization, +): + eda_credential = await _prepare_aap_credential_async(default_organization) + system_credential = await _prepare_system_vault_credential_async( + default_organization + ) + rulebook_process_id = await _prepare_activation_instance_with_credentials( + default_organization, + [eda_credential], + system_credential, + ) + + payload = { + "type": "Worker", + "activation_id": rulebook_process_id, + } + await ws_communicator.send_json_to(payload) + + for type in [ + "Rulebook", + "ControllerInfo", + "VaultCollection", + "EnvVars", + "EndOfResponse", + ]: + response = await ws_communicator.receive_json_from(timeout=TIMEOUT) + assert response["type"] == type + if type == "EnvVars": + assert response["data"].startswith("Q09OVFJPTExFUl9IT1NUOiBodHRwc") + + @database_sync_to_async def _prepare_credential( credential_type_inputs: dict, @@ -1259,6 +1296,33 @@ def _prepare_credential( ) +@database_sync_to_async +def _prepare_aap_credential_async( + organization: models.Organization, +): + return _prepare_aap_credential(organization) + + +def _prepare_aap_credential( + organization: models.Organization, +) -> models.EdaCredential: + aap_credential_type = models.CredentialType.objects.get( + name=enums.DefaultCredentialType.AAP + ) + + data = "secret" + return models.EdaCredential.objects.create( + name="eda_aap_credential", + inputs={ + "host": "https://controller_url/", + "username": "adam", + "password": data, + }, + credential_type=aap_credential_type, + organization=organization, + ) + + @database_sync_to_async def _prepare_system_vault_credential_async( organization: models.Organization, diff --git a/tests/unit/test_credential_validation.py b/tests/unit/test_credential_validation.py index 6f5b0b2a7..eac52e8db 100644 --- a/tests/unit/test_credential_validation.py +++ b/tests/unit/test_credential_validation.py @@ -20,6 +20,7 @@ from aap_eda.core.utils.credentials import ( PROTECTED_PASSPHRASE_ERROR, SUPPORTED_KEYS_IN_INJECTORS, + add_default_values_to_user_inputs, validate_injectors, validate_inputs, validate_schema, @@ -632,3 +633,68 @@ def test_validate_registry_host_name(aap_credential_type): {"host": "invalid@name"}, ) assert "Host format invalid" in errors["inputs.host"] + + +@pytest.mark.django_db +def test_add_default_values_to_user_inputs(): + user_inputs = {"host": "https://eda_controller_url"} + schema = { + "fields": [ + { + "id": "host", + "label": "Red Hat Ansible Automation Platform", + "type": "string", + }, + { + "id": "username", + "label": "Username", + "type": "string", + "default": "sysadmin", + "help_text": ( + "Red Hat Ansible Automation Platform username id" + " to authenticate as.This should not be set if" + " an OAuth token is being used." + ), + }, + { + "id": "need_password", + "label": "Need Password", + "type": "boolean", + "default": True, + "secret": True, + }, + { + "id": "oauth_token", + "label": "OAuth Token", + "type": "string", + "secret": True, + "help_text": ( + "An OAuth token to use to authenticate with." + "This should not be set if username/password" + " are being used." + ), + }, + { + "id": "verify_ssl", + "label": "Verify SSL", + "type": "boolean", + "secret": False, + }, + ], + "required": ["host"], + } + add_default_values_to_user_inputs(schema, user_inputs) + assert list(user_inputs.keys()) == [ + "host", + "username", + "need_password", + "oauth_token", + "verify_ssl", + ] + assert list(user_inputs.values()) == [ + "https://eda_controller_url", + "sysadmin", + True, + "", + False, + ]