Add OneDrive file source

This commit is contained in:
PlushZ
2026-04-05 12:04:50 +10:00
parent 2e12cd080c
commit 5341612687
4 changed files with 349 additions and 1 deletions
+48
View File
@@ -626,6 +626,54 @@ a production Galaxy instance but Dropbox operates on a different scale.
For more information on what Dropbox considers a "development" app versus a "production"
app - checkout the [Dropbox documentation](https://www.dropbox.com/developers/reference/developer-guide#production-approval).
#### OneDrive
Once you have OAuth 2.0 client credentials from Microsoft Entra (called `oauth2_client_id`
and `oauth2_client_secret` here), the following configuration can be used to enable
OneDrive for your Galaxy instance.
```{literalinclude} ../../../lib/galaxy/files/templates/examples/production_onedrive.yml
:language: yaml
```
To use this template, make the credentials available to Galaxy's web and job handler
processes using the environment variables `GALAXY_ONEDRIVE_CLIENT_ID` and
`GALAXY_ONEDRIVE_CLIENT_SECRET`. Jobs themselves do not need these values and should
not receive them.
The current OneDrive implementation targets Microsoft Graph special/approot and uses the
`Files.ReadWrite.AppFolder` delegated scope. This means Galaxy can browse, download,
upload, and create folders inside the application's dedicated OneDrive app folder
(`Apps/<Application Name>`). Supporting full-drive access would require code changes
in addition to broader scopes such as `Files.ReadWrite` or `Files.ReadWrite.All` and
configuring yml template with `oauth2_scope: "offline_access Files.ReadWrite.All"`
To configure Microsoft Entra for this file source:
1. Create an app registration for your Galaxy instance in Microsoft Entra.
2. Add a web redirect URI pointing to your Galaxy instance with `oauth2_callback`
appended to the root URL. For local development, this is typically
`http://localhost:8080/oauth2_callback`.
3. Configure supported account types for the accounts you intend to support. If you
want to support both work/school accounts and personal Microsoft accounts, set
the audience to "Any Entra ID tenant + Personal Microsoft accounts".
4. Add delegated Microsoft Graph permissions: `Files.ReadWrite.AppFolder`,
`offline_access` (to ensure Galaxy can obtain refresh tokens for long-lived access).
5. Create a client secret and provide its value to Galaxy via
`GALAXY_ONEDRIVE_CLIENT_SECRET` (remember, this value can be seen only once after creation).
6. Go to Overview, find "Application (client) ID" and provide its value to Galaxy
via `GALAXY_ONEDRIVE_CLIENT_ID`
The OAuth2 endpoints Galaxy uses for this template are the Microsoft identity platform's
v2 endpoints under the `common` tenant. If you register an application that also supports
personal Microsoft accounts, ensure the manifest is consistent with that audience. In
particular, the app manifest should use `AzureADandPersonalMicrosoftAccount` as the
`signInAudience` and `2` as the `requestedAccessTokenVersion`.
This first implementation currently uses Microsoft Graph's simple upload endpoint and
does not yet implement resumable uploads for very large files, server-side pagination,
or server-side search/sorting.
## Playing Nicer with Ansible
Many large instances of Galaxy are configured with Ansible and much of the existing administrator
+14
View File
@@ -81,7 +81,14 @@ class OneDriveFilesSource(
return f"{api_base}/special/approot"
def _children_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str:
<<<<<<< HEAD
return f"{self._item_url(config, path)}/children"
=======
item_url = self._item_url(config, path)
if path.strip("/"):
return f"{item_url}:/children"
return f"{item_url}/children"
>>>>>>> 4132e5d794 (Add OneDrive file source)
def _content_url(self, config: OneDriveFilesSourceConfiguration, path: str) -> str:
return f"{self._item_url(config, path)}:/content" if path.strip("/") else f"{self._item_url(config, path)}/content"
@@ -189,7 +196,14 @@ class OneDriveFilesSource(
def _create_entry(
self, entry_data: EntryData, context: FilesSourceRuntimeContext[OneDriveFilesSourceConfiguration]
) -> Entry:
<<<<<<< HEAD
parent_path = getattr(entry_data, "path", "/")
=======
parent_path = getattr(entry_data, "path", None)
if parent_path is None:
target = getattr(entry_data, "target", "/")
parent_path = self.to_relative_path(target)
>>>>>>> 4132e5d794 (Add OneDrive file source)
payload = {
"name": entry_data.name,
"folder": {},
@@ -23,7 +23,10 @@ from galaxy.exceptions import (
RequestParameterMissingException,
)
from galaxy.files import FileSourcesUserContext
from galaxy.files.sources import dropbox
from galaxy.files.sources import (
dropbox,
onedrive,
)
from galaxy.files.templates import ConfiguredFileSourceTemplates
from galaxy.files.templates.examples import get_example
from galaxy.managers._config_templates import prepare_environment_from_root
@@ -334,6 +337,90 @@ class TestFileSourcesTestCase(BaseTestCase):
assert not status.connection.is_not_ok
assert pyfilesystem_fs_init_kwd["access_token"] == "my_test_access_token"
def test_onedrive_oauth2_flow(self, tmp_path, monkeypatch):
json = {
"refresh_token": "my_test_refresh_token",
}
def mock_get_token_from_code_raw(
code,
client_pair,
config,
redirect_uri,
):
return MockResponse(json)
monkeypatch.setattr(config_templates, "get_token_from_code_raw", mock_get_token_from_code_raw)
self._init_onedrive_env(tmp_path, monkeypatch)
authorize_url = self.manager.template_oauth2(self.trans, "onedrive", 0).authorize_url
from urllib.parse import (
parse_qs,
urlparse,
)
parse_result = urlparse(authorize_url)
assert parse_result.hostname == "login.microsoftonline.com"
assert parse_result.path == "/common/oauth2/v2.0/authorize"
query_params = parse_qs(parse_result.query)
assert query_params["scope"][0] == "offline_access Files.ReadWrite.AppFolder"
assert "state" in query_params
state_param = query_params["state"]
state = OAuth2State.decode(state_param[0])
assert state.route == "file_source_instances/onedrive/0"
redirect_url = self.manager.handle_authorization_code(
self.trans,
"moocow",
state,
)
parse_result = urlparse(redirect_url)
query_params = parse_qs(parse_result.query)
assert "uuid" in query_params
uuid = query_params["uuid"][0]
user_vault = self.trans.user_vault
config_secret_key = UserFileSource.vault_key_from_uuid(uuid, "_oauth2_refresh_token", None)
assert user_vault.read_secret(config_secret_key)
def test_onedrive_oauth2_access_token_injection_during_verify(self, tmp_path, monkeypatch):
self._init_onedrive_env(tmp_path, monkeypatch)
uuid = uuid4().hex
user_vault = self.trans.user_vault
config_secret_key = UserFileSource.vault_key_from_uuid(uuid, "_oauth2_refresh_token", None)
user_vault.write_secret(config_secret_key, "test_refresh_token")
create_payload = CreateInstancePayload(
name=SIMPLE_FILE_SOURCE_NAME,
description=SIMPLE_FILE_SOURCE_DESCRIPTION,
template_id="onedrive",
template_version=0,
variables={},
secrets={},
uuid=uuid,
)
self._create_instance(create_payload)
json = {
"access_token": "my_test_access_token",
}
observed_headers = {}
def mock_get_token_from_refresh_raw(refresh_token, client_pair, config):
return MockResponse(json)
def mock_request(method, url, headers=None, timeout=None, **kwargs):
observed_headers.update(headers or {})
return OneDriveMockResponse(json_data={"value": []})
monkeypatch.setattr(config_templates, "get_token_from_refresh_raw", mock_get_token_from_refresh_raw)
monkeypatch.setattr(onedrive.requests, "request", mock_request)
status = self.manager.plugin_status(self.trans, create_payload)
assert status.oauth2_access_token_generation
assert not status.oauth2_access_token_generation.is_not_ok
assert status.connection
assert not status.connection.is_not_ok
assert observed_headers["Authorization"] == "Bearer my_test_access_token"
def test_report_oauth2_access_token_generation_failure(self, tmp_path, monkeypatch):
self._init_dropbox_env(tmp_path, monkeypatch)
@@ -855,6 +942,13 @@ class TestFileSourcesTestCase(BaseTestCase):
monkeypatch.setenv("GALAXY_DROPBOX_APP_CLIENT_ID", "mock_client_id")
monkeypatch.setenv("GALAXY_DROPBOX_APP_CLIENT_SECRET", "mock_client_secret")
def _init_onedrive_env(self, tmp_path, monkeypatch):
self.init_user_in_database()
self._init_managers(tmp_path, safe_load(get_example("production_onedrive.yml")))
monkeypatch.setenv("GALAXY_ONEDRIVE_CLIENT_ID", "mock_client_id")
monkeypatch.setenv("GALAXY_ONEDRIVE_CLIENT_SECRET", "mock_client_secret")
def _create_user_file_source(self, template_id="home_directory") -> UserFileSourceModel:
create_payload = CreateInstancePayload(
name=SIMPLE_FILE_SOURCE_NAME,
@@ -943,3 +1037,18 @@ class MockExceptionResponse:
def raise_for_status(self):
raise HTTPError(self._exception_msg, self._exception_msg, response=None)
class OneDriveMockResponse:
def __init__(self, status_code=200, json_data=None, text=""):
self.status_code = status_code
self._json_data = json_data or {}
self.text = text
@property
def ok(self):
return 200 <= self.status_code < 300
def json(self):
return self._json_data
+177
View File
@@ -0,0 +1,177 @@
from pathlib import Path
import pytest
from galaxy.exceptions import (
AuthenticationRequired,
MessageException,
)
from galaxy.files.models import (
EntryData,
FileSourcePluginsConfig,
)
from galaxy.files.sources.onedrive import OneDriveFilesSource
class MockResponse:
def __init__(self, status_code=200, json_data=None, text="", content_chunks=None):
self.status_code = status_code
self._json_data = json_data or {}
self.text = text
self._content_chunks = content_chunks or []
@property
def ok(self):
return 200 <= self.status_code < 300
def json(self):
return self._json_data
def iter_content(self, chunk_size=1024 * 1024):
yield from self._content_chunks
def _plugin():
template = OneDriveFilesSource.build_template_config(
id="test1",
type="onedrive",
label="OneDrive",
doc="Test OneDrive file source",
writable=True,
access_token="test_access_token",
file_sources_config=FileSourcePluginsConfig(),
)
return OneDriveFilesSource(template)
def test_list_root(monkeypatch):
plugin = _plugin()
observed = {}
def mock_request(method, url, headers=None, timeout=None, **kwargs):
observed["method"] = method
observed["url"] = url
observed["headers"] = headers
return MockResponse(
json_data={
"value": [
{"name": "subdir", "folder": {}, "size": 0},
{"name": "a.txt", "size": 12, "lastModifiedDateTime": "2026-04-05T12:00:00Z"},
]
}
)
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request)
entries, count = plugin.list("/")
assert count == 2
assert observed["method"] == "GET"
assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot/children"
assert observed["headers"]["Authorization"] == "Bearer test_access_token"
assert entries[0].path == "/subdir"
assert entries[0].uri == "gxfiles://test1/subdir"
assert entries[1].path == "/a.txt"
assert entries[1].uri == "gxfiles://test1/a.txt"
def test_list_nested_folder(monkeypatch):
plugin = _plugin()
observed = {}
def mock_request(method, url, headers=None, timeout=None, **kwargs):
observed["url"] = url
return MockResponse(json_data={"value": [{"name": "b.txt", "size": 4}]})
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request)
entries, count = plugin.list("/level1/level 2")
assert count == 1
assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/level1/level%202:/children"
assert entries[0].path == "/level1/level 2/b.txt"
def test_realize_to_downloads_content(monkeypatch, tmp_path: Path):
plugin = _plugin()
target = tmp_path / "downloaded.txt"
def mock_request(method, url, headers=None, timeout=None, stream=None, **kwargs):
assert method == "GET"
assert stream is True
assert url == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/dir/file.txt:/content"
return MockResponse(content_chunks=[b"hello ", b"world"])
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request)
plugin.realize_to("/dir/file.txt", str(target))
assert target.read_text("utf-8") == "hello world"
def test_write_from_uploads_content(monkeypatch, tmp_path: Path):
plugin = _plugin()
source = tmp_path / "upload.txt"
source.write_text("payload", "utf-8")
observed = {}
def mock_put(url, headers=None, data=None, timeout=None):
observed["url"] = url
observed["headers"] = headers
observed["data"] = data.read()
return MockResponse(json_data={"id": "item1"})
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.put", mock_put)
actual_uri = plugin.write_from("/nested/upload.txt", str(source))
assert actual_uri == "gxfiles://test1/nested/upload.txt"
assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/nested/upload.txt:/content"
assert observed["headers"]["Authorization"] == "Bearer test_access_token"
assert observed["data"] == b"payload"
def test_create_entry_creates_directory(monkeypatch):
plugin = _plugin()
observed = {}
def mock_post(url, json=None, headers=None, timeout=None):
observed["url"] = url
observed["json"] = json
observed["headers"] = headers
return MockResponse(json_data={"name": "newdir", "webUrl": "https://example.org/newdir"})
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.post", mock_post)
entry = plugin.create_entry(EntryData(name="newdir", target="gxfiles://test1/parent"))
assert observed["url"] == "https://graph.microsoft.com/v1.0/me/drive/special/approot:/parent:/children"
assert observed["json"]["name"] == "newdir"
assert entry.uri == "gxfiles://test1/parent/newdir"
assert entry.external_link == "https://example.org/newdir"
def test_list_authentication_error(monkeypatch):
plugin = _plugin()
def mock_request(method, url, headers=None, timeout=None, **kwargs):
return MockResponse(status_code=401, json_data={"error": {"message": "Unauthorized"}})
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.request", mock_request)
with pytest.raises(AuthenticationRequired):
plugin.list("/")
def test_write_reports_api_error(monkeypatch, tmp_path: Path):
plugin = _plugin()
source = tmp_path / "upload.txt"
source.write_text("payload", "utf-8")
def mock_put(url, headers=None, data=None, timeout=None):
return MockResponse(status_code=400, json_data={"error": {"message": "Bad request"}})
monkeypatch.setattr("galaxy.files.sources.onedrive.requests.put", mock_put)
with pytest.raises(MessageException, match="Bad request"):
plugin.write_from("/upload.txt", str(source))