From d3c37a26e175d58270b5d4f2eb9b4975d2790b1a Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 23 Feb 2026 17:13:57 +0100 Subject: [PATCH 01/26] Add credential test definitions to tool parsers and XSD schema --- lib/galaxy/tool_util/parser/interface.py | 16 ++++++ lib/galaxy/tool_util/parser/xml.py | 40 +++++++++++++++ lib/galaxy/tool_util/parser/yaml.py | 60 +++++++++++++++++++++++ lib/galaxy/tool_util/xsd/galaxy.xsd | 62 ++++++++++++++++++++++++ 4 files changed, 178 insertions(+) diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index e7f2a10b0b9..c393227cdfe 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -117,6 +117,21 @@ class ToolSourceTestInput(TypedDict): attributes: ToolSourceTestInputAttributes +class DirectCredentialValue(TypedDict): + """Represents a credential value (variable or secret) provided directly.""" + + name: str + value: str + + +class DirectCredential(TypedDict): + """Represents a credential group with variables and secrets provided directly.""" + + name: str # Name of the credentials group + variables: List[DirectCredentialValue] + secrets: List[DirectCredentialValue] + + ToolSourceTestInputs = List[ToolSourceTestInput] ToolSourceTestOutputs = List[ToolSourceTestOutput] TestSourceTestOutputColllection = Any @@ -136,6 +151,7 @@ class ToolSourceTest(TypedDict): command: AssertionList command_version: AssertionList value_state_representation: Literal["test_case_xml", "test_case_json"] + credentials: Optional[List[DirectCredential]] class ToolSourceTests(TypedDict): diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index 9fb4540a016..f68cec47e64 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -53,6 +53,8 @@ from galaxy.util import ( ) from .interface import ( AssertionList, + DirectCredential, + DirectCredentialValue, DrillDownDynamicOptions, DynamicOptions, InputSource, @@ -819,6 +821,7 @@ def _test_elem_to_dict(test_elem, i, profile=None) -> ToolSourceTest: expect_test_failure=string_as_bool(test_elem.get("expect_test_failure", False)), maxseconds=test_elem.get("maxseconds", None), value_state_representation="test_case_xml", + credentials=__parse_credentials_elems(test_elem), ) _copy_to_dict_if_present(test_elem, rval, ["num_outputs"]) return rval @@ -1093,6 +1096,43 @@ def __parse_inputs_elems(test_elem, i) -> ToolSourceTestInputs: return raw_inputs +def __parse_credentials_elems(test_elem): + """ + Parse credential definitions from test element. + Returns a list of DirectCredential dictionaries or None if no credentials are defined. + """ + + credentials_list = [] + for cred_elem in test_elem.findall("credentials"): + name = cred_elem.get("name") + if not name: + raise ValueError("Credentials element must have a 'name' attribute") + + variables = [] + for var_elem in cred_elem.findall("variable"): + var_name = var_elem.get("name") + var_value = var_elem.get("value") + if not var_name: + raise ValueError("Credential variable must have a 'name' attribute") + if var_value is None: + raise ValueError(f"Credential variable '{var_name}' must have a 'value' attribute") + variables.append(DirectCredentialValue(name=var_name, value=var_value)) + + secrets = [] + for secret_elem in cred_elem.findall("secret"): + secret_name = secret_elem.get("name") + secret_value = secret_elem.get("value") + if not secret_name: + raise ValueError("Credential secret must have a 'name' attribute") + if secret_value is None: + raise ValueError(f"Credential secret '{secret_name}' must have a 'value' attribute") + secrets.append(DirectCredentialValue(name=secret_name, value=secret_value)) + + credentials_list.append(DirectCredential(name=name, variables=variables, secrets=secrets)) + + return credentials_list if credentials_list else None + + def _test_collection_def_dict(elem: Element) -> XmlTestCollectionDefDict: elements: List[TestCollectionDefElementDict] = [] attrib: Dict[str, Any] = _element_to_dict(elem) diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 80abbc150d8..6f933c72b30 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -41,6 +41,8 @@ from galaxy.util import listify from .interface import ( AssertionDict, AssertionList, + DirectCredential, + DirectCredentialValue, InputSource, PageSource, PagesSource, @@ -424,9 +426,67 @@ def _parse_test(i: int, test_dict: dict) -> ToolSourceTest: test_dict["expect_failure"] = test_dict.get("expect_failure", False) test_dict["expect_test_failure"] = test_dict.get("expect_test_failure", False) test_dict["value_state_representation"] = "test_case_json" + test_dict["credentials"] = __parse_credentials_yaml(test_dict.get("credentials", None)) return cast(ToolSourceTest, test_dict) +def __parse_credentials_yaml(credentials_data) -> Optional[List[DirectCredential]]: + """ + Parse credentials from YAML test definition. + + Supports both list and dict formats: + - List: [{name: "cred1", variables: [...], secrets: [...]}] + - Dict: {cred1: {variables: [...], secrets: []}} + """ + + if not credentials_data: + return None + + credentials_list: List[DirectCredential] = [] + + # Support both dict and list formats + if is_dict(credentials_data): + # Convert {name: {variables: [], secrets: []}} to list format + items = credentials_data.items() + else: + # Already a list: [{name: "...", variables: [], secrets: []}] + items = [(cred.get("name"), cred) for cred in credentials_data] + + for name, cred_data in items: + if not name: + raise ValueError("Test credentials must have a 'name'") + + variables: List[DirectCredentialValue] = [] + for var_data in cred_data.get("variables", []): + if is_dict(var_data): + var_name = var_data.get("name") + var_value = var_data.get("value") + else: + raise ValueError("YAML credential variable must be a dictionary with 'name' and 'value'") + if not var_name: + raise ValueError("Credential variable must have a 'name'") + if var_value is None: + raise ValueError(f"Credential variable '{var_name}' must have a 'value'") + variables.append(DirectCredentialValue(name=var_name, value=var_value)) + + secrets: List[DirectCredentialValue] = [] + for secret_data in cred_data.get("secrets", []): + if is_dict(secret_data): + secret_name = secret_data.get("name") + secret_value = secret_data.get("value") + else: + raise ValueError("YAML credential secret must be a dictionary with 'name' and 'value'") + if not secret_name: + raise ValueError("Credential secret must have a 'name'") + if secret_value is None: + raise ValueError(f"Credential secret '{secret_name}' must have a 'value'") + secrets.append(DirectCredentialValue(name=secret_name, value=secret_value)) + + credentials_list.append(DirectCredential(name=name, variables=variables, secrets=secrets)) + + return credentials_list if credentials_list else None + + def to_test_assert_list(assertions) -> AssertionList: assertions = assertions or [] diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index bbac660864c..0243960b30c 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -1399,6 +1399,7 @@ that at least one of the assumptions of the test is not met. This is most useful + @@ -1439,6 +1440,67 @@ $assertions + + + + + + + + + + +``` + +]]> + + + + + + + Name of the credential variable (must match a variable defined in the tool's requirements/credentials) + + + + + Test value for this variable + + + + + + + + + Name of the credential secret (must match a secret defined in the tool's requirements/credentials) + + + + + Test value for this secret (use public test credentials only) + + + + + + + + Name of the credentials group (must match the name defined in tool's requirements/credentials) + + + Date: Mon, 23 Feb 2026 17:14:08 +0100 Subject: [PATCH 02/26] Add credential support to test verification framework --- lib/galaxy/tool_util/verify/_types.py | 2 + lib/galaxy/tool_util/verify/interactor.py | 209 ++++++++++++++++------ lib/galaxy/tool_util/verify/parse.py | 1 + 3 files changed, 158 insertions(+), 54 deletions(-) diff --git a/lib/galaxy/tool_util/verify/_types.py b/lib/galaxy/tool_util/verify/_types.py index fd260f88a65..305d9592675 100644 --- a/lib/galaxy/tool_util/verify/_types.py +++ b/lib/galaxy/tool_util/verify/_types.py @@ -16,6 +16,7 @@ from typing_extensions import ( from galaxy.tool_util.parser.interface import ( AssertionList, + DirectCredential, TestSourceTestOutputColllection, ToolSourceTestOutputs, ) @@ -63,3 +64,4 @@ class ToolTestDescriptionDict(TypedDict): request_unavailable_reason: NotRequired[Optional[str]] maxseconds: NotRequired[Optional[int]] value_state_representation: NotRequired[ValueStateRepresentationT] + credentials: NotRequired[Optional[List[DirectCredential]]] diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index 1bf9d5d6998..b965952ea87 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -139,6 +139,7 @@ class ValidToolTestDict(TypedDict): required_files: NotRequired[RequiredFilesT] required_data_tables: NotRequired[RequiredDataTablesT] required_loc_files: NotRequired[RequiredLocFileT] + credentials: NotRequired[Optional[List[Any]]] error: Literal[False] tool_id: str tool_version: str @@ -706,6 +707,9 @@ class GalaxyInteractorApi: if testdef.value_state_representation == "test_case_json": # Don't submit user / YAML tools to the old endpoint. submit_with_legacy_api = False + if testdef.credentials: + # Credentials require the non-legacy API path to pass credentials_context. + submit_with_legacy_api = False if submit_with_legacy_api: inputs_tree = testdef.inputs.copy() @@ -764,62 +768,149 @@ class GalaxyInteractorApi: inputs_tree[f"__job_resource|{key}"] = value submit_response = None - for _ in range(DEFAULT_TOOL_TEST_WAIT): - submit_response = self.__submit_tool( - history_id, - tool_id=testdef.tool_id, - tool_input=inputs_tree, - tool_version=testdef.tool_version, - use_legacy_api=submit_with_legacy_api, - ) - if _are_tool_inputs_not_ready(submit_response): - print("Tool inputs not ready yet") - time.sleep(1) - continue - else: - break - submit_response_object = ensure_tool_run_response_okay(submit_response, "execute tool", inputs_tree) - if not submit_with_legacy_api: - tool_request_id = submit_response_object["tool_request_id"] - successful = self.wait_on_tool_request(tool_request_id) - if not successful: - request = self.get_tool_request(tool_request_id) or {} - raise RunToolException( - f"Tool request failure - state {request.get('state')}, message: {request.get('state_message')}", - inputs_tree, + + # Create vault-based credentials via API for test execution + created_credentials = [] + credentials_context = None + if testdef.credentials: + # Get user_id for credential creation + whoami_response = self._get("whoami") + user_id = whoami_response.json()["id"] + + credentials_context_list = [] + for cred in testdef.credentials: + # Build payload for credential creation + credential_payload = { + "source_type": "tool", + "source_id": testdef.tool_id, + "source_version": testdef.tool_version or "1.0.0", + "service_credential": { + "name": cred["name"], + "version": "1.0", # Default version for test credentials + "group": { + "name": f"test_group_{cred['name']}", + "variables": cred.get("variables", []), + "secrets": cred.get("secrets", []), + }, + }, + } + + # Create credentials via API + create_response = self._post(f"users/{user_id}/credentials", data=credential_payload, json=True) + create_response.raise_for_status() + created_cred = create_response.json() + + # Get the user_credentials_id by listing credentials + # (POST returns ServiceCredentialGroupResponse which only has the group id, + # we need UserServiceCredentialsResponse which has the user_credentials_id) + list_response = self._get(f"users/{user_id}/credentials") + list_response.raise_for_status() + all_credentials = list_response.json() + + # Find the credential we just created by matching source + # Then find the group in that credential's groups that matches our created group ID + user_credentials_id = None + for user_cred in all_credentials: + if user_cred["source_type"] == "tool" and user_cred["source_id"] == testdef.tool_id: + # Found the right UserCredentials, now find our group within it + for group in user_cred["groups"]: + if group["id"] == created_cred["id"]: + user_credentials_id = user_cred["id"] + break + if user_credentials_id: + break + + if not user_credentials_id: + raise RuntimeError( + f"Failed to find user_credentials_id for created credential group {created_cred['id']}" + ) + + # Store for cleanup + created_credentials.append({"user_credentials_id": user_credentials_id, "user_id": user_id}) + + # Build credentials_context entry + credentials_context_list.append( + { + "user_credentials_id": user_credentials_id, + "name": cred["name"], + "version": "1.0", + "selected_group": {"id": created_cred["id"], "name": created_cred["name"]}, + } ) - job_refs = self.jobs_for_tool_request(tool_request_id) - outputs = OutputsDict() - output_collections = {} - if len(job_refs) != 1: - raise Exception( - f"Found incorrect number of jobs for tool request - was expecting a single job {job_refs}" - ) - assert len(job_refs) == 1, job_refs - job_id = job_refs[0]["id"] - jobs = [self.__get_job(job_id).json()] - job_outputs = self.job_outputs(job_id) - for job_output in job_outputs: - if "dataset" in job_output: - outputs[job_output["name"]] = job_output["dataset"] - else: - output_collections[job_output["name"]] = job_output["dataset_collection_instance"] - else: - outputs = self.__dictify_outputs(submit_response_object) - output_collections = self.__dictify_output_collections(submit_response_object) - jobs = submit_response_object["jobs"] + + credentials_context = credentials_context_list + try: - return RunToolResponse( - inputs=inputs_tree, - outputs=outputs, - output_collections=output_collections, - jobs=jobs, - ) - except KeyError: - message = ( - f"Error creating a job for these tool inputs - {submit_response_object.get('err_msg', 'unknown error')}" - ) - raise RunToolException(message, inputs_tree) + for _ in range(DEFAULT_TOOL_TEST_WAIT): + submit_response = self.__submit_tool( + history_id, + tool_id=testdef.tool_id, + tool_input=inputs_tree, + tool_version=testdef.tool_version, + use_legacy_api=submit_with_legacy_api, + credentials_context=credentials_context, + ) + if _are_tool_inputs_not_ready(submit_response): + print("Tool inputs not ready yet") + time.sleep(1) + continue + else: + break + submit_response_object = ensure_tool_run_response_okay(submit_response, "execute tool", inputs_tree) + if not submit_with_legacy_api: + tool_request_id = submit_response_object["tool_request_id"] + successful = self.wait_on_tool_request(tool_request_id) + if not successful: + request = self.get_tool_request(tool_request_id) or {} + raise RunToolException( + f"Tool request failure - state {request.get('state')}, message: {request.get('state_message')}", + inputs_tree, + ) + job_refs = self.jobs_for_tool_request(tool_request_id) + outputs = OutputsDict() + output_collections = {} + if len(job_refs) != 1: + raise Exception( + f"Found incorrect number of jobs for tool request - was expecting a single job {job_refs}" + ) + assert len(job_refs) == 1, job_refs + job_id = job_refs[0]["id"] + # If credentials were created for this test, wait for job completion + # before the finally block cleans them up, so the job can read them. + if created_credentials: + self.wait_for_job(job_id, history_id, testdef.maxseconds or DEFAULT_TOOL_TEST_WAIT) + jobs = [self.__get_job(job_id).json()] + job_outputs = self.job_outputs(job_id) + for job_output in job_outputs: + if "dataset" in job_output: + outputs[job_output["name"]] = job_output["dataset"] + else: + output_collections[job_output["name"]] = job_output["dataset_collection_instance"] + else: + outputs = self.__dictify_outputs(submit_response_object) + output_collections = self.__dictify_output_collections(submit_response_object) + jobs = submit_response_object["jobs"] + try: + return RunToolResponse( + inputs=inputs_tree, + outputs=outputs, + output_collections=output_collections, + jobs=jobs, + ) + except KeyError: + message = f"Error creating a job for these tool inputs - {submit_response_object.get('err_msg', 'unknown error')}" + raise RunToolException(message, inputs_tree) + finally: + # Clean up created credentials + for cred_info in created_credentials: + try: + delete_response = self._delete( + f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}" + ) + delete_response.raise_for_status() + except Exception as e: + # Log but don't fail the test if cleanup fails + print(f"Warning: Failed to delete test credentials: {e}") def _create_collection(self, history_id, collection_def): create_payload = dict( @@ -1003,6 +1094,7 @@ class GalaxyInteractorApi: files: Optional[dict] = None, tool_version: Optional[str] = None, use_legacy_api: bool = True, + credentials_context: Optional[list] = None, ): extra_data = extra_data or {} if use_legacy_api: @@ -1019,6 +1111,8 @@ class GalaxyInteractorApi: data = dict( history_id=history_id, tool_id=tool_id, inputs=tool_input, tool_version=tool_version, **extra_data ) + if credentials_context: + data["credentials_context"] = credentials_context submit_tool_request_response = self._post("jobs", data=data, json=True) return submit_tool_request_response @@ -1937,6 +2031,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD request: Optional[Dict[str, Any]] = None request_schema: Optional[Dict[str, Any]] = None request_unavailable_reason: Optional[str] = None + credentials: Optional[List[Any]] = None if not error_in_test_definition: processed_test_dict = cast(ValidToolTestDict, processed_dict) @@ -1965,6 +2060,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD request = processed_test_dict.get("request", None) request_schema = processed_test_dict.get("request_schema", None) request_unavailable_reason = processed_test_dict.get("request_unavailable_reason", None) + credentials = processed_test_dict.get("credentials", None) else: invalid_test_dict = cast(InvalidToolTestDict, processed_dict) maxseconds = DEFAULT_TOOL_TEST_WAIT @@ -1998,6 +2094,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD request_schema=request_schema, request_unavailable_reason=request_unavailable_reason, value_state_representation=value_state_representation, + credentials=credentials, ) @@ -2064,6 +2161,7 @@ class ToolTestDescription: output_collections: List[TestCollectionOutputDef] maxseconds: Optional[int] value_state_representation: ValueStateRepresentationT + credentials: Optional[List[Any]] # List of credential context dicts @staticmethod def from_tool_source_dict(processed_test_dict: ToolTestDict) -> "ToolTestDescription": @@ -2096,6 +2194,7 @@ class ToolTestDescription: self.tool_version = json_dict.get("tool_version") self.maxseconds = json_dict.get("maxseconds") self.value_state_representation = json_dict.get("value_state_representation", "test_case_xml") + self.credentials = json_dict.get("credentials") def test_data(self): """ @@ -2133,6 +2232,8 @@ class ToolTestDescription: } if self.maxseconds is not None: test_description_def["maxseconds"] = self.maxseconds + if self.credentials is not None: + test_description_def["credentials"] = self.credentials return ToolTestDescriptionDict(**test_description_def) diff --git a/lib/galaxy/tool_util/verify/parse.py b/lib/galaxy/tool_util/verify/parse.py index 3938ed3770a..24418f77256 100644 --- a/lib/galaxy/tool_util/verify/parse.py +++ b/lib/galaxy/tool_util/verify/parse.py @@ -185,6 +185,7 @@ def _description_from_tool_source( "maxseconds": maxseconds, "error": False, "value_state_representation": value_state_representation, + "credentials": raw_test_dict.get("credentials", None), } ) except Exception: From 921fc99ce5ebc5b05755b2eaeeb43fc33685dd46 Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 23 Feb 2026 17:14:17 +0100 Subject: [PATCH 03/26] Add credential context to job and task submission APIs --- lib/galaxy/managers/jobs.py | 5 +++++ lib/galaxy/schema/tasks.py | 2 ++ lib/galaxy/tools/actions/__init__.py | 1 + lib/galaxy/webapps/galaxy/services/jobs.py | 5 +++++ lib/galaxy/webapps/galaxy/services/tools.py | 10 ++++++++-- 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 8a6195ba15f..6418967f3a5 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -79,6 +79,7 @@ from galaxy.model.index_filter_util import ( text_column_filter, ) from galaxy.model.scoped_session import galaxy_scoped_session +from galaxy.schema.credentials import CredentialsContext from galaxy.schema.schema import ( JobIndexQueryPayload, JobIndexSortByEnum, @@ -2186,6 +2187,9 @@ class JobSubmitter: target_history = request_context.history use_cached_jobs = request.use_cached_jobs rerun_remap_job_id = request.rerun_remap_job_id + credentials_context: Optional[CredentialsContext] = None + if request.credentials_context: + credentials_context = CredentialsContext(root=request.credentials_context) tool_state, new_hdas = self.dereference(request_context, tool, request, tool_request) to_materialize_list = [p for p in new_hdas if not p.request.deferred] for to_materialize in to_materialize_list: @@ -2201,6 +2205,7 @@ class JobSubmitter: history=target_history, use_cached_job=use_cached_jobs, rerun_remap_job_id=rerun_remap_job_id, + credentials_context=credentials_context, ) tool_request.state = ToolRequest.states.SUBMITTED sa_session.add(tool_request) diff --git a/lib/galaxy/schema/tasks.py b/lib/galaxy/schema/tasks.py index 8c6ef67e7f8..5d5f173d2dd 100644 --- a/lib/galaxy/schema/tasks.py +++ b/lib/galaxy/schema/tasks.py @@ -9,6 +9,7 @@ from pydantic import Field from galaxy.util.hash_util import HashFunctionNameEnum from . import PdfDocumentType +from .credentials import ServiceCredentialsContext from .schema import ( BcoGenerationParametersMixin, DatasetSourceType, @@ -186,3 +187,4 @@ class QueueJobs(Model): user: RequestUser # TODO: test anonymous users through this submission path use_cached_jobs: bool rerun_remap_job_id: Optional[int] # link to a job to rerun & remap + credentials_context: Optional[list[ServiceCredentialsContext]] = None # credential context for vault-based credential injection diff --git a/lib/galaxy/tools/actions/__init__.py b/lib/galaxy/tools/actions/__init__.py index 79890964b4b..15ae62e1599 100644 --- a/lib/galaxy/tools/actions/__init__.py +++ b/lib/galaxy/tools/actions/__init__.py @@ -972,6 +972,7 @@ class DefaultToolAction(ToolAction): if credentials_context is None: return + # Create database associations for vault-based credentials for service_context in credentials_context.root: association = JobCredentialsContextAssociation( job=job, diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index ffc3a054983..781c58a78d1 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -47,6 +47,7 @@ from galaxy.schema.schema import ( AsyncTaskResultSummary, JobIndexQueryPayload, ) +from galaxy.schema.credentials import ServiceCredentialsContext from galaxy.schema.tasks import ( QueueJobs, ToolSource, @@ -90,6 +91,9 @@ class JobRequest(BaseModel): default=None, title="rerun_remap_job_id", description="TODO" ) send_email_notification: bool = Field(default=False, title="Send Email Notification", description="TODO") + credentials_context: Optional[list[ServiceCredentialsContext]] = Field( + default=None, title="credentials_context", description="Credential context for tool execution." + ) class JobCreateResponse(BaseModel): @@ -286,6 +290,7 @@ class JobsService(ServiceBase): tool_request_id=tool_request_id, use_cached_jobs=job_request.use_cached_jobs or False, rerun_remap_job_id=job_request.rerun_remap_job_id, + credentials_context=job_request.credentials_context, ) result = queue_jobs.delay(request=task_request) return JobCreateResponse( diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index ef469889acb..c508b857e65 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -352,7 +352,7 @@ class ToolsService(ServiceBase): inputs.get("use_cached_job", "false") ) preferred_object_store_id = payload.get("preferred_object_store_id") - credentials_context = payload.get("credentials_context") + credentials_context_raw = payload.get("credentials_context") input_format = str(payload.get("input_format", "legacy")) if input_format not in get_args(InputFormatT): raise exceptions.RequestParameterInvalidException(f"input_format invalid {input_format}") @@ -360,6 +360,12 @@ class ToolsService(ServiceBase): if "data_manager_mode" in payload: incoming["__data_manager_mode"] = payload["data_manager_mode"] tags = payload.get("__tags") + + # Handle credentials_context + credentials_context: Optional[CredentialsContext] = None + if credentials_context_raw: + credentials_context = CredentialsContext(root=credentials_context_raw) + vars = tool.handle_input( trans, incoming, @@ -367,7 +373,7 @@ class ToolsService(ServiceBase): use_cached_job=use_cached_job, input_format=input_format, preferred_object_store_id=preferred_object_store_id, - credentials_context=CredentialsContext(root=credentials_context) if credentials_context else None, + credentials_context=credentials_context, tags=tags, ) From 72cd9d4eebd5591e956cae03a1e4e8621c969f21 Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 23 Feb 2026 17:14:24 +0100 Subject: [PATCH 04/26] Add credential integration test tool and configuration --- test/functional/test_toolbox_pytest.py | 7 +++ test/functional/tools/credentials_test.xml | 52 ++++++++++++++++++++++ test/functional/tools/sample_tool_conf.xml | 1 + 3 files changed, 60 insertions(+) create mode 100644 test/functional/tools/credentials_test.xml diff --git a/test/functional/test_toolbox_pytest.py b/test/functional/test_toolbox_pytest.py index b6b999de4ed..19b598a940c 100644 --- a/test/functional/test_toolbox_pytest.py +++ b/test/functional/test_toolbox_pytest.py @@ -12,6 +12,7 @@ from galaxy.tool_util.verify.interactor import ( UseLegacyApiT, ) from galaxy_test.api._framework import ApiTestCase +from galaxy_test.driver import integration_util from galaxy_test.driver.driver_util import GalaxyTestDriver SKIPTEST = os.path.join(os.path.dirname(__file__), "known_broken_tools.txt") @@ -64,6 +65,12 @@ class TestFrameworkTools(ApiTestCase): conda_auto_init = True conda_auto_install = True + @classmethod + def handle_galaxy_config_kwds(cls, config): + """Configure vault for credential testing.""" + super().handle_galaxy_config_kwds(config) + config["vault_config_file"] = integration_util.VAULT_CONF + @pytest.mark.parametrize("testcase", cases(), ids=idfn) def test_tool(self, testcase: ToolTest): use_legacy_api = os.environ.get("GALAXY_TEST_USE_LEGACY_TOOL_API", DEFAULT_USE_LEGACY_API) diff --git a/test/functional/tools/credentials_test.xml b/test/functional/tools/credentials_test.xml new file mode 100644 index 00000000000..759bbf8e350 --- /dev/null +++ b/test/functional/tools/credentials_test.xml @@ -0,0 +1,52 @@ + + Test tool for validating credential injection + + + + + + + '$output' && + echo "Password length: \${#TEST_PASSWORD}" >> '$output' + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml index 78bcb42ae6f..bcb548f3a89 100644 --- a/test/functional/tools/sample_tool_conf.xml +++ b/test/functional/tools/sample_tool_conf.xml @@ -334,6 +334,7 @@ + From 85e95f14930a1683684f49d89c75dc235a95843b Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 23 Feb 2026 17:22:20 +0100 Subject: [PATCH 05/26] Add credential context definitions to components schema --- client/src/api/schema/schema.ts | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index d4f35364efb..beaecd49ccf 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -17584,6 +17584,11 @@ export interface components { }; /** JobRequest */ JobRequest: { + /** + * credentials_context + * @description Credential context for tool execution. + */ + credentials_context?: components["schemas"]["ServiceCredentialsContext"][] | null; /** * history_id * @description TODO @@ -21710,6 +21715,20 @@ export interface components { */ source_version: string; }; + /** SelectedGroup */ + SelectedGroup: { + /** + * Id + * @description The ID of the selected credential group. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Name + * @description The name of the selected credential group. + */ + name: string; + }; /** ServerDirElement */ ServerDirElement: { /** @@ -21882,6 +21901,27 @@ export interface components { */ version: string; }; + /** ServiceCredentialsContext */ + ServiceCredentialsContext: { + /** + * Name + * @description The name of the service. + */ + name: string; + /** @description The currently selected credential group. */ + selected_group: components["schemas"]["SelectedGroup"]; + /** + * User Credentials Id + * @description The ID of the user credentials. + * @example 0123456789ABCDEF + */ + user_credentials_id: string; + /** + * Version + * @description The version of the service. + */ + version: string; + }; /** ServiceCredentialsDefinition */ ServiceCredentialsDefinition: { /** From 0151cb50d810994e94f2b013dfbe7b98d341cc6f Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 23 Feb 2026 18:17:13 +0100 Subject: [PATCH 06/26] Add version support to tool test credentials Introduces an optional version attribute for credential definitions in tool tests. This allows test authors to specify the required credential schema version, ensuring compatibility with evolving service requirements. The change includes updates to the XML and YAML parsers, XSD validation, and comprehensive unit tests. --- lib/galaxy/tool_util/parser/interface.py | 10 +- lib/galaxy/tool_util/parser/xml.py | 6 +- lib/galaxy/tool_util/parser/yaml.py | 6 +- lib/galaxy/tool_util/xsd/galaxy.xsd | 5 + .../unit/tool_util/test_credential_parsing.py | 272 ++++++++++++++++++ 5 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 test/unit/tool_util/test_credential_parsing.py diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index c393227cdfe..0b81e2c670e 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -124,14 +124,18 @@ class DirectCredentialValue(TypedDict): value: str -class DirectCredential(TypedDict): - """Represents a credential group with variables and secrets provided directly.""" - +class _DirectCredentialRequired(TypedDict): name: str # Name of the credentials group variables: List[DirectCredentialValue] secrets: List[DirectCredentialValue] +class DirectCredential(_DirectCredentialRequired, total=False): + """Represents a credential group with variables and secrets provided directly.""" + + version: str # Version of the credential definition (defaults to "1.0") + + ToolSourceTestInputs = List[ToolSourceTestInput] ToolSourceTestOutputs = List[ToolSourceTestOutput] TestSourceTestOutputColllection = Any diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index f68cec47e64..e2f56199951 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -1128,7 +1128,11 @@ def __parse_credentials_elems(test_elem): raise ValueError(f"Credential secret '{secret_name}' must have a 'value' attribute") secrets.append(DirectCredentialValue(name=secret_name, value=secret_value)) - credentials_list.append(DirectCredential(name=name, variables=variables, secrets=secrets)) + cred: DirectCredential = {"name": name, "variables": variables, "secrets": secrets} + version = cred_elem.get("version") + if version is not None: + cred["version"] = version + credentials_list.append(cred) return credentials_list if credentials_list else None diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 6f933c72b30..7ffb2cee799 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -482,7 +482,11 @@ def __parse_credentials_yaml(credentials_data) -> Optional[List[DirectCredential raise ValueError(f"Credential secret '{secret_name}' must have a 'value'") secrets.append(DirectCredentialValue(name=secret_name, value=secret_value)) - credentials_list.append(DirectCredential(name=name, variables=variables, secrets=secrets)) + cred: DirectCredential = {"name": name, "variables": variables, "secrets": secrets} + version = cred_data.get("version") if is_dict(cred_data) else None + if version is not None: + cred["version"] = str(version) + credentials_list.append(cred) return credentials_list if credentials_list else None diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 0243960b30c..8939ec47b80 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -1500,6 +1500,11 @@ This is intended for use with public test accounts/credentials only. Name of the credentials group (must match the name defined in tool's requirements/credentials) + + + Version of the credentials definition (must match the version defined in tool's requirements/credentials). Defaults to "1.0". + + diff --git a/test/unit/tool_util/test_credential_parsing.py b/test/unit/tool_util/test_credential_parsing.py new file mode 100644 index 00000000000..57fd50479f0 --- /dev/null +++ b/test/unit/tool_util/test_credential_parsing.py @@ -0,0 +1,272 @@ +"""Unit tests for credential parsing in tool XML and YAML test definitions.""" + +import os +import shutil +import tempfile + +import pytest + +from galaxy.tool_util.parser.factory import get_tool_source +from galaxy.util.unittest import TestCase + +# Minimal tool XML wrapper that includes a test +_TOOL_XML_TEMPLATE = """\ + + echo done + + + +{tests} + + +""" + +# Minimal tool YAML wrapper +_TOOL_YAML_TEMPLATE = """\ +class: GalaxyTool +id: cred_test +name: Cred Test +version: "1.0.0" +shell_command: echo done +inputs: [] +outputs: [] +tests: +{tests} +""" + + +def _write_temp_tool(content: str, suffix: str, directory: str) -> str: + path = os.path.join(directory, f"cred_test{suffix}") + with open(path, "w") as f: + f.write(content) + return path + + +def _parse_tests(path: str): + tool_source = get_tool_source(path) + return tool_source.parse_tests_to_dict()["tests"] + + +class TestXmlCredentialParsing(TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def _parse_xml_tests(self, test_block: str): + xml = _TOOL_XML_TEMPLATE.format(tests=test_block) + path = _write_temp_tool(xml, ".xml", self.tmpdir) + return _parse_tests(path) + + def test_no_credentials(self): + tests = self._parse_xml_tests("") + assert tests[0]["credentials"] is None + + def test_single_credential_with_variable_and_secret(self): + tests = self._parse_xml_tests(""" + + + + + """) + creds = tests[0]["credentials"] + assert creds is not None + assert len(creds) == 1 + cred = creds[0] + assert cred["name"] == "my_service" + assert len(cred["variables"]) == 1 + assert cred["variables"][0] == {"name": "MY_USER", "value": "testuser"} + assert len(cred["secrets"]) == 1 + assert cred["secrets"][0] == {"name": "MY_PASS", "value": "testpass"} + + def test_credential_version_explicit(self): + tests = self._parse_xml_tests(""" + + + + """) + cred = tests[0]["credentials"][0] + assert cred.get("version") == "2.5" + + def test_credential_version_omitted(self): + """When version is absent, the field should not be present (no default injected by parser).""" + tests = self._parse_xml_tests(""" + + + + """) + cred = tests[0]["credentials"][0] + assert "version" not in cred + + def test_multiple_credentials(self): + tests = self._parse_xml_tests(""" + + + + + + + """) + creds = tests[0]["credentials"] + assert len(creds) == 2 + assert creds[0]["name"] == "svc_a" + assert creds[1]["name"] == "svc_b" + + def test_credentials_only_variables(self): + tests = self._parse_xml_tests(""" + + + + """) + cred = tests[0]["credentials"][0] + assert len(cred["variables"]) == 1 + assert cred["secrets"] == [] + + def test_credentials_only_secrets(self): + tests = self._parse_xml_tests(""" + + + + """) + cred = tests[0]["credentials"][0] + assert cred["variables"] == [] + assert len(cred["secrets"]) == 1 + + def test_missing_name_raises(self): + with pytest.raises(ValueError, match="name"): + self._parse_xml_tests(""" + + + + """) + + def test_missing_variable_name_raises(self): + with pytest.raises(ValueError, match="name"): + self._parse_xml_tests(""" + + + + """) + + def test_missing_variable_value_raises(self): + with pytest.raises(ValueError, match="value"): + self._parse_xml_tests(""" + + + + """) + + def test_missing_secret_value_raises(self): + with pytest.raises(ValueError, match="value"): + self._parse_xml_tests(""" + + + + """) + + +class TestYamlCredentialParsing(TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def _parse_yaml_tests(self, test_block: str): + yaml = _TOOL_YAML_TEMPLATE.format(tests=test_block) + path = _write_temp_tool(yaml, ".yml", self.tmpdir) + return _parse_tests(path) + + def test_no_credentials(self): + tests = self._parse_yaml_tests(" - doc: simple\n outputs: {}\n") + assert tests[0]["credentials"] is None + + def test_list_format_with_variable_and_secret(self): + tests = self._parse_yaml_tests("""\ + - doc: cred test + credentials: + - name: my_service + variables: + - name: MY_USER + value: testuser + secrets: + - name: MY_PASS + value: testpass + outputs: {} +""") + creds = tests[0]["credentials"] + assert creds is not None + assert len(creds) == 1 + cred = creds[0] + assert cred["name"] == "my_service" + assert cred["variables"] == [{"name": "MY_USER", "value": "testuser"}] + assert cred["secrets"] == [{"name": "MY_PASS", "value": "testpass"}] + + def test_dict_format(self): + tests = self._parse_yaml_tests("""\ + - doc: cred test + credentials: + my_service: + variables: + - name: V + value: v + secrets: [] + outputs: {} +""") + creds = tests[0]["credentials"] + assert len(creds) == 1 + assert creds[0]["name"] == "my_service" + + def test_version_in_list_format(self): + tests = self._parse_yaml_tests("""\ + - doc: cred test + credentials: + - name: svc + version: "2.0" + variables: + - name: V + value: v + secrets: [] + outputs: {} +""") + cred = tests[0]["credentials"][0] + assert cred.get("version") == "2.0" + + def test_version_omitted(self): + tests = self._parse_yaml_tests("""\ + - doc: cred test + credentials: + - name: svc + variables: + - name: V + value: v + secrets: [] + outputs: {} +""") + cred = tests[0]["credentials"][0] + assert "version" not in cred + + def test_missing_variable_name_raises(self): + with pytest.raises(ValueError, match="name"): + self._parse_yaml_tests("""\ + - doc: cred test + credentials: + - name: svc + variables: + - value: v + secrets: [] + outputs: {} +""") + + def test_missing_variable_value_raises(self): + with pytest.raises(ValueError, match="value"): + self._parse_yaml_tests("""\ + - doc: cred test + credentials: + - name: svc + variables: + - name: V + secrets: [] + outputs: {} +""") From dab1a382ea2e649b23dfde03304e09318245ad16 Mon Sep 17 00:00:00 2001 From: Arash Date: Mon, 23 Feb 2026 18:17:14 +0100 Subject: [PATCH 07/26] Update interactor to handle versioned credentials Enhances the tool test interactor to utilize credential version information and improves type safety across credential-related data structures. By incorporating version checks when looking up existing user credentials and defaulting to version 1.0 when unspecified, it ensures more reliable credential mapping during tool test execution. --- lib/galaxy/tool_util/verify/interactor.py | 25 ++++++++++++++--------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index b965952ea87..876fd1afd73 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -46,6 +46,7 @@ from galaxy.tool_util.parameters import ( ) from galaxy.tool_util.parser.interface import ( AssertionList, + DirectCredential, TestCollectionDef, TestCollectionOutputDef, TestSourceTestOutputColllection, @@ -139,7 +140,7 @@ class ValidToolTestDict(TypedDict): required_files: NotRequired[RequiredFilesT] required_data_tables: NotRequired[RequiredDataTablesT] required_loc_files: NotRequired[RequiredLocFileT] - credentials: NotRequired[Optional[List[Any]]] + credentials: NotRequired[Optional[List[DirectCredential]]] error: Literal[False] tool_id: str tool_version: str @@ -786,7 +787,7 @@ class GalaxyInteractorApi: "source_version": testdef.tool_version or "1.0.0", "service_credential": { "name": cred["name"], - "version": "1.0", # Default version for test credentials + "version": cred.get("version", "1.0"), "group": { "name": f"test_group_{cred['name']}", "variables": cred.get("variables", []), @@ -807,12 +808,16 @@ class GalaxyInteractorApi: list_response.raise_for_status() all_credentials = list_response.json() - # Find the credential we just created by matching source - # Then find the group in that credential's groups that matches our created group ID + # Find the user_credentials_id by searching for the UserCredentials entry + # that contains our newly-created group (matched by group ID). + # Filter by source_type, source_id, and source_version to reduce the scan. user_credentials_id = None for user_cred in all_credentials: - if user_cred["source_type"] == "tool" and user_cred["source_id"] == testdef.tool_id: - # Found the right UserCredentials, now find our group within it + if ( + user_cred["source_type"] == "tool" + and user_cred["source_id"] == testdef.tool_id + and user_cred.get("source_version") == (testdef.tool_version or "1.0.0") + ): for group in user_cred["groups"]: if group["id"] == created_cred["id"]: user_credentials_id = user_cred["id"] @@ -833,7 +838,7 @@ class GalaxyInteractorApi: { "user_credentials_id": user_credentials_id, "name": cred["name"], - "version": "1.0", + "version": cred.get("version", "1.0"), "selected_group": {"id": created_cred["id"], "name": created_cred["name"]}, } ) @@ -1094,7 +1099,7 @@ class GalaxyInteractorApi: files: Optional[dict] = None, tool_version: Optional[str] = None, use_legacy_api: bool = True, - credentials_context: Optional[list] = None, + credentials_context: Optional[List[Dict[str, Any]]] = None, ): extra_data = extra_data or {} if use_legacy_api: @@ -2031,7 +2036,7 @@ def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionD request: Optional[Dict[str, Any]] = None request_schema: Optional[Dict[str, Any]] = None request_unavailable_reason: Optional[str] = None - credentials: Optional[List[Any]] = None + credentials: Optional[List[DirectCredential]] = None if not error_in_test_definition: processed_test_dict = cast(ValidToolTestDict, processed_dict) @@ -2161,7 +2166,7 @@ class ToolTestDescription: output_collections: List[TestCollectionOutputDef] maxseconds: Optional[int] value_state_representation: ValueStateRepresentationT - credentials: Optional[List[Any]] # List of credential context dicts + credentials: Optional[List[DirectCredential]] @staticmethod def from_tool_source_dict(processed_test_dict: ToolTestDict) -> "ToolTestDescription": From 29d64c1827fcf55596419ef33db9198a086dd10e Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 24 Feb 2026 10:23:18 +0100 Subject: [PATCH 08/26] Format the code --- lib/galaxy/schema/tasks.py | 4 +++- lib/galaxy/webapps/galaxy/services/jobs.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/schema/tasks.py b/lib/galaxy/schema/tasks.py index 5d5f173d2dd..a752018657b 100644 --- a/lib/galaxy/schema/tasks.py +++ b/lib/galaxy/schema/tasks.py @@ -187,4 +187,6 @@ class QueueJobs(Model): user: RequestUser # TODO: test anonymous users through this submission path use_cached_jobs: bool rerun_remap_job_id: Optional[int] # link to a job to rerun & remap - credentials_context: Optional[list[ServiceCredentialsContext]] = None # credential context for vault-based credential injection + credentials_context: Optional[list[ServiceCredentialsContext]] = ( + None # credential context for vault-based credential injection + ) diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index 781c58a78d1..bcb3b77106e 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -35,6 +35,7 @@ from galaxy.model import ( ToolRequest, ToolSource as ToolSourceModel, ) +from galaxy.schema.credentials import ServiceCredentialsContext from galaxy.schema.fields import ( DecodedDatabaseIdField, EncodedDatabaseIdField, @@ -47,7 +48,6 @@ from galaxy.schema.schema import ( AsyncTaskResultSummary, JobIndexQueryPayload, ) -from galaxy.schema.credentials import ServiceCredentialsContext from galaxy.schema.tasks import ( QueueJobs, ToolSource, From 8571240ccb8caf21106ed68f868e8e804234acb3 Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 24 Feb 2026 14:47:53 +0100 Subject: [PATCH 09/26] Refactor TestFrameworkTools to include ConfiguresDatabaseVault for credential testing --- test/functional/test_toolbox_pytest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/functional/test_toolbox_pytest.py b/test/functional/test_toolbox_pytest.py index 19b598a940c..ad52fdecf41 100644 --- a/test/functional/test_toolbox_pytest.py +++ b/test/functional/test_toolbox_pytest.py @@ -12,8 +12,8 @@ from galaxy.tool_util.verify.interactor import ( UseLegacyApiT, ) from galaxy_test.api._framework import ApiTestCase -from galaxy_test.driver import integration_util from galaxy_test.driver.driver_util import GalaxyTestDriver +from galaxy_test.driver.integration_util import ConfiguresDatabaseVault SKIPTEST = os.path.join(os.path.dirname(__file__), "known_broken_tools.txt") @@ -61,7 +61,7 @@ def idfn(val: ToolTest): return f"{val.tool_id}/{val.tool_version}-{val.test_index}" -class TestFrameworkTools(ApiTestCase): +class TestFrameworkTools(ApiTestCase, ConfiguresDatabaseVault): conda_auto_init = True conda_auto_install = True @@ -69,7 +69,7 @@ class TestFrameworkTools(ApiTestCase): def handle_galaxy_config_kwds(cls, config): """Configure vault for credential testing.""" super().handle_galaxy_config_kwds(config) - config["vault_config_file"] = integration_util.VAULT_CONF + cls._configure_database_vault(config) @pytest.mark.parametrize("testcase", cases(), ids=idfn) def test_tool(self, testcase: ToolTest): From 0eb9eddfbc029a19694713c1c37a641215988c6e Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 25 Feb 2026 19:00:35 +0100 Subject: [PATCH 10/26] Refactor credential context to use ServiceCredentialRef for improved flexibility in job and task handling --- lib/galaxy/schema/credentials.py | 32 +++++++++++++++++++++- lib/galaxy/schema/tasks.py | 4 +-- lib/galaxy/webapps/galaxy/services/jobs.py | 6 ++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/schema/credentials.py b/lib/galaxy/schema/credentials.py index 41e8071c851..d8297dbff3e 100644 --- a/lib/galaxy/schema/credentials.py +++ b/lib/galaxy/schema/credentials.py @@ -6,13 +6,16 @@ from typing import ( ) from pydantic import ( + BeforeValidator, Field, RootModel, ) from galaxy.schema.fields import ( + decode_id, DecodedDatabaseIdField, EncodedDatabaseIdField, + ensure_valid_id, ) from galaxy.schema.schema import Model @@ -399,13 +402,40 @@ class ServiceCredentialsContextResponse(Model): ] +def _flexible_decode_id(v: object) -> int: + """Accept both hex-encoded ID strings (API) and plain ints (Celery/internal).""" + if isinstance(v, int): + return v + return decode_id(ensure_valid_id(str(v))) + + +FlexibleDatabaseIdField = Annotated[int, BeforeValidator(_flexible_decode_id)] + + +class SelectedGroupRef(Model): + """Reference to a credential group; accepts hex-string IDs (API) or plain ints (Celery).""" + + id: FlexibleDatabaseIdField + name: str + + +class ServiceCredentialRef(Model): + """Reference to service credentials; accepts hex-string IDs (API) or plain ints (Celery). + Used internally in the tool execution chain and Celery tasks.""" + + user_credentials_id: FlexibleDatabaseIdField + name: str + version: str + selected_group: SelectedGroupRef + + class CredentialsContext(RootModel): """Context for credentials to be used during tool execution. Contains the list of selected service credentials provided by the user. """ - root: list[ServiceCredentialsContext] + root: list[ServiceCredentialRef] class CredentialsContextResponse(RootModel): diff --git a/lib/galaxy/schema/tasks.py b/lib/galaxy/schema/tasks.py index a752018657b..45a45c08f00 100644 --- a/lib/galaxy/schema/tasks.py +++ b/lib/galaxy/schema/tasks.py @@ -9,7 +9,7 @@ from pydantic import Field from galaxy.util.hash_util import HashFunctionNameEnum from . import PdfDocumentType -from .credentials import ServiceCredentialsContext +from .credentials import ServiceCredentialRef from .schema import ( BcoGenerationParametersMixin, DatasetSourceType, @@ -187,6 +187,6 @@ class QueueJobs(Model): user: RequestUser # TODO: test anonymous users through this submission path use_cached_jobs: bool rerun_remap_job_id: Optional[int] # link to a job to rerun & remap - credentials_context: Optional[list[ServiceCredentialsContext]] = ( + credentials_context: Optional[list[ServiceCredentialRef]] = ( None # credential context for vault-based credential injection ) diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index bcb3b77106e..c0348dda0c9 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -35,7 +35,7 @@ from galaxy.model import ( ToolRequest, ToolSource as ToolSourceModel, ) -from galaxy.schema.credentials import ServiceCredentialsContext +from galaxy.schema.credentials import ServiceCredentialRef from galaxy.schema.fields import ( DecodedDatabaseIdField, EncodedDatabaseIdField, @@ -91,7 +91,7 @@ class JobRequest(BaseModel): default=None, title="rerun_remap_job_id", description="TODO" ) send_email_notification: bool = Field(default=False, title="Send Email Notification", description="TODO") - credentials_context: Optional[list[ServiceCredentialsContext]] = Field( + credentials_context: Optional[list[ServiceCredentialRef]] = Field( default=None, title="credentials_context", description="Credential context for tool execution." ) @@ -290,7 +290,7 @@ class JobsService(ServiceBase): tool_request_id=tool_request_id, use_cached_jobs=job_request.use_cached_jobs or False, rerun_remap_job_id=job_request.rerun_remap_job_id, - credentials_context=job_request.credentials_context, + credentials_context=job_request.credentials_context or None, ) result = queue_jobs.delay(request=task_request) return JobCreateResponse( From 080b224e35aec6eddc83a1b009bf910185218e85 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 26 Feb 2026 12:04:54 +0100 Subject: [PATCH 11/26] Refactor credential schemas to use ServiceCredentialRef and SelectedGroupRef for improved type consistency --- client/src/api/schema/schema.ts | 51 +++++++++++++-------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index beaecd49ccf..e2522246c04 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -17588,7 +17588,7 @@ export interface components { * credentials_context * @description Credential context for tool execution. */ - credentials_context?: components["schemas"]["ServiceCredentialsContext"][] | null; + credentials_context?: components["schemas"]["ServiceCredentialRef"][] | null; /** * history_id * @description TODO @@ -21715,18 +21715,14 @@ export interface components { */ source_version: string; }; - /** SelectedGroup */ - SelectedGroup: { - /** - * Id - * @description The ID of the selected credential group. - * @example 0123456789ABCDEF - */ - id: string; - /** - * Name - * @description The name of the selected credential group. - */ + /** + * SelectedGroupRef + * @description Reference to a credential group; accepts hex-string IDs (API) or plain ints (Celery). + */ + SelectedGroupRef: { + /** Id */ + id: number; + /** Name */ name: string; }; /** ServerDirElement */ @@ -21901,25 +21897,18 @@ export interface components { */ version: string; }; - /** ServiceCredentialsContext */ - ServiceCredentialsContext: { - /** - * Name - * @description The name of the service. - */ + /** + * ServiceCredentialRef + * @description Reference to service credentials; accepts hex-string IDs (API) or plain ints (Celery). + * Used internally in the tool execution chain and Celery tasks. + */ + ServiceCredentialRef: { + /** Name */ name: string; - /** @description The currently selected credential group. */ - selected_group: components["schemas"]["SelectedGroup"]; - /** - * User Credentials Id - * @description The ID of the user credentials. - * @example 0123456789ABCDEF - */ - user_credentials_id: string; - /** - * Version - * @description The version of the service. - */ + selected_group: components["schemas"]["SelectedGroupRef"]; + /** User Credentials Id */ + user_credentials_id: number; + /** Version */ version: string; }; /** ServiceCredentialsDefinition */ From ac30e1d5cee6756af4e624ac9f9a6e13b892cda7 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 26 Feb 2026 16:18:16 +0100 Subject: [PATCH 12/26] Refactor credential imports to use SelectedGroupRef and ServiceCredentialRef for consistency --- lib/galaxy/workflow/modules.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index 997b3957da3..acc4253b377 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -48,8 +48,8 @@ from galaxy.model.dataset_collections.type_description import COLLECTION_TYPE_DE from galaxy.model.dataset_collections.types.sample_sheet_util import validate_column_definitions from galaxy.schema.credentials import ( CredentialsContext, - SelectedGroup, - ServiceCredentialsContext, + SelectedGroupRef, + ServiceCredentialRef, ) from galaxy.schema.invocation import ( CancelReason, @@ -2685,11 +2685,11 @@ class ToolModule(WorkflowModule): for user_cred, group, _cred in results: key = (user_cred.id, user_cred.name, user_cred.version) if key not in seen: - seen[key] = ServiceCredentialsContext( + seen[key] = ServiceCredentialRef( user_credentials_id=encode(user_cred.id), name=user_cred.name, version=user_cred.version, - selected_group=SelectedGroup( + selected_group=SelectedGroupRef( id=encode(group.id), name=group.name, ), From 14f2cb6e18a7acae07af191cbda0fa6b89391f5a Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 26 Feb 2026 17:24:14 +0100 Subject: [PATCH 13/26] Refactor credential schemas and context to use ServiceCredentialsContext and SelectedGroup for improved clarity and consistency --- client/src/api/schema/schema.ts | 51 +++++++++++++--------- lib/galaxy/managers/jobs.py | 21 ++++++++- lib/galaxy/schema/credentials.py | 32 +------------- lib/galaxy/schema/tasks.py | 16 ++++++- lib/galaxy/webapps/galaxy/services/jobs.py | 19 ++++++-- lib/galaxy/workflow/modules.py | 8 ++-- 6 files changed, 85 insertions(+), 62 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index e2522246c04..beaecd49ccf 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -17588,7 +17588,7 @@ export interface components { * credentials_context * @description Credential context for tool execution. */ - credentials_context?: components["schemas"]["ServiceCredentialRef"][] | null; + credentials_context?: components["schemas"]["ServiceCredentialsContext"][] | null; /** * history_id * @description TODO @@ -21715,14 +21715,18 @@ export interface components { */ source_version: string; }; - /** - * SelectedGroupRef - * @description Reference to a credential group; accepts hex-string IDs (API) or plain ints (Celery). - */ - SelectedGroupRef: { - /** Id */ - id: number; - /** Name */ + /** SelectedGroup */ + SelectedGroup: { + /** + * Id + * @description The ID of the selected credential group. + * @example 0123456789ABCDEF + */ + id: string; + /** + * Name + * @description The name of the selected credential group. + */ name: string; }; /** ServerDirElement */ @@ -21897,18 +21901,25 @@ export interface components { */ version: string; }; - /** - * ServiceCredentialRef - * @description Reference to service credentials; accepts hex-string IDs (API) or plain ints (Celery). - * Used internally in the tool execution chain and Celery tasks. - */ - ServiceCredentialRef: { - /** Name */ + /** ServiceCredentialsContext */ + ServiceCredentialsContext: { + /** + * Name + * @description The name of the service. + */ name: string; - selected_group: components["schemas"]["SelectedGroupRef"]; - /** User Credentials Id */ - user_credentials_id: number; - /** Version */ + /** @description The currently selected credential group. */ + selected_group: components["schemas"]["SelectedGroup"]; + /** + * User Credentials Id + * @description The ID of the user credentials. + * @example 0123456789ABCDEF + */ + user_credentials_id: string; + /** + * Version + * @description The version of the service. + */ version: string; }; /** ServiceCredentialsDefinition */ diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 6418967f3a5..567d0c25c5a 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -79,7 +79,11 @@ from galaxy.model.index_filter_util import ( text_column_filter, ) from galaxy.model.scoped_session import galaxy_scoped_session -from galaxy.schema.credentials import CredentialsContext +from galaxy.schema.credentials import ( + CredentialsContext, + SelectedGroup, + ServiceCredentialsContext, +) from galaxy.schema.schema import ( JobIndexQueryPayload, JobIndexSortByEnum, @@ -2189,7 +2193,20 @@ class JobSubmitter: rerun_remap_job_id = request.rerun_remap_job_id credentials_context: Optional[CredentialsContext] = None if request.credentials_context: - credentials_context = CredentialsContext(root=request.credentials_context) + credentials_context = CredentialsContext( + root=[ + ServiceCredentialsContext.model_construct( + user_credentials_id=task.user_credentials_id, + name=task.name, + version=task.version, + selected_group=SelectedGroup.model_construct( + id=task.selected_group_id, + name=task.selected_group_name, + ), + ) + for task in request.credentials_context + ] + ) tool_state, new_hdas = self.dereference(request_context, tool, request, tool_request) to_materialize_list = [p for p in new_hdas if not p.request.deferred] for to_materialize in to_materialize_list: diff --git a/lib/galaxy/schema/credentials.py b/lib/galaxy/schema/credentials.py index d8297dbff3e..41e8071c851 100644 --- a/lib/galaxy/schema/credentials.py +++ b/lib/galaxy/schema/credentials.py @@ -6,16 +6,13 @@ from typing import ( ) from pydantic import ( - BeforeValidator, Field, RootModel, ) from galaxy.schema.fields import ( - decode_id, DecodedDatabaseIdField, EncodedDatabaseIdField, - ensure_valid_id, ) from galaxy.schema.schema import Model @@ -402,40 +399,13 @@ class ServiceCredentialsContextResponse(Model): ] -def _flexible_decode_id(v: object) -> int: - """Accept both hex-encoded ID strings (API) and plain ints (Celery/internal).""" - if isinstance(v, int): - return v - return decode_id(ensure_valid_id(str(v))) - - -FlexibleDatabaseIdField = Annotated[int, BeforeValidator(_flexible_decode_id)] - - -class SelectedGroupRef(Model): - """Reference to a credential group; accepts hex-string IDs (API) or plain ints (Celery).""" - - id: FlexibleDatabaseIdField - name: str - - -class ServiceCredentialRef(Model): - """Reference to service credentials; accepts hex-string IDs (API) or plain ints (Celery). - Used internally in the tool execution chain and Celery tasks.""" - - user_credentials_id: FlexibleDatabaseIdField - name: str - version: str - selected_group: SelectedGroupRef - - class CredentialsContext(RootModel): """Context for credentials to be used during tool execution. Contains the list of selected service credentials provided by the user. """ - root: list[ServiceCredentialRef] + root: list[ServiceCredentialsContext] class CredentialsContextResponse(RootModel): diff --git a/lib/galaxy/schema/tasks.py b/lib/galaxy/schema/tasks.py index 45a45c08f00..a55119ce019 100644 --- a/lib/galaxy/schema/tasks.py +++ b/lib/galaxy/schema/tasks.py @@ -9,7 +9,6 @@ from pydantic import Field from galaxy.util.hash_util import HashFunctionNameEnum from . import PdfDocumentType -from .credentials import ServiceCredentialRef from .schema import ( BcoGenerationParametersMixin, DatasetSourceType, @@ -172,6 +171,19 @@ class TaskResult(Model): ) +class ServiceCredentialTask(Model): + """Flat representation of a service credential for Celery task payloads. + + Uses plain int IDs to follow Galaxy's standard Celery task pattern. + """ + + user_credentials_id: int + name: str + version: str + selected_group_id: int + selected_group_name: str + + TOOL_SOURCE_CLASS = Literal["XmlToolSource", "YamlToolSource", "CwlToolSource"] @@ -187,6 +199,6 @@ class QueueJobs(Model): user: RequestUser # TODO: test anonymous users through this submission path use_cached_jobs: bool rerun_remap_job_id: Optional[int] # link to a job to rerun & remap - credentials_context: Optional[list[ServiceCredentialRef]] = ( + credentials_context: Optional[list[ServiceCredentialTask]] = ( None # credential context for vault-based credential injection ) diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index c0348dda0c9..4e45679f3b4 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -35,7 +35,7 @@ from galaxy.model import ( ToolRequest, ToolSource as ToolSourceModel, ) -from galaxy.schema.credentials import ServiceCredentialRef +from galaxy.schema.credentials import ServiceCredentialsContext from galaxy.schema.fields import ( DecodedDatabaseIdField, EncodedDatabaseIdField, @@ -50,6 +50,7 @@ from galaxy.schema.schema import ( ) from galaxy.schema.tasks import ( QueueJobs, + ServiceCredentialTask, ToolSource, ) from galaxy.security.idencoding import IdEncodingHelper @@ -91,7 +92,7 @@ class JobRequest(BaseModel): default=None, title="rerun_remap_job_id", description="TODO" ) send_email_notification: bool = Field(default=False, title="Send Email Notification", description="TODO") - credentials_context: Optional[list[ServiceCredentialRef]] = Field( + credentials_context: Optional[list[ServiceCredentialsContext]] = Field( default=None, title="credentials_context", description="Credential context for tool execution." ) @@ -283,6 +284,18 @@ class JobsService(ServiceBase): tool_dir=tool.tool_dir, tool_source_class=tool_source_model.source_class, ) + credentials_context_tasks = None + if job_request.credentials_context: + credentials_context_tasks = [ + ServiceCredentialTask( + user_credentials_id=sc.user_credentials_id, + name=sc.name, + version=sc.version, + selected_group_id=sc.selected_group.id, + selected_group_name=sc.selected_group.name, + ) + for sc in job_request.credentials_context + ] task_request = QueueJobs( user=trans.async_request_user, history_id=target_history and target_history.id, @@ -290,7 +303,7 @@ class JobsService(ServiceBase): tool_request_id=tool_request_id, use_cached_jobs=job_request.use_cached_jobs or False, rerun_remap_job_id=job_request.rerun_remap_job_id, - credentials_context=job_request.credentials_context or None, + credentials_context=credentials_context_tasks, ) result = queue_jobs.delay(request=task_request) return JobCreateResponse( diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index acc4253b377..997b3957da3 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -48,8 +48,8 @@ from galaxy.model.dataset_collections.type_description import COLLECTION_TYPE_DE from galaxy.model.dataset_collections.types.sample_sheet_util import validate_column_definitions from galaxy.schema.credentials import ( CredentialsContext, - SelectedGroupRef, - ServiceCredentialRef, + SelectedGroup, + ServiceCredentialsContext, ) from galaxy.schema.invocation import ( CancelReason, @@ -2685,11 +2685,11 @@ class ToolModule(WorkflowModule): for user_cred, group, _cred in results: key = (user_cred.id, user_cred.name, user_cred.version) if key not in seen: - seen[key] = ServiceCredentialRef( + seen[key] = ServiceCredentialsContext( user_credentials_id=encode(user_cred.id), name=user_cred.name, version=user_cred.version, - selected_group=SelectedGroupRef( + selected_group=SelectedGroup( id=encode(group.id), name=group.name, ), From 1152310360427fc7971ba985df3ab83baec7250d Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 10 Mar 2026 18:59:46 +0100 Subject: [PATCH 14/26] Refactor GalaxyInteractorApi to handle credentials_context in non-legacy API submissions --- lib/galaxy/tool_util/verify/interactor.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index 876fd1afd73..ec5f1e098be 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -708,10 +708,6 @@ class GalaxyInteractorApi: if testdef.value_state_representation == "test_case_json": # Don't submit user / YAML tools to the old endpoint. submit_with_legacy_api = False - if testdef.credentials: - # Credentials require the non-legacy API path to pass credentials_context. - submit_with_legacy_api = False - if submit_with_legacy_api: inputs_tree = testdef.inputs.copy() for key, value in inputs_tree.items(): @@ -1103,6 +1099,16 @@ class GalaxyInteractorApi: ): extra_data = extra_data or {} if use_legacy_api: + if credentials_context: + data = dict( + history_id=history_id, + tool_id=tool_id, + inputs=tool_input, + tool_version=tool_version, + credentials_context=credentials_context, + **extra_data, + ) + return self._post("tools", data=data, json=True) data = dict( history_id=history_id, tool_id=tool_id, From 803f61aa323af1708a01c91aa70ac62c5ec59e1f Mon Sep 17 00:00:00 2001 From: Arash Date: Tue, 10 Mar 2026 19:09:45 +0100 Subject: [PATCH 15/26] Add raise_for_status function for improved error handling in API responses --- lib/galaxy/tool_util/verify/interactor.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index ec5f1e098be..b7c5813cdfb 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -241,6 +241,17 @@ class InteractorStagingInterface(StagingInterface): return True +def raise_for_status(response: Response) -> None: + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + try: + body = response.json() + except Exception: + body = response.text + raise requests.exceptions.HTTPError(f"{e} - Response body: {body}", response=response) from e + + class GalaxyInteractorApi: # api_key and cookies can also be manually set by UsesApiTestCaseMixin._different_user() api_key: Optional[str] @@ -794,14 +805,14 @@ class GalaxyInteractorApi: # Create credentials via API create_response = self._post(f"users/{user_id}/credentials", data=credential_payload, json=True) - create_response.raise_for_status() + raise_for_status(create_response) created_cred = create_response.json() # Get the user_credentials_id by listing credentials # (POST returns ServiceCredentialGroupResponse which only has the group id, # we need UserServiceCredentialsResponse which has the user_credentials_id) list_response = self._get(f"users/{user_id}/credentials") - list_response.raise_for_status() + raise_for_status(list_response) all_credentials = list_response.json() # Find the user_credentials_id by searching for the UserCredentials entry @@ -908,7 +919,7 @@ class GalaxyInteractorApi: delete_response = self._delete( f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}" ) - delete_response.raise_for_status() + raise_for_status(delete_response) except Exception as e: # Log but don't fail the test if cleanup fails print(f"Warning: Failed to delete test credentials: {e}") From ed118c9064df5add97123be6e4fd1b010baaae22 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 11 Mar 2026 11:45:54 +0100 Subject: [PATCH 16/26] Refactor tool submission logic for improved clarity and error handling --- lib/galaxy/tool_util/verify/interactor.py | 114 +++++++++++----------- 1 file changed, 55 insertions(+), 59 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index b7c5813cdfb..9232003190a 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -852,66 +852,63 @@ class GalaxyInteractorApi: credentials_context = credentials_context_list - try: - for _ in range(DEFAULT_TOOL_TEST_WAIT): - submit_response = self.__submit_tool( - history_id, - tool_id=testdef.tool_id, - tool_input=inputs_tree, - tool_version=testdef.tool_version, - use_legacy_api=submit_with_legacy_api, - credentials_context=credentials_context, - ) - if _are_tool_inputs_not_ready(submit_response): - print("Tool inputs not ready yet") - time.sleep(1) - continue - else: - break - submit_response_object = ensure_tool_run_response_okay(submit_response, "execute tool", inputs_tree) - if not submit_with_legacy_api: - tool_request_id = submit_response_object["tool_request_id"] - successful = self.wait_on_tool_request(tool_request_id) - if not successful: - request = self.get_tool_request(tool_request_id) or {} - raise RunToolException( - f"Tool request failure - state {request.get('state')}, message: {request.get('state_message')}", - inputs_tree, - ) - job_refs = self.jobs_for_tool_request(tool_request_id) - outputs = OutputsDict() - output_collections = {} - if len(job_refs) != 1: - raise Exception( - f"Found incorrect number of jobs for tool request - was expecting a single job {job_refs}" - ) - assert len(job_refs) == 1, job_refs - job_id = job_refs[0]["id"] - # If credentials were created for this test, wait for job completion - # before the finally block cleans them up, so the job can read them. - if created_credentials: - self.wait_for_job(job_id, history_id, testdef.maxseconds or DEFAULT_TOOL_TEST_WAIT) - jobs = [self.__get_job(job_id).json()] - job_outputs = self.job_outputs(job_id) - for job_output in job_outputs: - if "dataset" in job_output: - outputs[job_output["name"]] = job_output["dataset"] - else: - output_collections[job_output["name"]] = job_output["dataset_collection_instance"] + for _ in range(DEFAULT_TOOL_TEST_WAIT): + submit_response = self.__submit_tool( + history_id, + tool_id=testdef.tool_id, + tool_input=inputs_tree, + tool_version=testdef.tool_version, + use_legacy_api=submit_with_legacy_api, + credentials_context=credentials_context, + ) + if _are_tool_inputs_not_ready(submit_response): + print("Tool inputs not ready yet") + time.sleep(1) + continue else: - outputs = self.__dictify_outputs(submit_response_object) - output_collections = self.__dictify_output_collections(submit_response_object) - jobs = submit_response_object["jobs"] - try: - return RunToolResponse( - inputs=inputs_tree, - outputs=outputs, - output_collections=output_collections, - jobs=jobs, + break + submit_response_object = ensure_tool_run_response_okay(submit_response, "execute tool", inputs_tree) + if not submit_with_legacy_api: + tool_request_id = submit_response_object["tool_request_id"] + successful = self.wait_on_tool_request(tool_request_id) + if not successful: + request = self.get_tool_request(tool_request_id) or {} + raise RunToolException( + f"Tool request failure - state {request.get('state')}, message: {request.get('state_message')}", + inputs_tree, ) - except KeyError: - message = f"Error creating a job for these tool inputs - {submit_response_object.get('err_msg', 'unknown error')}" - raise RunToolException(message, inputs_tree) + job_refs = self.jobs_for_tool_request(tool_request_id) + outputs = OutputsDict() + output_collections = {} + if len(job_refs) != 1: + raise Exception( + f"Found incorrect number of jobs for tool request - was expecting a single job {job_refs}" + ) + assert len(job_refs) == 1, job_refs + job_id = job_refs[0]["id"] + if created_credentials: + self.wait_for_job(job_id, history_id, testdef.maxseconds or DEFAULT_TOOL_TEST_WAIT) + jobs = [self.__get_job(job_id).json()] + job_outputs = self.job_outputs(job_id) + for job_output in job_outputs: + if "dataset" in job_output: + outputs[job_output["name"]] = job_output["dataset"] + else: + output_collections[job_output["name"]] = job_output["dataset_collection_instance"] + else: + outputs = self.__dictify_outputs(submit_response_object) + output_collections = self.__dictify_output_collections(submit_response_object) + jobs = submit_response_object["jobs"] + try: + return RunToolResponse( + inputs=inputs_tree, + outputs=outputs, + output_collections=output_collections, + jobs=jobs, + ) + except KeyError: + message = f"Error creating a job for these tool inputs - {submit_response_object.get('err_msg', 'unknown error')}" + raise RunToolException(message, inputs_tree) finally: # Clean up created credentials for cred_info in created_credentials: @@ -921,7 +918,6 @@ class GalaxyInteractorApi: ) raise_for_status(delete_response) except Exception as e: - # Log but don't fail the test if cleanup fails print(f"Warning: Failed to delete test credentials: {e}") def _create_collection(self, history_id, collection_def): From 9df196f75314b4eaa31088b644cc960073002c32 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 11 Mar 2026 12:59:12 +0100 Subject: [PATCH 17/26] Add ToolSubmissionResponse class and refactor GalaxyInteractorApi for improved tool submission handling --- lib/galaxy/tool_util/verify/interactor.py | 76 ++++++++++++++++------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index 9232003190a..e045c8f2cb3 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -209,6 +209,14 @@ class RunToolResponse(NamedTuple): jobs: List[Dict[str, Any]] +class ToolSubmissionResponse(NamedTuple): + inputs: Dict[str, Any] + tool_request_id: Optional[str] # None for legacy submissions + submit_response_object: Dict[str, Any] # raw validated response + is_legacy: bool + cleanup: Optional[Callable[[], None]] = None + + class InteractorStagingInterface(StagingInterface): def __init__(self, galaxy_interactor: "GalaxyInteractorApi", maxseconds: Optional[int], upload_async: bool) -> None: @@ -709,7 +717,7 @@ class GalaxyInteractorApi: history_id: str, resource_parameters: Optional[Dict[str, Any]] = None, use_legacy_api: UseLegacyApiT = DEFAULT_USE_LEGACY_API, - ) -> RunToolResponse: + ) -> "ToolSubmissionResponse": # We need to handle the case where we've uploaded a valid compressed file since the upload # tool will have uncompressed it on the fly. resource_parameters = resource_parameters or {} @@ -868,8 +876,35 @@ class GalaxyInteractorApi: else: break submit_response_object = ensure_tool_run_response_okay(submit_response, "execute tool", inputs_tree) - if not submit_with_legacy_api: - tool_request_id = submit_response_object["tool_request_id"] + tool_request_id = None if submit_with_legacy_api else submit_response_object.get("tool_request_id") + + cleanup: Optional[Callable[[], None]] = None + if created_credentials: + + def cleanup(): + for cred_info in created_credentials: + try: + delete_response = self._delete( + f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}" + ) + raise_for_status(delete_response) + except Exception as e: + print(f"Warning: Failed to delete test credentials: {e}") + + return ToolSubmissionResponse( + inputs=inputs_tree, + tool_request_id=tool_request_id, + submit_response_object=submit_response_object, + is_legacy=submit_with_legacy_api, + cleanup=cleanup, + ) + + def resolve_tool_submission(self, submission: "ToolSubmissionResponse") -> RunToolResponse: + inputs_tree = submission.inputs + submit_response_object = submission.submit_response_object + if not submission.is_legacy: + tool_request_id = submission.tool_request_id + assert tool_request_id is not None successful = self.wait_on_tool_request(tool_request_id) if not successful: request = self.get_tool_request(tool_request_id) or {} @@ -878,19 +913,15 @@ class GalaxyInteractorApi: inputs_tree, ) job_refs = self.jobs_for_tool_request(tool_request_id) - outputs = OutputsDict() - output_collections = {} if len(job_refs) != 1: raise Exception( f"Found incorrect number of jobs for tool request - was expecting a single job {job_refs}" ) - assert len(job_refs) == 1, job_refs job_id = job_refs[0]["id"] - if created_credentials: - self.wait_for_job(job_id, history_id, testdef.maxseconds or DEFAULT_TOOL_TEST_WAIT) jobs = [self.__get_job(job_id).json()] - job_outputs = self.job_outputs(job_id) - for job_output in job_outputs: + outputs = OutputsDict() + output_collections: Dict[str, Any] = {} + for job_output in self.job_outputs(job_id): if "dataset" in job_output: outputs[job_output["name"]] = job_output["dataset"] else: @@ -907,18 +938,10 @@ class GalaxyInteractorApi: jobs=jobs, ) except KeyError: - message = f"Error creating a job for these tool inputs - {submit_response_object.get('err_msg', 'unknown error')}" + message = ( + f"Error creating a job for these tool inputs - {submit_response_object.get('err_msg', 'unknown error')}" + ) raise RunToolException(message, inputs_tree) - finally: - # Clean up created credentials - for cred_info in created_credentials: - try: - delete_response = self._delete( - f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}" - ) - raise_for_status(delete_response) - except Exception as e: - print(f"Warning: Failed to delete test credentials: {e}") def _create_collection(self, history_id, collection_def): create_payload = dict( @@ -1741,6 +1764,7 @@ def verify_tool( tool_execution_exception: Optional[Exception] = None input_staging_exc_info = None expected_failure_occurred = False + credential_cleanup: Optional[Callable[[], None]] = None begin_time = time.time() try: try: @@ -1758,10 +1782,13 @@ def verify_tool( input_staging_exc_info = sys.exc_info() raise try: - tool_response = galaxy_interactor.run_tool( + submission = galaxy_interactor.run_tool( testdef, test_history, resource_parameters=resource_parameters, use_legacy_api=use_legacy_api ) - data_list, jobs, tool_inputs = tool_response.outputs, tool_response.jobs, tool_response.inputs + tool_inputs = submission.inputs + credential_cleanup = submission.cleanup + tool_response = galaxy_interactor.resolve_tool_submission(submission) + data_list, jobs = tool_response.outputs, tool_response.jobs data_collection_list = tool_response.output_collections except RunToolException as e: tool_inputs = e.inputs @@ -1786,6 +1813,9 @@ def verify_tool( except Exception as e: job_output_exceptions = [e] raise e + finally: + if credential_cleanup: + credential_cleanup() finally: if register_job_data is not None: end_time = time.time() From 7435c742a5b9dd4a1ddef4e4e463522175a8bd03 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 11 Mar 2026 14:28:14 +0100 Subject: [PATCH 18/26] Refactor credential cleanup function for improved clarity and ensure proper execution in finally block --- lib/galaxy/tool_util/verify/interactor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index e045c8f2cb3..d9d15bdf506 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -881,7 +881,7 @@ class GalaxyInteractorApi: cleanup: Optional[Callable[[], None]] = None if created_credentials: - def cleanup(): + def _cleanup_credentials(): for cred_info in created_credentials: try: delete_response = self._delete( @@ -891,6 +891,8 @@ class GalaxyInteractorApi: except Exception as e: print(f"Warning: Failed to delete test credentials: {e}") + cleanup = _cleanup_credentials + return ToolSubmissionResponse( inputs=inputs_tree, tool_request_id=tool_request_id, @@ -1813,10 +1815,9 @@ def verify_tool( except Exception as e: job_output_exceptions = [e] raise e - finally: - if credential_cleanup: - credential_cleanup() finally: + if credential_cleanup: + credential_cleanup() if register_job_data is not None: end_time = time.time() job_data["time_seconds"] = end_time - begin_time From b3872c50d7929978e7602e4799eb0e738dfb53b2 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 12 Mar 2026 10:49:30 +0100 Subject: [PATCH 19/26] Refactor credential handling by removing unused credential context definitions and updating related logic in job submission and tool services --- client/src/api/schema/schema.ts | 40 --------------------- lib/galaxy/managers/jobs.py | 31 +--------------- lib/galaxy/schema/tasks.py | 16 --------- lib/galaxy/tool_util/verify/interactor.py | 3 ++ lib/galaxy/webapps/galaxy/services/jobs.py | 30 +++------------- lib/galaxy/webapps/galaxy/services/tools.py | 12 ++----- 6 files changed, 10 insertions(+), 122 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index beaecd49ccf..d4f35364efb 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -17584,11 +17584,6 @@ export interface components { }; /** JobRequest */ JobRequest: { - /** - * credentials_context - * @description Credential context for tool execution. - */ - credentials_context?: components["schemas"]["ServiceCredentialsContext"][] | null; /** * history_id * @description TODO @@ -21715,20 +21710,6 @@ export interface components { */ source_version: string; }; - /** SelectedGroup */ - SelectedGroup: { - /** - * Id - * @description The ID of the selected credential group. - * @example 0123456789ABCDEF - */ - id: string; - /** - * Name - * @description The name of the selected credential group. - */ - name: string; - }; /** ServerDirElement */ ServerDirElement: { /** @@ -21901,27 +21882,6 @@ export interface components { */ version: string; }; - /** ServiceCredentialsContext */ - ServiceCredentialsContext: { - /** - * Name - * @description The name of the service. - */ - name: string; - /** @description The currently selected credential group. */ - selected_group: components["schemas"]["SelectedGroup"]; - /** - * User Credentials Id - * @description The ID of the user credentials. - * @example 0123456789ABCDEF - */ - user_credentials_id: string; - /** - * Version - * @description The version of the service. - */ - version: string; - }; /** ServiceCredentialsDefinition */ ServiceCredentialsDefinition: { /** diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 567d0c25c5a..382b95b8618 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -79,11 +79,6 @@ from galaxy.model.index_filter_util import ( text_column_filter, ) from galaxy.model.scoped_session import galaxy_scoped_session -from galaxy.schema.credentials import ( - CredentialsContext, - SelectedGroup, - ServiceCredentialsContext, -) from galaxy.schema.schema import ( JobIndexQueryPayload, JobIndexSortByEnum, @@ -105,7 +100,6 @@ from galaxy.tool_util.parameters import ( dereference, RequestInternalDereferencedToolState, RequestInternalToolState, - ToolParameterBundleModel, ) from galaxy.tools import Tool from galaxy.tools._types import ( @@ -2175,13 +2169,7 @@ class JobSubmitter: return DataRequestInternalHda(id=hda.id, src="hda") tool_state = RequestInternalToolState(tool_request.request) - if tool.parameters is None: - raise RequestParameterInvalidException(f"Tool {tool.id} has no parameters defined") - parameter_bundle = ToolParameterBundleModel(parameters=tool.parameters) - return ( - dereference(tool_state, parameter_bundle, dereference_callback, dereference_collection_callback), - new_hdas, - ) + return dereference(tool_state, tool, dereference_callback, dereference_collection_callback), new_hdas def queue_jobs(self, tool: Tool, request: QueueJobs) -> None: tool_request: ToolRequest = self._tool_request(request.tool_request_id) @@ -2191,22 +2179,6 @@ class JobSubmitter: target_history = request_context.history use_cached_jobs = request.use_cached_jobs rerun_remap_job_id = request.rerun_remap_job_id - credentials_context: Optional[CredentialsContext] = None - if request.credentials_context: - credentials_context = CredentialsContext( - root=[ - ServiceCredentialsContext.model_construct( - user_credentials_id=task.user_credentials_id, - name=task.name, - version=task.version, - selected_group=SelectedGroup.model_construct( - id=task.selected_group_id, - name=task.selected_group_name, - ), - ) - for task in request.credentials_context - ] - ) tool_state, new_hdas = self.dereference(request_context, tool, request, tool_request) to_materialize_list = [p for p in new_hdas if not p.request.deferred] for to_materialize in to_materialize_list: @@ -2222,7 +2194,6 @@ class JobSubmitter: history=target_history, use_cached_job=use_cached_jobs, rerun_remap_job_id=rerun_remap_job_id, - credentials_context=credentials_context, ) tool_request.state = ToolRequest.states.SUBMITTED sa_session.add(tool_request) diff --git a/lib/galaxy/schema/tasks.py b/lib/galaxy/schema/tasks.py index a55119ce019..8c6ef67e7f8 100644 --- a/lib/galaxy/schema/tasks.py +++ b/lib/galaxy/schema/tasks.py @@ -171,19 +171,6 @@ class TaskResult(Model): ) -class ServiceCredentialTask(Model): - """Flat representation of a service credential for Celery task payloads. - - Uses plain int IDs to follow Galaxy's standard Celery task pattern. - """ - - user_credentials_id: int - name: str - version: str - selected_group_id: int - selected_group_name: str - - TOOL_SOURCE_CLASS = Literal["XmlToolSource", "YamlToolSource", "CwlToolSource"] @@ -199,6 +186,3 @@ class QueueJobs(Model): user: RequestUser # TODO: test anonymous users through this submission path use_cached_jobs: bool rerun_remap_job_id: Optional[int] # link to a job to rerun & remap - credentials_context: Optional[list[ServiceCredentialTask]] = ( - None # credential context for vault-based credential injection - ) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index d9d15bdf506..ec11ba45899 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -727,6 +727,9 @@ class GalaxyInteractorApi: if testdef.value_state_representation == "test_case_json": # Don't submit user / YAML tools to the old endpoint. submit_with_legacy_api = False + if testdef.credentials: + # Force legacy API for credential-bearing tests since /api/tools already supports credentials_context. + submit_with_legacy_api = True if submit_with_legacy_api: inputs_tree = testdef.inputs.copy() for key, value in inputs_tree.items(): diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index 4e45679f3b4..62f7186f73d 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -35,7 +35,6 @@ from galaxy.model import ( ToolRequest, ToolSource as ToolSourceModel, ) -from galaxy.schema.credentials import ServiceCredentialsContext from galaxy.schema.fields import ( DecodedDatabaseIdField, EncodedDatabaseIdField, @@ -50,7 +49,6 @@ from galaxy.schema.schema import ( ) from galaxy.schema.tasks import ( QueueJobs, - ServiceCredentialTask, ToolSource, ) from galaxy.security.idencoding import IdEncodingHelper @@ -59,7 +57,6 @@ from galaxy.tool_util.parameters import ( RelaxedRequestToolState, RequestToolState, strictify, - ToolParameterBundleModel, ) from galaxy.webapps.galaxy.services.base import ( async_task_summary, @@ -92,9 +89,6 @@ class JobRequest(BaseModel): default=None, title="rerun_remap_job_id", description="TODO" ) send_email_notification: bool = Field(default=False, title="Send Email Notification", description="TODO") - credentials_context: Optional[list[ServiceCredentialsContext]] = Field( - default=None, title="credentials_context", description="Credential context for tool execution." - ) class JobCreateResponse(BaseModel): @@ -252,17 +246,14 @@ class JobsService(ServiceBase): target_history = self.history_manager.get_owned(history_id, trans.user, current_history=trans.history) inputs = job_request.inputs strict = job_request.strict - if tool.parameters is None: - raise exceptions.RequestParameterInvalidException(f"Tool {tool.id} has no parameters defined") - parameter_bundle = ToolParameterBundleModel(parameters=tool.parameters) if not strict: relaxed_request_state = RelaxedRequestToolState(inputs or {}) - relaxed_request_state.validate(parameter_bundle, f"{tool.id} (relaxed request model)") - request_state = strictify(relaxed_request_state, parameter_bundle) + relaxed_request_state.validate(tool, f"{tool.id} (relaxed request model)") + request_state = strictify(relaxed_request_state, tool) else: request_state = RequestToolState(inputs or {}) - request_state.validate(parameter_bundle, f"{tool.id} (request model)") - request_internal_state = decode(request_state, parameter_bundle, trans.security.decode_id) + request_state.validate(tool, f"{tool.id} (request model)") + request_internal_state = decode(request_state, tool, trans.security.decode_id) tool_request = ToolRequest() # TODO: hash and such... tool_source_model = ToolSourceModel( @@ -284,18 +275,6 @@ class JobsService(ServiceBase): tool_dir=tool.tool_dir, tool_source_class=tool_source_model.source_class, ) - credentials_context_tasks = None - if job_request.credentials_context: - credentials_context_tasks = [ - ServiceCredentialTask( - user_credentials_id=sc.user_credentials_id, - name=sc.name, - version=sc.version, - selected_group_id=sc.selected_group.id, - selected_group_name=sc.selected_group.name, - ) - for sc in job_request.credentials_context - ] task_request = QueueJobs( user=trans.async_request_user, history_id=target_history and target_history.id, @@ -303,7 +282,6 @@ class JobsService(ServiceBase): tool_request_id=tool_request_id, use_cached_jobs=job_request.use_cached_jobs or False, rerun_remap_job_id=job_request.rerun_remap_job_id, - credentials_context=credentials_context_tasks, ) result = queue_jobs.delay(request=task_request) return JobCreateResponse( diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index c508b857e65..30ca04fa30e 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -258,8 +258,6 @@ class ToolsService(ServiceBase): tool_ref: ToolRunReference, ) -> list[ToolParameterT]: tool = get_tool(trans, tool_ref) - if tool.parameters is None: - raise exceptions.RequestParameterInvalidException("Tool input parameter schema could not be retrieved.") return tool.parameters def create_fetch( @@ -352,7 +350,7 @@ class ToolsService(ServiceBase): inputs.get("use_cached_job", "false") ) preferred_object_store_id = payload.get("preferred_object_store_id") - credentials_context_raw = payload.get("credentials_context") + credentials_context = payload.get("credentials_context") input_format = str(payload.get("input_format", "legacy")) if input_format not in get_args(InputFormatT): raise exceptions.RequestParameterInvalidException(f"input_format invalid {input_format}") @@ -360,12 +358,6 @@ class ToolsService(ServiceBase): if "data_manager_mode" in payload: incoming["__data_manager_mode"] = payload["data_manager_mode"] tags = payload.get("__tags") - - # Handle credentials_context - credentials_context: Optional[CredentialsContext] = None - if credentials_context_raw: - credentials_context = CredentialsContext(root=credentials_context_raw) - vars = tool.handle_input( trans, incoming, @@ -373,7 +365,7 @@ class ToolsService(ServiceBase): use_cached_job=use_cached_job, input_format=input_format, preferred_object_store_id=preferred_object_store_id, - credentials_context=credentials_context, + credentials_context=CredentialsContext(root=credentials_context) if credentials_context else None, tags=tags, ) From b057d63b78fdd1518668104a9f643638113ea978 Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 12 Mar 2026 11:13:54 +0100 Subject: [PATCH 20/26] Add validation for tool parameters in job submission and tool services --- lib/galaxy/managers/jobs.py | 9 ++++++++- lib/galaxy/webapps/galaxy/services/jobs.py | 12 ++++++++---- lib/galaxy/webapps/galaxy/services/tools.py | 2 ++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index 382b95b8618..c15c8cb5698 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -100,6 +100,7 @@ from galaxy.tool_util.parameters import ( dereference, RequestInternalDereferencedToolState, RequestInternalToolState, + ToolParameterBundleModel, ) from galaxy.tools import Tool from galaxy.tools._types import ( @@ -2169,7 +2170,13 @@ class JobSubmitter: return DataRequestInternalHda(id=hda.id, src="hda") tool_state = RequestInternalToolState(tool_request.request) - return dereference(tool_state, tool, dereference_callback, dereference_collection_callback), new_hdas + if tool.parameters is None: + raise InconsistentDatabase(f"Tool {tool.id} has no parameters defined") + parameter_bundle = ToolParameterBundleModel(parameters=tool.parameters) + return ( + dereference(tool_state, parameter_bundle, dereference_callback, dereference_collection_callback), + new_hdas, + ) def queue_jobs(self, tool: Tool, request: QueueJobs) -> None: tool_request: ToolRequest = self._tool_request(request.tool_request_id) diff --git a/lib/galaxy/webapps/galaxy/services/jobs.py b/lib/galaxy/webapps/galaxy/services/jobs.py index 62f7186f73d..ffc3a054983 100644 --- a/lib/galaxy/webapps/galaxy/services/jobs.py +++ b/lib/galaxy/webapps/galaxy/services/jobs.py @@ -57,6 +57,7 @@ from galaxy.tool_util.parameters import ( RelaxedRequestToolState, RequestToolState, strictify, + ToolParameterBundleModel, ) from galaxy.webapps.galaxy.services.base import ( async_task_summary, @@ -246,14 +247,17 @@ class JobsService(ServiceBase): target_history = self.history_manager.get_owned(history_id, trans.user, current_history=trans.history) inputs = job_request.inputs strict = job_request.strict + if tool.parameters is None: + raise exceptions.RequestParameterInvalidException(f"Tool {tool.id} has no parameters defined") + parameter_bundle = ToolParameterBundleModel(parameters=tool.parameters) if not strict: relaxed_request_state = RelaxedRequestToolState(inputs or {}) - relaxed_request_state.validate(tool, f"{tool.id} (relaxed request model)") - request_state = strictify(relaxed_request_state, tool) + relaxed_request_state.validate(parameter_bundle, f"{tool.id} (relaxed request model)") + request_state = strictify(relaxed_request_state, parameter_bundle) else: request_state = RequestToolState(inputs or {}) - request_state.validate(tool, f"{tool.id} (request model)") - request_internal_state = decode(request_state, tool, trans.security.decode_id) + request_state.validate(parameter_bundle, f"{tool.id} (request model)") + request_internal_state = decode(request_state, parameter_bundle, trans.security.decode_id) tool_request = ToolRequest() # TODO: hash and such... tool_source_model = ToolSourceModel( diff --git a/lib/galaxy/webapps/galaxy/services/tools.py b/lib/galaxy/webapps/galaxy/services/tools.py index 30ca04fa30e..ef469889acb 100644 --- a/lib/galaxy/webapps/galaxy/services/tools.py +++ b/lib/galaxy/webapps/galaxy/services/tools.py @@ -258,6 +258,8 @@ class ToolsService(ServiceBase): tool_ref: ToolRunReference, ) -> list[ToolParameterT]: tool = get_tool(trans, tool_ref) + if tool.parameters is None: + raise exceptions.RequestParameterInvalidException("Tool input parameter schema could not be retrieved.") return tool.parameters def create_fetch( From 2a346432a646416449ff54d42a69569cb7d3189c Mon Sep 17 00:00:00 2001 From: Arash Date: Thu, 12 Mar 2026 11:15:57 +0100 Subject: [PATCH 21/26] Replace InconsistentDatabase exception with RequestParameterInvalidException for missing tool parameters --- lib/galaxy/managers/jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/managers/jobs.py b/lib/galaxy/managers/jobs.py index c15c8cb5698..8a6195ba15f 100644 --- a/lib/galaxy/managers/jobs.py +++ b/lib/galaxy/managers/jobs.py @@ -2171,7 +2171,7 @@ class JobSubmitter: tool_state = RequestInternalToolState(tool_request.request) if tool.parameters is None: - raise InconsistentDatabase(f"Tool {tool.id} has no parameters defined") + raise RequestParameterInvalidException(f"Tool {tool.id} has no parameters defined") parameter_bundle = ToolParameterBundleModel(parameters=tool.parameters) return ( dereference(tool_state, parameter_bundle, dereference_callback, dereference_collection_callback), From a241357240efb7f3f23173055e196ec5dc8192b8 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 18 Mar 2026 12:02:56 +0100 Subject: [PATCH 22/26] Extract credential API helpers and fold credentials_context into extra_data - Add `_credential_api_call` for low-level HTTP calls (POST/GET/DELETE) - Add `_create_test_credentials` to encapsulate the ~70-line credential setup block formerly inlined in `run_tool` - Fold `credentials_context` into `extra_data` before passing to `__submit_tool`, removing the dedicated `credentials_context` parameter --- lib/galaxy/tool_util/verify/interactor.py | 166 +++++++++++----------- 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index ec11ba45899..7c7a9947591 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -21,6 +21,7 @@ from typing import ( List, NamedTuple, Optional, + Tuple, Union, ) @@ -711,6 +712,80 @@ class GalaxyInteractorApi: raise ValueError(f"Invalid `location` URL: `{location}`") return location + def _credential_api_call(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Any: + """Low-level helper: call a credential API endpoint, raise on error, return JSON.""" + if method == "post": + response = self._post(path, data=data or {}, json=True) + elif method == "get": + response = self._get(path) + elif method == "delete": + response = self._delete(path) + else: + raise ValueError(f"Unsupported method: {method}") + raise_for_status(response) + return response.json() + + def _create_test_credentials( + self, testdef: "ToolTestDescription" + ) -> Tuple[List[Dict[str, Any]], Optional[List[Dict[str, Any]]]]: + """Create vault credentials for a test and return (created_credentials, credentials_context).""" + if not testdef.credentials: + return [], None + + user_id = self._credential_api_call("get", "whoami")["id"] + created_credentials = [] + credentials_context_list = [] + + for cred in testdef.credentials: + credential_payload = { + "source_type": "tool", + "source_id": testdef.tool_id, + "source_version": testdef.tool_version or "1.0.0", + "service_credential": { + "name": cred["name"], + "version": cred.get("version", "1.0"), + "group": { + "name": f"test_group_{cred['name']}", + "variables": cred.get("variables", []), + "secrets": cred.get("secrets", []), + }, + }, + } + created_cred = self._credential_api_call("post", f"users/{user_id}/credentials", data=credential_payload) + all_credentials = self._credential_api_call("get", f"users/{user_id}/credentials") + + # Find user_credentials_id by matching the newly-created group id. + user_credentials_id = None + for user_cred in all_credentials: + if ( + user_cred["source_type"] == "tool" + and user_cred["source_id"] == testdef.tool_id + and user_cred.get("source_version") == (testdef.tool_version or "1.0.0") + ): + for group in user_cred["groups"]: + if group["id"] == created_cred["id"]: + user_credentials_id = user_cred["id"] + break + if user_credentials_id: + break + + if not user_credentials_id: + raise RuntimeError( + f"Failed to find user_credentials_id for created credential group {created_cred['id']}" + ) + + created_credentials.append({"user_credentials_id": user_credentials_id, "user_id": user_id}) + credentials_context_list.append( + { + "user_credentials_id": user_credentials_id, + "name": cred["name"], + "version": cred.get("version", "1.0"), + "selected_group": {"id": created_cred["id"], "name": created_cred["name"]}, + } + ) + + return created_credentials, credentials_context_list + def run_tool( self, testdef: "ToolTestDescription", @@ -788,80 +863,10 @@ class GalaxyInteractorApi: submit_response = None - # Create vault-based credentials via API for test execution - created_credentials = [] - credentials_context = None - if testdef.credentials: - # Get user_id for credential creation - whoami_response = self._get("whoami") - user_id = whoami_response.json()["id"] - - credentials_context_list = [] - for cred in testdef.credentials: - # Build payload for credential creation - credential_payload = { - "source_type": "tool", - "source_id": testdef.tool_id, - "source_version": testdef.tool_version or "1.0.0", - "service_credential": { - "name": cred["name"], - "version": cred.get("version", "1.0"), - "group": { - "name": f"test_group_{cred['name']}", - "variables": cred.get("variables", []), - "secrets": cred.get("secrets", []), - }, - }, - } - - # Create credentials via API - create_response = self._post(f"users/{user_id}/credentials", data=credential_payload, json=True) - raise_for_status(create_response) - created_cred = create_response.json() - - # Get the user_credentials_id by listing credentials - # (POST returns ServiceCredentialGroupResponse which only has the group id, - # we need UserServiceCredentialsResponse which has the user_credentials_id) - list_response = self._get(f"users/{user_id}/credentials") - raise_for_status(list_response) - all_credentials = list_response.json() - - # Find the user_credentials_id by searching for the UserCredentials entry - # that contains our newly-created group (matched by group ID). - # Filter by source_type, source_id, and source_version to reduce the scan. - user_credentials_id = None - for user_cred in all_credentials: - if ( - user_cred["source_type"] == "tool" - and user_cred["source_id"] == testdef.tool_id - and user_cred.get("source_version") == (testdef.tool_version or "1.0.0") - ): - for group in user_cred["groups"]: - if group["id"] == created_cred["id"]: - user_credentials_id = user_cred["id"] - break - if user_credentials_id: - break - - if not user_credentials_id: - raise RuntimeError( - f"Failed to find user_credentials_id for created credential group {created_cred['id']}" - ) - - # Store for cleanup - created_credentials.append({"user_credentials_id": user_credentials_id, "user_id": user_id}) - - # Build credentials_context entry - credentials_context_list.append( - { - "user_credentials_id": user_credentials_id, - "name": cred["name"], - "version": cred.get("version", "1.0"), - "selected_group": {"id": created_cred["id"], "name": created_cred["name"]}, - } - ) - - credentials_context = credentials_context_list + extra_data: Dict[str, Any] = {} + created_credentials, credentials_context = self._create_test_credentials(testdef) + if credentials_context is not None: + extra_data["credentials_context"] = credentials_context for _ in range(DEFAULT_TOOL_TEST_WAIT): submit_response = self.__submit_tool( @@ -870,7 +875,7 @@ class GalaxyInteractorApi: tool_input=inputs_tree, tool_version=testdef.tool_version, use_legacy_api=submit_with_legacy_api, - credentials_context=credentials_context, + extra_data=extra_data, ) if _are_tool_inputs_not_ready(submit_response): print("Tool inputs not ready yet") @@ -887,10 +892,9 @@ class GalaxyInteractorApi: def _cleanup_credentials(): for cred_info in created_credentials: try: - delete_response = self._delete( - f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}" + self._credential_api_call( + "delete", f"users/{cred_info['user_id']}/credentials/{cred_info['user_credentials_id']}" ) - raise_for_status(delete_response) except Exception as e: print(f"Warning: Failed to delete test credentials: {e}") @@ -1130,17 +1134,15 @@ class GalaxyInteractorApi: files: Optional[dict] = None, tool_version: Optional[str] = None, use_legacy_api: bool = True, - credentials_context: Optional[List[Dict[str, Any]]] = None, ): extra_data = extra_data or {} if use_legacy_api: - if credentials_context: + if "credentials_context" in extra_data: data = dict( history_id=history_id, tool_id=tool_id, inputs=tool_input, tool_version=tool_version, - credentials_context=credentials_context, **extra_data, ) return self._post("tools", data=data, json=True) @@ -1157,8 +1159,6 @@ class GalaxyInteractorApi: data = dict( history_id=history_id, tool_id=tool_id, inputs=tool_input, tool_version=tool_version, **extra_data ) - if credentials_context: - data["credentials_context"] = credentials_context submit_tool_request_response = self._post("jobs", data=data, json=True) return submit_tool_request_response From bc32fa1c71fb12c575abf8f62f46d9434eaa387b Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 18 Mar 2026 12:03:21 +0100 Subject: [PATCH 23/26] Use Pydantic TypeAdapter for credential validation in XML and YAML parsers - Add module-level `_direct_credential_adapter = TypeAdapter(List[DirectCredential])` - Replace manual field-by-field validation loops with `validate_python(raw_list)` - Remove now-unused `DirectCredentialValue` import from both parsers --- lib/galaxy/tool_util/parser/xml.py | 48 +++++++++---------------- lib/galaxy/tool_util/parser/yaml.py | 55 +++++------------------------ 2 files changed, 26 insertions(+), 77 deletions(-) diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index e2f56199951..b2816a8943f 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -18,6 +18,7 @@ from typing import ( ) from packaging.version import Version +from pydantic import TypeAdapter from galaxy.tool_util.deps import requirements from galaxy.tool_util.parser.util import ( @@ -54,7 +55,6 @@ from galaxy.util import ( from .interface import ( AssertionList, DirectCredential, - DirectCredentialValue, DrillDownDynamicOptions, DynamicOptions, InputSource, @@ -1096,45 +1096,31 @@ def __parse_inputs_elems(test_elem, i) -> ToolSourceTestInputs: return raw_inputs +_direct_credential_adapter: TypeAdapter = TypeAdapter(List[DirectCredential]) + + def __parse_credentials_elems(test_elem): """ Parse credential definitions from test element. Returns a list of DirectCredential dictionaries or None if no credentials are defined. """ - - credentials_list = [] + raw_list = [] for cred_elem in test_elem.findall("credentials"): - name = cred_elem.get("name") - if not name: - raise ValueError("Credentials element must have a 'name' attribute") - - variables = [] - for var_elem in cred_elem.findall("variable"): - var_name = var_elem.get("name") - var_value = var_elem.get("value") - if not var_name: - raise ValueError("Credential variable must have a 'name' attribute") - if var_value is None: - raise ValueError(f"Credential variable '{var_name}' must have a 'value' attribute") - variables.append(DirectCredentialValue(name=var_name, value=var_value)) - - secrets = [] - for secret_elem in cred_elem.findall("secret"): - secret_name = secret_elem.get("name") - secret_value = secret_elem.get("value") - if not secret_name: - raise ValueError("Credential secret must have a 'name' attribute") - if secret_value is None: - raise ValueError(f"Credential secret '{secret_name}' must have a 'value' attribute") - secrets.append(DirectCredentialValue(name=secret_name, value=secret_value)) - - cred: DirectCredential = {"name": name, "variables": variables, "secrets": secrets} + variables = [ + {"name": v.get("name"), "value": v.get("value")} for v in cred_elem.findall("variable") + ] + secrets = [ + {"name": s.get("name"), "value": s.get("value")} for s in cred_elem.findall("secret") + ] + raw: dict = {"name": cred_elem.get("name"), "variables": variables, "secrets": secrets} version = cred_elem.get("version") if version is not None: - cred["version"] = version - credentials_list.append(cred) + raw["version"] = version + raw_list.append(raw) - return credentials_list if credentials_list else None + if not raw_list: + return None + return _direct_credential_adapter.validate_python(raw_list) def _test_collection_def_dict(elem: Element) -> XmlTestCollectionDefDict: diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 7ffb2cee799..3116f25364c 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -12,6 +12,7 @@ from typing import ( ) import packaging.version +from pydantic import TypeAdapter from galaxy.tool_util.deps import requirements from galaxy.tool_util.parameters.convert import _select_which_when @@ -42,7 +43,6 @@ from .interface import ( AssertionDict, AssertionList, DirectCredential, - DirectCredentialValue, InputSource, PageSource, PagesSource, @@ -430,6 +430,9 @@ def _parse_test(i: int, test_dict: dict) -> ToolSourceTest: return cast(ToolSourceTest, test_dict) +_direct_credential_adapter: TypeAdapter = TypeAdapter(List[DirectCredential]) + + def __parse_credentials_yaml(credentials_data) -> Optional[List[DirectCredential]]: """ Parse credentials from YAML test definition. @@ -438,57 +441,17 @@ def __parse_credentials_yaml(credentials_data) -> Optional[List[DirectCredential - List: [{name: "cred1", variables: [...], secrets: [...]}] - Dict: {cred1: {variables: [...], secrets: []}} """ - if not credentials_data: return None - credentials_list: List[DirectCredential] = [] - - # Support both dict and list formats + # Normalise both dict and list formats into a flat list of raw dicts. if is_dict(credentials_data): - # Convert {name: {variables: [], secrets: []}} to list format - items = credentials_data.items() + # {name: {variables: [], secrets: []}} → [{name: ..., variables: [], secrets: []}] + raw_list = [{"name": name, **cred_data} for name, cred_data in credentials_data.items()] else: - # Already a list: [{name: "...", variables: [], secrets: []}] - items = [(cred.get("name"), cred) for cred in credentials_data] + raw_list = list(credentials_data) - for name, cred_data in items: - if not name: - raise ValueError("Test credentials must have a 'name'") - - variables: List[DirectCredentialValue] = [] - for var_data in cred_data.get("variables", []): - if is_dict(var_data): - var_name = var_data.get("name") - var_value = var_data.get("value") - else: - raise ValueError("YAML credential variable must be a dictionary with 'name' and 'value'") - if not var_name: - raise ValueError("Credential variable must have a 'name'") - if var_value is None: - raise ValueError(f"Credential variable '{var_name}' must have a 'value'") - variables.append(DirectCredentialValue(name=var_name, value=var_value)) - - secrets: List[DirectCredentialValue] = [] - for secret_data in cred_data.get("secrets", []): - if is_dict(secret_data): - secret_name = secret_data.get("name") - secret_value = secret_data.get("value") - else: - raise ValueError("YAML credential secret must be a dictionary with 'name' and 'value'") - if not secret_name: - raise ValueError("Credential secret must have a 'name'") - if secret_value is None: - raise ValueError(f"Credential secret '{secret_name}' must have a 'value'") - secrets.append(DirectCredentialValue(name=secret_name, value=secret_value)) - - cred: DirectCredential = {"name": name, "variables": variables, "secrets": secrets} - version = cred_data.get("version") if is_dict(cred_data) else None - if version is not None: - cred["version"] = str(version) - credentials_list.append(cred) - - return credentials_list if credentials_list else None + return _direct_credential_adapter.validate_python(raw_list) def to_test_assert_list(assertions) -> AssertionList: From 2e0ced5142ac2f13feda52f3dae0d6fc781292ad Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 18 Mar 2026 12:03:33 +0100 Subject: [PATCH 24/26] Simplify test_credential_parsing to use FunctionalTestToolTestCase Replace the 273-line test file (temp files, inline XML/YAML templates, manual get_tool_source calls) with ~40 lines using FunctionalTestToolTestCase and the real credentials_test.xml fixture. --- .../unit/tool_util/test_credential_parsing.py | 285 ++---------------- 1 file changed, 28 insertions(+), 257 deletions(-) diff --git a/test/unit/tool_util/test_credential_parsing.py b/test/unit/tool_util/test_credential_parsing.py index 57fd50479f0..45fa9994450 100644 --- a/test/unit/tool_util/test_credential_parsing.py +++ b/test/unit/tool_util/test_credential_parsing.py @@ -1,272 +1,43 @@ -"""Unit tests for credential parsing in tool XML and YAML test definitions.""" +"""Unit tests for credential parsing from tool test definitions.""" -import os -import shutil -import tempfile - -import pytest - -from galaxy.tool_util.parser.factory import get_tool_source +from galaxy.tool_util.unittest_utils import functional_test_tool_path from galaxy.util.unittest import TestCase - -# Minimal tool XML wrapper that includes a test -_TOOL_XML_TEMPLATE = """\ - - echo done - - - -{tests} - - -""" - -# Minimal tool YAML wrapper -_TOOL_YAML_TEMPLATE = """\ -class: GalaxyTool -id: cred_test -name: Cred Test -version: "1.0.0" -shell_command: echo done -inputs: [] -outputs: [] -tests: -{tests} -""" +from .test_parsing import FunctionalTestToolTestCase -def _write_temp_tool(content: str, suffix: str, directory: str) -> str: - path = os.path.join(directory, f"cred_test{suffix}") - with open(path, "w") as f: - f.write(content) - return path +class TestCredentialParsing(FunctionalTestToolTestCase): + test_path = "credentials_test.xml" + def test_credentials_parsing(self): + tests_dict = self._tool_source.parse_tests_to_dict() + tests = tests_dict["tests"] + assert len(tests) == 2 -def _parse_tests(path: str): - tool_source = get_tool_source(path) - return tool_source.parse_tests_to_dict()["tests"] - - -class TestXmlCredentialParsing(TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - - def tearDown(self): - shutil.rmtree(self.tmpdir) - - def _parse_xml_tests(self, test_block: str): - xml = _TOOL_XML_TEMPLATE.format(tests=test_block) - path = _write_temp_tool(xml, ".xml", self.tmpdir) - return _parse_tests(path) - - def test_no_credentials(self): - tests = self._parse_xml_tests("") - assert tests[0]["credentials"] is None - - def test_single_credential_with_variable_and_secret(self): - tests = self._parse_xml_tests(""" - - - - - """) + # First test: test_user / test_password_123 creds = tests[0]["credentials"] assert creds is not None assert len(creds) == 1 cred = creds[0] - assert cred["name"] == "my_service" - assert len(cred["variables"]) == 1 - assert cred["variables"][0] == {"name": "MY_USER", "value": "testuser"} - assert len(cred["secrets"]) == 1 - assert cred["secrets"][0] == {"name": "MY_PASS", "value": "testpass"} + assert cred["name"] == "test_service" + assert cred["variables"] == [{"name": "TEST_USERNAME", "value": "test_user"}] + assert cred["secrets"] == [{"name": "TEST_PASSWORD", "value": "test_password_123"}] - def test_credential_version_explicit(self): - tests = self._parse_xml_tests(""" - - - - """) - cred = tests[0]["credentials"][0] - assert cred.get("version") == "2.5" - - def test_credential_version_omitted(self): - """When version is absent, the field should not be present (no default injected by parser).""" - tests = self._parse_xml_tests(""" - - - - """) - cred = tests[0]["credentials"][0] - assert "version" not in cred - - def test_multiple_credentials(self): - tests = self._parse_xml_tests(""" - - - - - - - """) - creds = tests[0]["credentials"] - assert len(creds) == 2 - assert creds[0]["name"] == "svc_a" - assert creds[1]["name"] == "svc_b" - - def test_credentials_only_variables(self): - tests = self._parse_xml_tests(""" - - - - """) - cred = tests[0]["credentials"][0] - assert len(cred["variables"]) == 1 - assert cred["secrets"] == [] - - def test_credentials_only_secrets(self): - tests = self._parse_xml_tests(""" - - - - """) - cred = tests[0]["credentials"][0] - assert cred["variables"] == [] - assert len(cred["secrets"]) == 1 - - def test_missing_name_raises(self): - with pytest.raises(ValueError, match="name"): - self._parse_xml_tests(""" - - - - """) - - def test_missing_variable_name_raises(self): - with pytest.raises(ValueError, match="name"): - self._parse_xml_tests(""" - - - - """) - - def test_missing_variable_value_raises(self): - with pytest.raises(ValueError, match="value"): - self._parse_xml_tests(""" - - - - """) - - def test_missing_secret_value_raises(self): - with pytest.raises(ValueError, match="value"): - self._parse_xml_tests(""" - - - - """) + # Second test: another_user / secret + creds2 = tests[1]["credentials"] + assert creds2 is not None + assert len(creds2) == 1 + assert creds2[0]["variables"] == [{"name": "TEST_USERNAME", "value": "another_user"}] + assert creds2[0]["secrets"] == [{"name": "TEST_PASSWORD", "value": "secret"}] -class TestYamlCredentialParsing(TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() +class TestNoCredentials(TestCase): + """Verify tools without credentials return None.""" - def tearDown(self): - shutil.rmtree(self.tmpdir) + def test_no_credentials_field(self): + from galaxy.tool_util.parser.factory import get_tool_source - def _parse_yaml_tests(self, test_block: str): - yaml = _TOOL_YAML_TEMPLATE.format(tests=test_block) - path = _write_temp_tool(yaml, ".yml", self.tmpdir) - return _parse_tests(path) - - def test_no_credentials(self): - tests = self._parse_yaml_tests(" - doc: simple\n outputs: {}\n") - assert tests[0]["credentials"] is None - - def test_list_format_with_variable_and_secret(self): - tests = self._parse_yaml_tests("""\ - - doc: cred test - credentials: - - name: my_service - variables: - - name: MY_USER - value: testuser - secrets: - - name: MY_PASS - value: testpass - outputs: {} -""") - creds = tests[0]["credentials"] - assert creds is not None - assert len(creds) == 1 - cred = creds[0] - assert cred["name"] == "my_service" - assert cred["variables"] == [{"name": "MY_USER", "value": "testuser"}] - assert cred["secrets"] == [{"name": "MY_PASS", "value": "testpass"}] - - def test_dict_format(self): - tests = self._parse_yaml_tests("""\ - - doc: cred test - credentials: - my_service: - variables: - - name: V - value: v - secrets: [] - outputs: {} -""") - creds = tests[0]["credentials"] - assert len(creds) == 1 - assert creds[0]["name"] == "my_service" - - def test_version_in_list_format(self): - tests = self._parse_yaml_tests("""\ - - doc: cred test - credentials: - - name: svc - version: "2.0" - variables: - - name: V - value: v - secrets: [] - outputs: {} -""") - cred = tests[0]["credentials"][0] - assert cred.get("version") == "2.0" - - def test_version_omitted(self): - tests = self._parse_yaml_tests("""\ - - doc: cred test - credentials: - - name: svc - variables: - - name: V - value: v - secrets: [] - outputs: {} -""") - cred = tests[0]["credentials"][0] - assert "version" not in cred - - def test_missing_variable_name_raises(self): - with pytest.raises(ValueError, match="name"): - self._parse_yaml_tests("""\ - - doc: cred test - credentials: - - name: svc - variables: - - value: v - secrets: [] - outputs: {} -""") - - def test_missing_variable_value_raises(self): - with pytest.raises(ValueError, match="value"): - self._parse_yaml_tests("""\ - - doc: cred test - credentials: - - name: svc - variables: - - name: V - secrets: [] - outputs: {} -""") + path = functional_test_tool_path("simple_constructs.xml") + tool_source = get_tool_source(path) + tests = tool_source.parse_tests_to_dict()["tests"] + for test in tests: + assert test.get("credentials") is None From b2f8e9c49d206550334f8460dd502d7e8922a191 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 18 Mar 2026 13:37:08 +0100 Subject: [PATCH 25/26] fixup! Use Pydantic TypeAdapter for credential validation in XML and YAML parsers --- lib/galaxy/tool_util/parser/xml.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index b2816a8943f..a66fdd29296 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -1106,12 +1106,8 @@ def __parse_credentials_elems(test_elem): """ raw_list = [] for cred_elem in test_elem.findall("credentials"): - variables = [ - {"name": v.get("name"), "value": v.get("value")} for v in cred_elem.findall("variable") - ] - secrets = [ - {"name": s.get("name"), "value": s.get("value")} for s in cred_elem.findall("secret") - ] + variables = [{"name": v.get("name"), "value": v.get("value")} for v in cred_elem.findall("variable")] + secrets = [{"name": s.get("name"), "value": s.get("value")} for s in cred_elem.findall("secret")] raw: dict = {"name": cred_elem.get("name"), "variables": variables, "secrets": secrets} version = cred_elem.get("version") if version is not None: From d93571bc850e780e87681d5792d8a6d576a13af1 Mon Sep 17 00:00:00 2001 From: Arash Date: Wed, 18 Mar 2026 14:56:48 +0100 Subject: [PATCH 26/26] Serialize credentials_context to JSON in GalaxyInteractorApi --- lib/galaxy/tool_util/verify/interactor.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index 7c7a9947591..0a9de6010d6 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -866,7 +866,7 @@ class GalaxyInteractorApi: extra_data: Dict[str, Any] = {} created_credentials, credentials_context = self._create_test_credentials(testdef) if credentials_context is not None: - extra_data["credentials_context"] = credentials_context + extra_data["credentials_context"] = dumps(credentials_context) for _ in range(DEFAULT_TOOL_TEST_WAIT): submit_response = self.__submit_tool( @@ -1137,15 +1137,6 @@ class GalaxyInteractorApi: ): extra_data = extra_data or {} if use_legacy_api: - if "credentials_context" in extra_data: - data = dict( - history_id=history_id, - tool_id=tool_id, - inputs=tool_input, - tool_version=tool_version, - **extra_data, - ) - return self._post("tools", data=data, json=True) data = dict( history_id=history_id, tool_id=tool_id,