From 9f210ba2549ec44a99e629115d38133262a7c161 Mon Sep 17 00:00:00 2001 From: Kory Draughn Date: Wed, 19 Aug 2026 15:21:48 -0400 Subject: [PATCH 1/4] [43] Sort iRODS error codes for testing --- test/irods_error_codes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/irods_error_codes.py b/test/irods_error_codes.py index b0cb47c8..e5a189ea 100644 --- a/test/irods_error_codes.py +++ b/test/irods_error_codes.py @@ -1,5 +1,7 @@ CAT_HOSTNAME_INVALID = -855000 CAT_INVALID_ARGUMENT = -816000 +CAT_INVALID_GROUP = -829000 +CAT_INVALID_RESOURCE = -831000 CAT_NAME_EXISTS_AS_COLLECTION = -833000 CAT_NO_ACCESS_PERMISSION = -818000 CAT_NO_CHECKSUM_FOR_REPLICA = -862000 @@ -20,5 +22,3 @@ SYS_RESC_DOES_NOT_EXIST = -78000 SYS_RESC_QUOTA_EXCEEDED = -110000 USER_INVALID_REPLICA_INPUT = -403000 -CAT_INVALID_GROUP = -829000 -CAT_INVALID_RESOURCE = -831000 From e2c691e0f13511e33902ba8a3720d1e042a4a989 Mon Sep 17 00:00:00 2001 From: Kory Draughn Date: Wed, 19 Aug 2026 13:54:38 -0400 Subject: [PATCH 2/4] [509] Implement support for logical quotas This commit introduces a new endpoint, /logical-quotas, which supports the following operations: - stat - set_quota - recalculate --- API.md | 125 +++++++ CMakeLists.txt | 1 + core/src/main.cpp | 1 + endpoints/CMakeLists.txt | 1 + endpoints/logical_quotas/CMakeLists.txt | 31 ++ endpoints/logical_quotas/src/main.cpp | 321 ++++++++++++++++++ .../irods/private/http_api/handlers.hpp | 2 + test/config.py | 7 +- test/irods_error_codes.py | 1 + test/test_irods_http_api.py | 246 ++++++++++++++ 10 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 endpoints/logical_quotas/CMakeLists.txt create mode 100644 endpoints/logical_quotas/src/main.cpp diff --git a/API.md b/API.md index 3f2463d7..00a9963d 100644 --- a/API.md +++ b/API.md @@ -1611,6 +1611,131 @@ If an HTTP status code of 200 is returned, the body of the response will contain If there was an error, expect an HTTP status code in either the 4XX or 5XX range. +## Logical Quota Operations + +### stat + +Returns quota information for one or more collections. + +> [!WARNING] +> This operation requires rodsadmin level privileges. + +#### Request + +HTTP Method: GET + +```bash +curl http://localhost:/irods-http-api//logical-quotas \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'op=stat' \ + --data-urlencode 'lpath=' \ # Absolute logical path to a collection. Optional. + -G +``` + +If `lpath` points to a valid collection, the HTTP API will return quota information for that collection and all its ancestors. + +If a target collection is not provided via the `lpath` parameter, the HTTP API will return quota information for all collections in the zone. + +#### Response + +If an HTTP status code of 200 is returned, the body of the response will contain JSON. Its structure is shown below. + +```js +{ + "irods_response": { + "status_code": 0 + "status_message": "string" // Optional + }, + "quotas": [ + { + "collection": "string", + "max_bytes": 0, + "max_objects": 0, + "over_bytes": 0, + "over_objects": 0 + }, + + // Additional entries ... + ] +} +``` + +If there was an error, expect an HTTP status code in either the 4XX or 5XX range. + +### set_quota + +Sets the quota for a collection. + +> [!WARNING] +> This operation requires rodsadmin level privileges. + +#### Request + +HTTP Method: POST + +```bash +curl http://localhost:/irods-http-api//logical-quotas \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'op=set_quota' \ + --data-urlencode 'lpath=' \ # Absolute logical path to the collection which the quota applies. + --data-urlencode 'maximum-bytes=' \ # The number of bytes which will serves as the byte limit. Optional. + --data-urlencode 'maximum-objects=' # The number of objects which will serve as the limit. Optional. +``` + +`maximum-bytes` and/or `maximum-objects` MUST be specified for this operation to succeed. + +To remove a quota, set `maximum-bytes` and `maximum-objects` to 0. + +#### Response + +If an HTTP status code of 200 is returned, the body of the response will contain JSON. Its structure is shown below. + +```js +{ + "irods_response": { + "status_code": 0 + "status_message": "string" // Optional + } +} +``` + +If there was an error, expect an HTTP status code in either the 4XX or 5XX range. + +### recalculate + +Calculate or update quota information based on the state of the catalog. + +> [!WARNING] +> This operation requires rodsadmin level privileges. + +> [!IMPORTANT] +> iRODS does not automatically update quota information as data changes. This operation is provided to give administrators control over how frequently totals are calculated. + +#### Request + +HTTP Method: POST + +```bash +curl http://localhost:/irods-http-api//logical-quotas \ + -H 'Authorization: Bearer ' \ + --data-urlencode 'op=recalculate' +``` + +#### Response + +If an HTTP status code of 200 is returned, the body of the response will contain JSON. Its structure is shown below. + +```js +{ + "irods_response": { + "status_code": 0 + "status_message": "string" // Optional + } +} +``` + +If there was an error, expect an HTTP status code in either the 4XX or 5XX range. + ## Resource Operations ### create diff --git a/CMakeLists.txt b/CMakeLists.txt index 90770ad3..f761817c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -162,6 +162,7 @@ target_link_objects( #irods_http_api_endpoint_config irods_http_api_endpoint_data_objects irods_http_api_endpoint_information + irods_http_api_endpoint_logical_quotas irods_http_api_endpoint_physical_quotas irods_http_api_endpoint_query irods_http_api_endpoint_resources diff --git a/core/src/main.cpp b/core/src/main.cpp index e7fb31cd..721331c5 100644 --- a/core/src/main.cpp +++ b/core/src/main.cpp @@ -101,6 +101,7 @@ const irods::http::request_handler_map_type req_handlers{ //{IRODS_HTTP_API_BASE_URL "/config", irods::http::handler::configuration}, {IRODS_HTTP_API_BASE_URL "/data-objects", irods::http::handler::data_objects}, {IRODS_HTTP_API_BASE_URL "/info", irods::http::handler::information}, + {IRODS_HTTP_API_BASE_URL "/logical-quotas", irods::http::handler::logical_quotas}, {IRODS_HTTP_API_BASE_URL "/physical-quotas", irods::http::handler::physical_quotas}, {IRODS_HTTP_API_BASE_URL "/query", irods::http::handler::query}, {IRODS_HTTP_API_BASE_URL "/resources", irods::http::handler::resources}, diff --git a/endpoints/CMakeLists.txt b/endpoints/CMakeLists.txt index 25d06ea9..defcc582 100644 --- a/endpoints/CMakeLists.txt +++ b/endpoints/CMakeLists.txt @@ -5,6 +5,7 @@ add_subdirectory(collections) #add_subdirectory(config) add_subdirectory(data_objects) add_subdirectory(information) +add_subdirectory(logical_quotas) add_subdirectory(physical_quotas) add_subdirectory(query) add_subdirectory(resources) diff --git a/endpoints/logical_quotas/CMakeLists.txt b/endpoints/logical_quotas/CMakeLists.txt new file mode 100644 index 00000000..a2b60c25 --- /dev/null +++ b/endpoints/logical_quotas/CMakeLists.txt @@ -0,0 +1,31 @@ +add_library( + irods_http_api_endpoint_logical_quotas + OBJECT + "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp" +) + +target_compile_definitions( + irods_http_api_endpoint_logical_quotas + PRIVATE + ${IRODS_COMPILE_DEFINITIONS} + ${IRODS_COMPILE_DEFINITIONS_PRIVATE} +) + +target_link_libraries( + irods_http_api_endpoint_logical_quotas + PRIVATE + irods_client + CURL::libcurl + nlohmann_json::nlohmann_json +) + +target_include_directories( + irods_http_api_endpoint_logical_quotas + PRIVATE + "${IRODS_HTTP_PROJECT_SOURCE_DIR}/core/include" + "${IRODS_HTTP_PROJECT_BINARY_DIR}/core/include" + "${IRODS_HTTP_PROJECT_SOURCE_DIR}/endpoints/shared/include" + "${IRODS_EXTERNALS_FULLPATH_BOOST}/include" +) + +set_target_properties(irods_http_api_endpoint_logical_quotas PROPERTIES EXCLUDE_FROM_ALL TRUE) diff --git a/endpoints/logical_quotas/src/main.cpp b/endpoints/logical_quotas/src/main.cpp new file mode 100644 index 00000000..4b973dad --- /dev/null +++ b/endpoints/logical_quotas/src/main.cpp @@ -0,0 +1,321 @@ +#include "irods/private/http_api/handlers.hpp" + +#include "irods/private/http_api/common.hpp" +#include "irods/private/http_api/globals.hpp" +#include "irods/private/http_api/log.hpp" +#include "irods/private/http_api/session.hpp" +#include "irods/private/http_api/version.hpp" + +#include +#include +#include +#include +#include + +#ifdef IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS +# include +#endif // IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +namespace beast = boost::beast; // from +namespace http = beast::http; // from + +namespace logging = irods::http::log; + +using json = nlohmann::json; +// clang-format on + +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(name) \ + auto name( \ + irods::http::session_pointer_type _sess_ptr, \ + irods::http::request_type& _req, \ + irods::http::query_arguments_type& _args) \ + ->void + +namespace +{ + // + // Handler function prototypes + // + + IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(op_stat); + + IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(op_set_quota); + IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(op_recalculate); + + // + // Operation to Handler mappings + // + + // clang-format off + const std::unordered_map handlers_for_get{ + {"stat", op_stat} + }; + + const std::unordered_map handlers_for_post{ + {"set_quota", op_set_quota}, + {"recalculate", op_recalculate} + }; + // clang-format on +} // anonymous namespace + +namespace irods::http::handler +{ + // NOLINTNEXTLINE(performance-unnecessary-value-param) + IRODS_HTTP_API_ENDPOINT_ENTRY_FUNCTION_SIGNATURE(logical_quotas) + { + // NOLINTNEXTLINE(performance-unnecessary-value-param) + execute_operation(_sess_ptr, _req, handlers_for_get, handlers_for_post); + } // logical_quotas +} // namespace irods::http::handler + +namespace +{ + // + // Operation handler implementations + // + + // NOLINTNEXTLINE(performance-unnecessary-value-param) + IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(op_stat) + { + auto result = irods::http::resolve_client_identity(_req); + if (result.response) { + return _sess_ptr->send(std::move(*result.response)); + } + + const auto client_info = result.client_info; + + irods::http::globals::background_task( + [fn = __func__, client_info, _sess_ptr, _req = std::move(_req), _args = std::move(_args)] { + logging::info(*_sess_ptr, "{}: client_info.username = [{}]", fn, client_info.username); + + http::response res{http::status::ok, _req.version()}; + res.set(http::field::server, irods::http::version::server_name); + res.set(http::field::content_type, "application/json"); + res.keep_alive(_req.keep_alive()); + +#ifdef IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + try { + GetLogicalQuotaInput input{}; + LogicalQuotaList* output{}; + + irods::at_scope_exit free_output{[&input, &output] { + clear_get_logical_quota_input(&input); + clear_logical_quota_list(output); + std::free(output); // NOLINT(cppcoreguidelines-owning-memory, cppcoreguidelines-no-malloc) + }}; + + if (const auto iter = _args.find("lpath"); iter != std::end(_args)) { + input.coll_name = strdup(iter->second.c_str()); + } + + auto conn = irods::get_connection(client_info.username); + const auto ec = rc_get_logical_quota(static_cast(conn), &input, &output); + + std::vector quota_info; + + if (ec >= 0 && output->len > 0) { + quota_info.reserve(output->len); + + std::span entries(output->list, output->len); + + std::transform( + std::begin(entries), + std::end(entries), + std::back_inserter(quota_info), + [](const LogicalQuota& _e) { + // clang-format off + return json{ + {"collection", _e.coll_name}, + {"max_bytes", _e.max_bytes}, + {"max_objects", _e.max_objects}, + {"over_bytes", _e.over_bytes}, + {"over_objects", _e.over_objects} + }; + // clang-format on + }); + } + + // clang-format off + res.body() = json{ + {"irods_response", {{"status_code", ec}}}, + {"quotas", quota_info} + }.dump(); + // clang-format on + } + catch (const irods::exception& e) { + logging::error(*_sess_ptr, "{}: {}", fn, e.client_display_what()); + // clang-format off + res.body() = json{ + {"irods_response", { + {"status_code", e.code()}, + {"status_message", e.client_display_what()} + }} + }.dump(); + // clang-format on + } + catch (const std::exception& e) { + logging::error(*_sess_ptr, "{}: {}", fn, e.what()); + res.result(http::status::internal_server_error); + } +#else + res.result(http::status::not_implemented); +#endif // IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + + res.prepare_payload(); + + return _sess_ptr->send(std::move(res)); + }); + } // op_stat + + // NOLINTNEXTLINE(performance-unnecessary-value-param) + IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(op_set_quota) + { + auto result = irods::http::resolve_client_identity(_req); + if (result.response) { + return _sess_ptr->send(std::move(*result.response)); + } + + const auto client_info = result.client_info; + + irods::http::globals::background_task([fn = __func__, + client_info, + _sess_ptr, + _req = std::move(_req), + _args = std::move(_args)] { + logging::info(*_sess_ptr, "{}: client_info.username = [{}]", fn, client_info.username); + + http::response res{http::status::ok, _req.version()}; + res.set(http::field::server, irods::http::version::server_name); + res.set(http::field::content_type, "application/json"); + res.keep_alive(_req.keep_alive()); + +#ifdef IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + try { + const auto lpath_iter = _args.find("lpath"); + if (lpath_iter == std::end(_args)) { + logging::error(*_sess_ptr, "{}: Missing [lpath] parameter.", fn); + return _sess_ptr->send(irods::http::fail(res, http::status::bad_request)); + } + + GeneralAdminInput input{}; + input.arg0 = "set_logical_quota"; + input.arg1 = lpath_iter->second.c_str(); + + const auto max_bytes_iter = _args.find("maximum-bytes"); + if (max_bytes_iter != std::end(_args)) { + input.arg2 = max_bytes_iter->second.c_str(); + } + + const auto max_objects_iter = _args.find("maximum-objects"); + if (max_objects_iter != std::end(_args)) { + input.arg3 = max_objects_iter->second.c_str(); + } + + if (!input.arg2 && !input.arg3) { + logging::error( + *_sess_ptr, + "{}: No quota parameter provided. Expected [maximum-bytes] and/or [maximum-objects] parameter.", + fn); + return _sess_ptr->send(irods::http::fail(res, http::status::bad_request)); + } + + auto conn = irods::get_connection(client_info.username); + const auto ec = rcGeneralAdmin(static_cast(conn), &input); + + res.body() = json{{"irods_response", {{"status_code", ec}}}}.dump(); + } + catch (const irods::exception& e) { + logging::error(*_sess_ptr, "{}: {}", fn, e.client_display_what()); + // clang-format off + res.body() = json{ + {"irods_response", { + {"status_code", e.code()}, + {"status_message", e.client_display_what()} + }} + }.dump(); + // clang-format on + } + catch (const std::exception& e) { + logging::error(*_sess_ptr, "{}: {}", fn, e.what()); + res.result(http::status::internal_server_error); + } +#else + res.result(http::status::not_implemented); +#endif // IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + + res.prepare_payload(); + + return _sess_ptr->send(std::move(res)); + }); + } // op_set_quota + + // NOLINTNEXTLINE(performance-unnecessary-value-param) + IRODS_HTTP_API_ENDPOINT_OPERATION_SIGNATURE(op_recalculate) + { + auto result = irods::http::resolve_client_identity(_req); + if (result.response) { + return _sess_ptr->send(std::move(*result.response)); + } + + const auto client_info = result.client_info; + + irods::http::globals::background_task( + [fn = __func__, client_info, _sess_ptr, _req = std::move(_req), _args = std::move(_args)] { + logging::info(*_sess_ptr, "{}: client_info.username = [{}]", fn, client_info.username); + + http::response res{http::status::ok, _req.version()}; + res.set(http::field::server, irods::http::version::server_name); + res.set(http::field::content_type, "application/json"); + res.keep_alive(_req.keep_alive()); + +#ifdef IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + try { + GeneralAdminInput input{}; + input.arg0 = "calculate_logical_usage"; + + auto conn = irods::get_connection(client_info.username); + const auto ec = rcGeneralAdmin(static_cast(conn), &input); + + res.body() = json{{"irods_response", {{"status_code", ec}}}}.dump(); + } + catch (const irods::exception& e) { + logging::error(*_sess_ptr, "{}: {}", fn, e.client_display_what()); + // clang-format off + res.body() = json{ + {"irods_response", { + {"status_code", e.code()}, + {"status_message", e.client_display_what()} + }} + }.dump(); + // clang-format on + } + catch (const std::exception& e) { + logging::error(*_sess_ptr, "{}: {}", fn, e.what()); + res.result(http::status::internal_server_error); + } +#else + res.result(http::status::not_implemented); +#endif // IRODS_LIBRARY_FEATURE_LOGICAL_QUOTAS + + res.prepare_payload(); + + return _sess_ptr->send(std::move(res)); + }); + } // op_recalculate +} // anonymous namespace diff --git a/endpoints/shared/include/irods/private/http_api/handlers.hpp b/endpoints/shared/include/irods/private/http_api/handlers.hpp index a05dc34e..c83e9ece 100644 --- a/endpoints/shared/include/irods/private/http_api/handlers.hpp +++ b/endpoints/shared/include/irods/private/http_api/handlers.hpp @@ -22,6 +22,8 @@ namespace irods::http::handler IRODS_HTTP_API_ENDPOINT_ENTRY_FUNCTION_SIGNATURE(information); + IRODS_HTTP_API_ENDPOINT_ENTRY_FUNCTION_SIGNATURE(logical_quotas); + IRODS_HTTP_API_ENDPOINT_ENTRY_FUNCTION_SIGNATURE(physical_quotas); IRODS_HTTP_API_ENDPOINT_ENTRY_FUNCTION_SIGNATURE(query); diff --git a/test/config.py b/test/config.py index 031c0762..b603a345 100644 --- a/test/config.py +++ b/test/config.py @@ -33,7 +33,12 @@ # Requires that physical quotas be enabled on the iRODS server. # See documentation for msiSetRescQuotaPolicy() to learn more. - 'run_physical_quota_tests': False + 'run_physical_quota_tests': False, + + # Requires that logical quotas be enabled in the zone. Visit + # docs.irods.org to learn more about the built-in logical quotas + # system. + 'run_logical_quota_tests': False } schema = { diff --git a/test/irods_error_codes.py b/test/irods_error_codes.py index e5a189ea..6099ddff 100644 --- a/test/irods_error_codes.py +++ b/test/irods_error_codes.py @@ -10,6 +10,7 @@ DIRECT_CHILD_ACCESS = -1816000 HIERARCHY_ERROR = -1803000 INVALID_HANDLE = -175000 +LOGICAL_QUOTA_EXCEEDED = -186000 NOT_A_COLLECTION = -170000 NOT_A_DATA_OBJECT = -171000 NO_MICROSERVICE_FOUND_ERR = -1102000 diff --git a/test/test_irods_http_api.py b/test/test_irods_http_api.py index 62b4643d..eb28e4da 100644 --- a/test/test_irods_http_api.py +++ b/test/test_irods_http_api.py @@ -4498,6 +4498,252 @@ def test_server_reports_error_when_http_method_is_not_supported(self): def test_server_reports_error_when_op_is_not_supported(self): do_test_server_reports_error_when_op_is_not_supported(self) +class test_logical_quotas_endpoint(unittest.TestCase): + + @classmethod + def setUpClass(cls): + setup_class(cls, {'endpoint_name': 'logical-quotas'}) + + @classmethod + def tearDownClass(cls): + tear_down_class(cls) + + def setUp(self): + self.assertFalse(self._class_init_error, 'Class initialization failed. Cannot continue.') + + def test_core_functionality(self): + if not config.test_config.get('run_logical_quota_tests', False): + self.skipTest('Logical Quota tests not enabled. Check [run_logical_quota_tests] in test configuration file.') + + rodsadmin_headers = {'Authorization': f'Bearer {self.rodsadmin_bearer_token}'} + rodsuser_headers = {'Authorization': f'Bearer {self.rodsuser_bearer_token}'} + + zone_collection = f'/{self.zone_name}' + rodsuser_collection = f'/{self.zone_name}/home/{self.rodsuser_username}' + data_object_a = f'/{self.zone_name}/home/{self.rodsuser_username}/data_object_a.txt' + data_object_b = f'/{self.zone_name}/home/{self.rodsuser_username}/data_object_b.txt' + + try: + # Set quotas on two collections. One on the zone collection and another + # on a non-rodsadmin user's collection. + zone_coll_max_bytes = 20 + zone_coll_max_objects = 4 + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'set_quota', + 'lpath': zone_collection, + 'maximum-bytes': zone_coll_max_bytes, + 'maximum-objects': zone_coll_max_objects + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], 0) + + rodsuser_coll_max_bytes = 10 + rodsuser_coll_max_objects = 2 + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'set_quota', + 'lpath': rodsuser_collection, + 'maximum-bytes': rodsuser_coll_max_bytes, + 'maximum-objects': rodsuser_coll_max_objects + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], 0) + + # Show the quota information for the target collection and its ancestors. + r = requests.get(self.url_endpoint, headers=rodsadmin_headers, params={ + 'op': 'stat', + 'lpath': rodsuser_collection + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + result = r.json() + self.assertEqual(result['irods_response']['status_code'], 0) + self.assertEqual(len(result['quotas']), 2) + + zone_coll_quota_info = { + 'collection': zone_collection, + 'max_bytes': zone_coll_max_bytes, + 'max_objects': zone_coll_max_objects, + 'over_bytes': -zone_coll_max_bytes, + 'over_objects': -zone_coll_max_objects + } + self.assertIn(zone_coll_quota_info, result['quotas']) + + rodsuser_coll_quota_info = { + 'collection': rodsuser_collection, + 'max_bytes': rodsuser_coll_max_bytes, + 'max_objects': rodsuser_coll_max_objects, + 'over_bytes': -rodsuser_coll_max_bytes, + 'over_objects': -rodsuser_coll_max_objects + } + self.assertIn(rodsuser_coll_quota_info, result['quotas']) + + # Create a data object which does not violate the quota limit. + content_a = '12345' + r = requests.post(f'{self.url_base}/data-objects', headers=rodsuser_headers, data={ + 'op': 'write', + 'lpath': data_object_a, + 'bytes': content_a + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], 0) + + # Recalculate the quotas. + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'recalculate' + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], 0) + + # Show the quotas. + r = requests.get(self.url_endpoint, headers=rodsadmin_headers, params={ + 'op': 'stat', + 'lpath': rodsuser_collection + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + result = r.json() + self.assertEqual(result['irods_response']['status_code'], 0) + self.assertEqual(len(result['quotas']), 2) + + zone_coll_quota_info = { + 'collection': zone_collection, + 'max_bytes': zone_coll_max_bytes, + 'max_objects': zone_coll_max_objects, + 'over_bytes': -zone_coll_max_bytes + len(content_a), + 'over_objects': -zone_coll_max_objects + 1 + } + self.assertIn(zone_coll_quota_info, result['quotas']) + + rodsuser_coll_quota_info = { + 'collection': rodsuser_collection, + 'max_bytes': rodsuser_coll_max_bytes, + 'max_objects': rodsuser_coll_max_objects, + 'over_bytes': -rodsuser_coll_max_bytes + len(content_a), + 'over_objects': -rodsuser_coll_max_objects + 1 + } + self.assertIn(rodsuser_coll_quota_info, result['quotas']) + + # Create another data object and write enough bytes to it to trigger + # a quota violation. + content_b = 'X' * 50 + r = requests.post(f'{self.url_base}/data-objects', headers=rodsuser_headers, data={ + 'op': 'write', + 'lpath': data_object_b, + 'bytes': content_b + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], 0) + + # Recalculate the quotas so they are in violation. + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'recalculate' + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], 0) + + # Show the quotas. + r = requests.get(self.url_endpoint, headers=rodsadmin_headers, params={ + 'op': 'stat', + 'lpath': rodsuser_collection + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + result = r.json() + self.assertEqual(result['irods_response']['status_code'], 0) + self.assertEqual(len(result['quotas']), 2) + + zone_coll_quota_info = { + 'collection': zone_collection, + 'max_bytes': zone_coll_max_bytes, + 'max_objects': zone_coll_max_objects, + 'over_bytes': -zone_coll_max_bytes + len(content_a) + len(content_b), + 'over_objects': -zone_coll_max_objects + 2 + } + self.assertIn(zone_coll_quota_info, result['quotas']) + + rodsuser_coll_quota_info = { + 'collection': rodsuser_collection, + 'max_bytes': rodsuser_coll_max_bytes, + 'max_objects': rodsuser_coll_max_objects, + 'over_bytes': -rodsuser_coll_max_bytes + len(content_a) + len(content_b), + 'over_objects': -rodsuser_coll_max_objects + 2 + } + self.assertIn(rodsuser_coll_quota_info, result['quotas']) + + # Show that attempting to create a third data object fails due + # to the quota being in violation. + r = requests.post(f'{self.url_base}/data-objects', headers=rodsuser_headers, data={ + 'op': 'touch', + 'lpath': data_object_b + '.nope' + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], irods_error_codes.LOGICAL_QUOTA_EXCEEDED) + + # Show that attempting to write bytes to an existing data object + # also fails due to the quota being in violation. + r = requests.post(f'{self.url_base}/data-objects', headers=rodsuser_headers, data={ + 'op': 'write', + 'lpath': data_object_b, + 'bytes': 'nope' + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json()['irods_response']['status_code'], irods_error_codes.LOGICAL_QUOTA_EXCEEDED) + + finally: + # Remove the data objects. + for dobj in [data_object_a, data_object_b]: + r = requests.post(f'{self.url_base}/data-objects', headers=rodsuser_headers, data={ + 'op': 'remove', + 'lpath': dobj, + 'catalog-only': 0, + 'no-trash': 1 + }) + self.logger.debug(r.content) + + # Recalculate the quotas. + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'recalculate' + }) + self.logger.debug(r.content) + + # Show the quotas. + r = requests.get(self.url_endpoint, headers=rodsadmin_headers, params={ + 'op': 'stat', + 'lpath': rodsuser_collection + }) + self.logger.debug(r.content) + + # Remove the quotas. + for coll in [zone_collection, rodsuser_collection]: + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'set_quota', + 'lpath': coll, + 'maximum-bytes': 0, + 'maximum-objects': 0 + }) + self.logger.debug(r.content) + + def test_server_returns_an_error_when_maximum_quota_parameters_are_missing(self): + if not config.test_config.get('run_logical_quota_tests', False): + self.skipTest('Physical Quota tests not enabled. Check [run_logical_quota_tests] in test configuration file.') + + rodsadmin_headers = {'Authorization': f'Bearer {self.rodsadmin_bearer_token}'} + + r = requests.post(self.url_endpoint, headers=rodsadmin_headers, data={ + 'op': 'set_quota', + 'lpath': 'ignored' + }) + self.logger.debug(r.content) + self.assertEqual(r.status_code, 400) + class test_physical_quotas_endpoint(unittest.TestCase): # TODO(irods/irods#8624): Update this comment once iRODS moves configuration of # physical quotas into the catalog. From 48f959190bfeef1fc2a22a1a49bd891355a588b4 Mon Sep 17 00:00:00 2001 From: Kory Draughn Date: Wed, 19 Aug 2026 15:42:30 -0400 Subject: [PATCH 3/4] squash w/ logical quotas. remove unused header --- endpoints/logical_quotas/src/main.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/endpoints/logical_quotas/src/main.cpp b/endpoints/logical_quotas/src/main.cpp index 4b973dad..35cd74e6 100644 --- a/endpoints/logical_quotas/src/main.cpp +++ b/endpoints/logical_quotas/src/main.cpp @@ -27,7 +27,6 @@ #include #include #include -#include #include // clang-format off From 3442c01db3a0e89274aedb1c10e04c066e5bc03a Mon Sep 17 00:00:00 2001 From: Kory Draughn Date: Wed, 19 Aug 2026 21:58:14 -0400 Subject: [PATCH 4/4] squash. code review - docs and naming --- API.md | 12 ++++++------ endpoints/logical_quotas/src/main.cpp | 4 ++-- test/test_irods_http_api.py | 24 ++++++++++++------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/API.md b/API.md index 00a9963d..5f19e601 100644 --- a/API.md +++ b/API.md @@ -1649,8 +1649,8 @@ If an HTTP status code of 200 is returned, the body of the response will contain "quotas": [ { "collection": "string", - "max_bytes": 0, - "max_objects": 0, + "maximum_bytes": 0, + "maximum_objects": 0, "over_bytes": 0, "over_objects": 0 }, @@ -1678,13 +1678,13 @@ curl http://localhost:/irods-http-api//logical-quotas \ -H 'Authorization: Bearer ' \ --data-urlencode 'op=set_quota' \ --data-urlencode 'lpath=' \ # Absolute logical path to the collection which the quota applies. - --data-urlencode 'maximum-bytes=' \ # The number of bytes which will serves as the byte limit. Optional. - --data-urlencode 'maximum-objects=' # The number of objects which will serve as the limit. Optional. + --data-urlencode 'maximum-bytes=' \ # The total number of bytes that can be stored in the collection. Optional. + --data-urlencode 'maximum-objects=' # The total number of data objects that can be stored in the collection. Optional. ``` -`maximum-bytes` and/or `maximum-objects` MUST be specified for this operation to succeed. +`maximum-bytes` and/or `maximum-objects` MUST be provided for this operation to succeed. -To remove a quota, set `maximum-bytes` and `maximum-objects` to 0. +To remove a quota, set both `maximum-bytes` and `maximum-objects` to 0. #### Response diff --git a/endpoints/logical_quotas/src/main.cpp b/endpoints/logical_quotas/src/main.cpp index 35cd74e6..c0c746ae 100644 --- a/endpoints/logical_quotas/src/main.cpp +++ b/endpoints/logical_quotas/src/main.cpp @@ -141,8 +141,8 @@ namespace // clang-format off return json{ {"collection", _e.coll_name}, - {"max_bytes", _e.max_bytes}, - {"max_objects", _e.max_objects}, + {"maximum_bytes", _e.max_bytes}, + {"maximum_objects", _e.max_objects}, {"over_bytes", _e.over_bytes}, {"over_objects", _e.over_objects} }; diff --git a/test/test_irods_http_api.py b/test/test_irods_http_api.py index eb28e4da..db651bcb 100644 --- a/test/test_irods_http_api.py +++ b/test/test_irods_http_api.py @@ -4563,8 +4563,8 @@ def test_core_functionality(self): zone_coll_quota_info = { 'collection': zone_collection, - 'max_bytes': zone_coll_max_bytes, - 'max_objects': zone_coll_max_objects, + 'maximum_bytes': zone_coll_max_bytes, + 'maximum_objects': zone_coll_max_objects, 'over_bytes': -zone_coll_max_bytes, 'over_objects': -zone_coll_max_objects } @@ -4572,8 +4572,8 @@ def test_core_functionality(self): rodsuser_coll_quota_info = { 'collection': rodsuser_collection, - 'max_bytes': rodsuser_coll_max_bytes, - 'max_objects': rodsuser_coll_max_objects, + 'maximum_bytes': rodsuser_coll_max_bytes, + 'maximum_objects': rodsuser_coll_max_objects, 'over_bytes': -rodsuser_coll_max_bytes, 'over_objects': -rodsuser_coll_max_objects } @@ -4611,8 +4611,8 @@ def test_core_functionality(self): zone_coll_quota_info = { 'collection': zone_collection, - 'max_bytes': zone_coll_max_bytes, - 'max_objects': zone_coll_max_objects, + 'maximum_bytes': zone_coll_max_bytes, + 'maximum_objects': zone_coll_max_objects, 'over_bytes': -zone_coll_max_bytes + len(content_a), 'over_objects': -zone_coll_max_objects + 1 } @@ -4620,8 +4620,8 @@ def test_core_functionality(self): rodsuser_coll_quota_info = { 'collection': rodsuser_collection, - 'max_bytes': rodsuser_coll_max_bytes, - 'max_objects': rodsuser_coll_max_objects, + 'maximum_bytes': rodsuser_coll_max_bytes, + 'maximum_objects': rodsuser_coll_max_objects, 'over_bytes': -rodsuser_coll_max_bytes + len(content_a), 'over_objects': -rodsuser_coll_max_objects + 1 } @@ -4660,8 +4660,8 @@ def test_core_functionality(self): zone_coll_quota_info = { 'collection': zone_collection, - 'max_bytes': zone_coll_max_bytes, - 'max_objects': zone_coll_max_objects, + 'maximum_bytes': zone_coll_max_bytes, + 'maximum_objects': zone_coll_max_objects, 'over_bytes': -zone_coll_max_bytes + len(content_a) + len(content_b), 'over_objects': -zone_coll_max_objects + 2 } @@ -4669,8 +4669,8 @@ def test_core_functionality(self): rodsuser_coll_quota_info = { 'collection': rodsuser_collection, - 'max_bytes': rodsuser_coll_max_bytes, - 'max_objects': rodsuser_coll_max_objects, + 'maximum_bytes': rodsuser_coll_max_bytes, + 'maximum_objects': rodsuser_coll_max_objects, 'over_bytes': -rodsuser_coll_max_bytes + len(content_a) + len(content_b), 'over_objects': -rodsuser_coll_max_objects + 2 }