Skip to content

Commit

Permalink
Merge pull request #17329 from jmchilton/device_source
Browse files Browse the repository at this point in the history
API endpoint that allows "changing" the objectstore for "safe" scenarios.
  • Loading branch information
martenson authored Feb 10, 2024
2 parents 47dffaf + 3cb25b7 commit cc94611
Show file tree
Hide file tree
Showing 15 changed files with 460 additions and 10 deletions.
46 changes: 46 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export interface paths {
*/
get: operations["get_metrics_api_datasets__dataset_id__metrics_get"];
};
"/api/datasets/{dataset_id}/object_store_id": {
/** Update an object store ID for a dataset you own. */
put: operations["datasets__update_object_store_id"];
};
"/api/datasets/{dataset_id}/parameters_display": {
/**
* Resolve parameters as a list for nested display.
Expand Down Expand Up @@ -2911,6 +2915,8 @@ export interface components {
badges: components["schemas"]["BadgeDict"][];
/** Description */
description?: string | null;
/** Device */
device?: string | null;
/** Name */
name?: string | null;
/** Object Store Id */
Expand Down Expand Up @@ -10550,6 +10556,14 @@ export interface components {
*/
synopsis?: string | null;
};
/** UpdateObjectStoreIdPayload */
UpdateObjectStoreIdPayload: {
/**
* Object Store Id
* @description Object store ID to update to, it must be an object store with the same device ID as the target dataset currently.
*/
object_store_id: string;
};
/** UpdateQuotaParams */
UpdateQuotaParams: {
/**
Expand Down Expand Up @@ -12167,6 +12181,38 @@ export interface operations {
};
};
};
datasets__update_object_store_id: {
/** Update an object store ID for a dataset you own. */
parameters: {
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
header?: {
"run-as"?: string | null;
};
/** @description The ID of the History Dataset. */
path: {
dataset_id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateObjectStoreIdPayload"];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": Record<string, never>;
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
resolve_parameters_display_api_datasets__dataset_id__parameters_display_get: {
/**
* Resolve parameters as a list for nested display.
Expand Down
4 changes: 2 additions & 2 deletions lib/galaxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ def __init__(self, fsmon=False, **kwargs) -> None:
self._register_singleton(GalaxyModelMapping, self.model)
self._register_singleton(galaxy_scoped_session, self.model.context)
self._register_singleton(install_model_scoped_session, self.install_model.context)
# Load quota management.
self.quota_agent = self._register_singleton(QuotaAgent, get_quota_agent(self.config, self.model))

def configure_fluent_log(self):
if self.config.fluent_log:
Expand Down Expand Up @@ -573,8 +575,6 @@ def __init__(self, configure_logging=True, use_converters=True, use_display_appl
self.host_security_agent = galaxy.model.security.HostAgent(
model=self.security_agent.model, permitted_actions=self.security_agent.permitted_actions
)
# Load quota management.
self.quota_agent = self._register_singleton(QuotaAgent, get_quota_agent(self.config, self.model))

# We need the datatype registry for running certain tasks that modify HDAs, and to build the registry we need
# to setup the installed repositories ... this is not ideal
Expand Down
32 changes: 32 additions & 0 deletions lib/galaxy/managers/datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def __init__(self, app: MinimalManagerApp):
self.permissions = DatasetRBACPermissions(app)
# needed for admin test
self.user_manager = users.UserManager(app)
self.quota_agent = app.quota_agent
self.security_agent = app.model.security_agent

def create(self, manage_roles=None, access_roles=None, flush=True, **kwargs):
"""
Expand Down Expand Up @@ -144,6 +146,36 @@ def has_access_permission(self, dataset, user):
roles = user.all_roles_exploiting_cache() if user else []
return self.app.security_agent.can_access_dataset(roles, dataset)

def update_object_store_id(self, trans, dataset, object_store_id: str):
device_source_map = self.app.object_store.get_device_source_map()
old_object_store_id = dataset.object_store_id
new_object_store_id = object_store_id
if old_object_store_id == new_object_store_id:
return None
old_device_id = device_source_map.get_device_id(old_object_store_id)
new_device_id = device_source_map.get_device_id(new_object_store_id)
if old_device_id != new_device_id:
raise exceptions.RequestParameterInvalidException(
"Cannot swap object store IDs for object stores that don't share a device ID."
)

if not self.security_agent.can_change_object_store_id(trans.user, dataset):
# TODO: probably want separate exceptions for doesn't own the dataset and dataset
# has been shared.
raise exceptions.InsufficientPermissionsException("Cannot change dataset permissions...")

quota_source_map = self.app.object_store.get_quota_source_map()
if quota_source_map:
old_label = quota_source_map.get_quota_source_label(old_object_store_id)
new_label = quota_source_map.get_quota_source_label(new_object_store_id)
if old_label != new_label:
self.quota_agent.relabel_quota_for_dataset(dataset, old_label, new_label)
sa_session = self.app.model.context
with transaction(sa_session):
dataset.object_store_id = new_object_store_id
sa_session.add(dataset)
sa_session.commit()

def compute_hash(self, request: ComputeDatasetHashTaskRequest):
# For files in extra_files_path
dataset = self.by_id(request.dataset_id)
Expand Down
10 changes: 10 additions & 0 deletions lib/galaxy/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4026,6 +4026,16 @@ def quota_source_info(self):
quota_source_map = self.object_store.get_quota_source_map()
return quota_source_map.get_quota_source_info(object_store_id)

@property
def device_source_label(self):
return self.device_source_info.label

@property
def device_source_info(self):
object_store_id = self.object_store_id
device_source_map = self.object_store.get_quota_source_map()
return device_source_map.get_device_source_info(object_store_id)

def set_file_name(self, filename):
if not filename:
self.external_filename = None
Expand Down
19 changes: 19 additions & 0 deletions lib/galaxy/model/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
select,
)
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import text

import galaxy.model
from galaxy.model import (
Expand Down Expand Up @@ -634,6 +635,24 @@ def can_modify_library_item(self, roles, item):
def can_manage_library_item(self, roles, item):
return self.allow_action(roles, self.permitted_actions.LIBRARY_MANAGE, item)

def can_change_object_store_id(self, user, dataset):
# prevent update if dataset shared with anyone but the current user
# private object stores would prevent this but if something has been
# kept private in a sharable object store still allow the swap
if dataset.library_associations:
return False
else:
query = text(
"""
SELECT COUNT(*)
FROM history
INNER JOIN
history_dataset_association on history_dataset_association.history_id = history.id
WHERE history.user_id != :user_id and history_dataset_association.dataset_id = :dataset_id
"""
).bindparams(dataset_id=dataset.id, user_id=user.id)
return self.sa_session.scalars(query).first() == 0

def get_item_actions(self, action, item):
# item must be one of: Dataset, Library, LibraryFolder, LibraryDataset, LibraryDatasetDatasetAssociation
# SM: Accessing item.actions emits a query to Library_Dataset_Permissions
Expand Down
51 changes: 49 additions & 2 deletions lib/galaxy/objectstore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
DEFAULT_PRIVATE = False
DEFAULT_QUOTA_SOURCE = None # Just track quota right on user object in Galaxy.
DEFAULT_QUOTA_ENABLED = True # enable quota tracking in object stores by default

DEFAULT_DEVICE_ID = None
log = logging.getLogger(__name__)


Expand Down Expand Up @@ -328,6 +328,10 @@ def to_dict(self) -> Dict[str, Any]:
def get_quota_source_map(self):
"""Return QuotaSourceMap describing mapping of object store IDs to quota sources."""

@abc.abstractmethod
def get_device_source_map(self) -> "DeviceSourceMap":
"""Return DeviceSourceMap describing mapping of object store IDs to device sources."""


class BaseObjectStore(ObjectStore):
store_by: str
Expand Down Expand Up @@ -490,6 +494,9 @@ def get_quota_source_map(self):
# I'd rather keep this abstract... but register_singleton wants it to be instantiable...
raise NotImplementedError()

def get_device_source_map(self):
return DeviceSourceMap()


class ConcreteObjectStore(BaseObjectStore):
"""Subclass of ObjectStore for stores that don't delegate (non-nested).
Expand All @@ -500,6 +507,7 @@ class ConcreteObjectStore(BaseObjectStore):
"""

badges: List[StoredBadgeDict]
device_id: Optional[str] = None

def __init__(self, config, config_dict=None, **kwargs):
"""
Expand Down Expand Up @@ -527,6 +535,7 @@ def __init__(self, config, config_dict=None, **kwargs):
quota_config = config_dict.get("quota", {})
self.quota_source = quota_config.get("source", DEFAULT_QUOTA_SOURCE)
self.quota_enabled = quota_config.get("enabled", DEFAULT_QUOTA_ENABLED)
self.device_id = config_dict.get("device", None)
self.badges = read_badges(config_dict)

def to_dict(self):
Expand All @@ -540,6 +549,7 @@ def to_dict(self):
"enabled": self.quota_enabled,
}
rval["badges"] = self._get_concrete_store_badges(None)
rval["device"] = self.device_id
return rval

def to_model(self, object_store_id: str) -> "ConcreteObjectStoreModel":
Expand All @@ -550,6 +560,7 @@ def to_model(self, object_store_id: str) -> "ConcreteObjectStoreModel":
description=self.description,
quota=QuotaModel(source=self.quota_source, enabled=self.quota_enabled),
badges=self._get_concrete_store_badges(None),
device=self.device_id,
)

def _get_concrete_store_badges(self, obj) -> List[BadgeDict]:
Expand Down Expand Up @@ -586,6 +597,9 @@ def get_quota_source_map(self):
)
return quota_source_map

def get_device_source_map(self) -> "DeviceSourceMap":
return DeviceSourceMap(self.device_id)


class DiskObjectStore(ConcreteObjectStore):
"""
Expand Down Expand Up @@ -636,6 +650,8 @@ def parse_xml(clazz, config_xml):
name = config_xml.attrib.get("name", None)
if name is not None:
config_dict["name"] = name
device = config_xml.attrib.get("device", None)
config_dict["device"] = device
for e in config_xml:
if e.tag == "quota":
config_dict["quota"] = {
Expand Down Expand Up @@ -1033,7 +1049,7 @@ def __init__(self, config, config_dict, fsmon=False):
"""
super().__init__(config, config_dict)
self._quota_source_map = None

self._device_source_map = None
self.backends = {}
self.weighted_backend_ids = []
self.original_weighted_backend_ids = []
Expand Down Expand Up @@ -1205,6 +1221,13 @@ def get_quota_source_map(self):
self._quota_source_map = quota_source_map
return self._quota_source_map

def get_device_source_map(self) -> "DeviceSourceMap":
if self._device_source_map is None:
device_source_map = DeviceSourceMap()
self._merge_device_source_map(device_source_map, self)
self._device_source_map = device_source_map
return self._device_source_map

@classmethod
def _merge_quota_source_map(clz, quota_source_map, object_store):
for backend_id, backend in object_store.backends.items():
Expand All @@ -1213,6 +1236,14 @@ def _merge_quota_source_map(clz, quota_source_map, object_store):
else:
quota_source_map.backends[backend_id] = backend.get_quota_source_map()

@classmethod
def _merge_device_source_map(clz, device_source_map: "DeviceSourceMap", object_store):
for backend_id, backend in object_store.backends.items():
if isinstance(backend, DistributedObjectStore):
clz._merge_device_source_map(device_source_map, backend)
else:
device_source_map.backends[backend_id] = backend.get_device_source_map()

def __get_store_id_for(self, obj, **kwargs):
if obj.object_store_id is not None:
if obj.object_store_id in self.backends:
Expand Down Expand Up @@ -1361,6 +1392,7 @@ class ConcreteObjectStoreModel(BaseModel):
description: Optional[str] = None
quota: QuotaModel
badges: List[BadgeDict]
device: Optional[str] = None


def type_to_object_store_class(store: str, fsmon: bool = False) -> Tuple[Type[BaseObjectStore], Dict[str, Any]]:
Expand Down Expand Up @@ -1503,6 +1535,21 @@ class QuotaSourceInfo(NamedTuple):
use: bool


class DeviceSourceMap:
def __init__(self, device_id=DEFAULT_DEVICE_ID):
self.default_device_id = device_id
self.backends = {}

def get_device_id(self, object_store_id: str) -> Optional[str]:
if object_store_id in self.backends:
device_map = self.backends.get(object_store_id)
if device_map:
print(device_map)
return device_map.get_device_id(object_store_id)

return self.default_device_id


class QuotaSourceMap:
def __init__(self, source=DEFAULT_QUOTA_SOURCE, enabled=DEFAULT_QUOTA_ENABLED):
self.default_quota_source = source
Expand Down
Loading

0 comments on commit cc94611

Please sign in to comment.