diff --git a/API.md b/API.md index 3f2463d7..5f19e601 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", + "maximum_bytes": 0, + "maximum_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 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 provided for this operation to succeed. + +To remove a quota, set both `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..c0c746ae --- /dev/null +++ b/endpoints/logical_quotas/src/main.cpp @@ -0,0 +1,320 @@ +#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 + +// 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}, + {"maximum_bytes", _e.max_bytes}, + {"maximum_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 b0cb47c8..6099ddff 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 @@ -8,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 @@ -20,5 +23,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 diff --git a/test/test_irods_http_api.py b/test/test_irods_http_api.py index 0a02b859..02c3ed79 100644 --- a/test/test_irods_http_api.py +++ b/test/test_irods_http_api.py @@ -4542,6 +4542,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, + 'maximum_bytes': zone_coll_max_bytes, + 'maximum_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, + 'maximum_bytes': rodsuser_coll_max_bytes, + 'maximum_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, + '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 + } + self.assertIn(zone_coll_quota_info, result['quotas']) + + rodsuser_coll_quota_info = { + 'collection': rodsuser_collection, + '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 + } + 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, + '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 + } + self.assertIn(zone_coll_quota_info, result['quotas']) + + rodsuser_coll_quota_info = { + 'collection': rodsuser_collection, + '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 + } + 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.