Add maintenance-command #193
7 changed files with 132 additions and 14 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
# 5.x.x (2026-01-28)
|
# 5.4.0 (2026-01-28)
|
||||||
|
|
||||||
## New features
|
## New features
|
||||||
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
supported by the collections, i.e., classes for which storage- and
|
supported by the collections, i.e., classes for which storage- and
|
||||||
validation-endpoints exist.
|
validation-endpoints exist.
|
||||||
|
|
||||||
|
- Add `/maintenance`-endpoint to temporarilly lock collections for non-curator
|
||||||
|
access.
|
||||||
|
|
||||||
|
|
||||||
# 5.3.6 (2026-01-13)
|
# 5.3.6 (2026-01-13)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = '5.3.6'
|
__version__ = '5.4.0'
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from starlette.status import (
|
||||||
HTTP_403_FORBIDDEN,
|
HTTP_403_FORBIDDEN,
|
||||||
HTTP_404_NOT_FOUND,
|
HTTP_404_NOT_FOUND,
|
||||||
HTTP_500_INTERNAL_SERVER_ERROR,
|
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
from starlette.status import (
|
from starlette.status import (
|
||||||
HTTP_413_REQUEST_ENTITY_TOO_LARGE as HTTP_413_CONTENT_TOO_LARGE,
|
HTTP_413_REQUEST_ENTITY_TOO_LARGE as HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
|
@ -31,6 +32,7 @@ __all__ = [
|
||||||
'HTTP_413_CONTENT_TOO_LARGE',
|
'HTTP_413_CONTENT_TOO_LARGE',
|
||||||
'HTTP_422_UNPROCESSABLE_CONTENT',
|
'HTTP_422_UNPROCESSABLE_CONTENT',
|
||||||
'HTTP_500_INTERNAL_SERVER_ERROR',
|
'HTTP_500_INTERNAL_SERVER_ERROR',
|
||||||
|
'HTTP_503_SERVICE_UNAVAILABLE',
|
||||||
'JSON',
|
'JSON',
|
||||||
'YAML',
|
'YAML',
|
||||||
'config_file_name',
|
'config_file_name',
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ class InstanceConfig:
|
||||||
hashed_tokens: dict = dataclasses.field(default_factory=dict)
|
hashed_tokens: dict = dataclasses.field(default_factory=dict)
|
||||||
validators: dict = dataclasses.field(default_factory=dict)
|
validators: dict = dataclasses.field(default_factory=dict)
|
||||||
use_classes: dict = dataclasses.field(default_factory=dict)
|
use_classes: dict = dataclasses.field(default_factory=dict)
|
||||||
|
maintenance_mode: set = dataclasses.field(default_factory=set)
|
||||||
|
|
||||||
mode_mapping = {
|
mode_mapping = {
|
||||||
TokenModes.READ_CURATED: TokenPermission(curated_read=True),
|
TokenModes.READ_CURATED: TokenPermission(curated_read=True),
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ from dump_things_service.model import (
|
||||||
get_subclasses,
|
get_subclasses,
|
||||||
)
|
)
|
||||||
from dump_things_service.utils import (
|
from dump_things_service.utils import (
|
||||||
|
authenticate_token,
|
||||||
check_bounds,
|
check_bounds,
|
||||||
check_collection,
|
check_collection,
|
||||||
combine_ttl,
|
combine_ttl,
|
||||||
|
|
@ -94,8 +95,9 @@ if TYPE_CHECKING:
|
||||||
from dump_things_service.lazy_list import LazyList
|
from dump_things_service.lazy_list import LazyList
|
||||||
|
|
||||||
|
|
||||||
class TokenCapabilityRequest(BaseModel):
|
class MaintenanceRequest(BaseModel):
|
||||||
token: str | None
|
collection: str
|
||||||
|
active: bool
|
||||||
|
|
||||||
|
|
||||||
class ServerCollectionResponse(BaseModel):
|
class ServerCollectionResponse(BaseModel):
|
||||||
|
|
@ -166,8 +168,8 @@ of the project.
|
||||||
|
|
||||||
tag_info = [
|
tag_info = [
|
||||||
{
|
{
|
||||||
'name': 'Server info',
|
'name': 'Server management',
|
||||||
'description': 'Get general information about the server',
|
'description': 'General server operations',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Read records',
|
'name': 'Read records',
|
||||||
|
|
@ -407,7 +409,7 @@ async def root() -> RedirectResponse:
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
'/server',
|
'/server',
|
||||||
tags=['Server info'],
|
tags=['Server management'],
|
||||||
name='get server information'
|
name='get server information'
|
||||||
)
|
)
|
||||||
async def server() -> ServerResponse:
|
async def server() -> ServerResponse:
|
||||||
|
|
@ -424,6 +426,48 @@ async def server() -> ServerResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
'/maintenance',
|
||||||
|
tags=['Server management'],
|
||||||
|
name='put a collection in maintenance mode'
|
||||||
|
)
|
||||||
|
async def maintenance(
|
||||||
|
body: MaintenanceRequest,
|
||||||
|
api_key: str | None = Depends(api_key_header_scheme),
|
||||||
|
):
|
||||||
|
|
||||||
|
if api_key is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f'Token required for this operation',
|
||||||
|
)
|
||||||
|
|
||||||
|
collection = body.collection
|
||||||
|
active = body.active
|
||||||
|
|
||||||
|
# Try to authenticate the token with the authentication providers that
|
||||||
|
# are associated with the collection.
|
||||||
|
check_collection(g_instance_config, collection)
|
||||||
|
auth_info = authenticate_token(g_instance_config, collection, api_key)
|
||||||
|
permissions = auth_info.token_permission
|
||||||
|
|
||||||
|
if not (
|
||||||
|
permissions.curated_write
|
||||||
|
and permissions.curated_read
|
||||||
|
and permissions.zones_access
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f'Curator permissions required for this operation',
|
||||||
|
)
|
||||||
|
|
||||||
|
if active:
|
||||||
|
g_instance_config.maintenance_mode.add(collection)
|
||||||
|
else:
|
||||||
|
g_instance_config.maintenance_mode.remove(collection)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
'/{collection}/record',
|
'/{collection}/record',
|
||||||
tags=['Read records'],
|
tags=['Read records'],
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import pytest # F401
|
||||||
|
|
||||||
from .. import (
|
from .. import (
|
||||||
HTTP_200_OK,
|
HTTP_200_OK,
|
||||||
|
HTTP_400_BAD_REQUEST,
|
||||||
HTTP_401_UNAUTHORIZED,
|
HTTP_401_UNAUTHORIZED,
|
||||||
HTTP_403_FORBIDDEN,
|
HTTP_403_FORBIDDEN,
|
||||||
HTTP_404_NOT_FOUND,
|
HTTP_404_NOT_FOUND,
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
from ..__about__ import __version__
|
from ..__about__ import __version__
|
||||||
from ..utils import cleaned_json
|
from ..utils import cleaned_json
|
||||||
|
|
@ -446,3 +448,57 @@ def test_ignore_classes(fastapi_client_simple):
|
||||||
json={'pid': f'dlflatsocial:c_{class_name}'},
|
json={'pid': f'dlflatsocial:c_{class_name}'},
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTP_404_NOT_FOUND
|
assert response.status_code == HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
def test_maintenance(fastapi_client_simple):
|
||||||
|
test_client, _ = fastapi_client_simple
|
||||||
|
|
||||||
|
# Ensure that only curators can put a collection in maintenance mode
|
||||||
|
response = test_client.post(
|
||||||
|
'/maintenance',
|
||||||
|
headers={'x-dumpthings-token': 'token-1'},
|
||||||
|
json={'collection': 'collection_1', 'active': True},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
# Ensure unknown collections are caught in maintenance mode
|
||||||
|
response = test_client.post(
|
||||||
|
'/maintenance',
|
||||||
|
headers={'x-dumpthings-token': 'token_admin'},
|
||||||
|
json={'collection': 'collection_x', 'active': True},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
response = test_client.post(
|
||||||
|
'/maintenance',
|
||||||
|
headers={'x-dumpthings-token': 'token_admin'},
|
||||||
|
json={'collection': 'collection_1', 'active': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = test_client.get(
|
||||||
|
'/collection_1/record?pid=abc:something/',
|
||||||
|
headers={'x-dumpthings-token': 'token-1'},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
# Ensure unknown collections are caught in maintenance mode
|
||||||
|
response = test_client.get(
|
||||||
|
'/collection_x/record?pid=abc:something/',
|
||||||
|
headers={'x-dumpthings-token': 'token-1'},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
# Deactivate maintenance mode
|
||||||
|
response = test_client.post(
|
||||||
|
'/maintenance',
|
||||||
|
headers={'x-dumpthings-token': 'token_admin'},
|
||||||
|
json={'collection': 'collection_1', 'active': False},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_200_OK
|
||||||
|
|
||||||
|
# Ensure that
|
||||||
|
response = test_client.get(
|
||||||
|
'/collection_1/record?pid=abc:something/',
|
||||||
|
headers={'x-dumpthings-token': 'token-1'},
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTP_200_OK
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from dump_things_service import (
|
||||||
HTTP_403_FORBIDDEN,
|
HTTP_403_FORBIDDEN,
|
||||||
HTTP_404_NOT_FOUND,
|
HTTP_404_NOT_FOUND,
|
||||||
HTTP_413_CONTENT_TOO_LARGE,
|
HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
from dump_things_service.auth import (
|
from dump_things_service.auth import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
|
@ -213,6 +214,19 @@ async def process_token(
|
||||||
final_permissions = join_default_token_permissions(
|
final_permissions = join_default_token_permissions(
|
||||||
instance_config, token_permissions, collection
|
instance_config, token_permissions, collection
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for maintenance mode
|
||||||
|
if collection in instance_config.maintenance_mode:
|
||||||
|
if not (
|
||||||
|
final_permissions.curated_read
|
||||||
|
and final_permissions.curated_write
|
||||||
|
and final_permissions.zones_access
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Collection '{collection}' is in maintenance mode",
|
||||||
|
)
|
||||||
|
|
||||||
if not final_permissions.incoming_read and not final_permissions.curated_read:
|
if not final_permissions.incoming_read and not final_permissions.curated_read:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_403_FORBIDDEN,
|
status_code=HTTP_403_FORBIDDEN,
|
||||||
|
|
@ -281,12 +295,6 @@ def get_token_store(
|
||||||
) -> tuple[ModelStore, str, TokenPermission, str] | tuple[None, None, None, None]:
|
) -> tuple[ModelStore, str, TokenPermission, str] | tuple[None, None, None, None]:
|
||||||
check_collection(instance_config, collection_name)
|
check_collection(instance_config, collection_name)
|
||||||
|
|
||||||
# Check whether a store for this collection and token does already exist.
|
|
||||||
# If the token is a hashed token, we have to
|
|
||||||
store_info = instance_config.token_stores[collection_name].get(plain_token)
|
|
||||||
if store_info:
|
|
||||||
return store_info
|
|
||||||
|
|
||||||
# Try to authenticate the token with the authentication providers that
|
# Try to authenticate the token with the authentication providers that
|
||||||
# are associated with the collection.
|
# are associated with the collection.
|
||||||
auth_info = authenticate_token(instance_config, collection_name, plain_token)
|
auth_info = authenticate_token(instance_config, collection_name, plain_token)
|
||||||
|
|
@ -319,6 +327,11 @@ def get_token_store(
|
||||||
detail='No incoming area for collection ' + collection_name
|
detail='No incoming area for collection ' + collection_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check whether a store for this collection and token does already exist.
|
||||||
|
store_info = instance_config.token_stores[collection_name].get(plain_token)
|
||||||
|
if store_info:
|
||||||
|
return store_info
|
||||||
|
|
||||||
store_dir = instance_config.store_path / incoming / auth_info.incoming_label
|
store_dir = instance_config.store_path / incoming / auth_info.incoming_label
|
||||||
token_store = create_token_store(
|
token_store = create_token_store(
|
||||||
instance_config=instance_config,
|
instance_config=instance_config,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue