diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py index 50276417..33dbe459 100644 --- a/hcloud/storage_boxes/__init__.py +++ b/hcloud/storage_boxes/__init__.py @@ -3,26 +3,39 @@ from .client import ( BoundStorageBox, BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, StorageBoxesClient, StorageBoxesPageResult, StorageBoxSnapshotsPageResult, + StorageBoxSubaccountsPageResult, ) from .domain import ( CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxStats, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, ) __all__ = [ "BoundStorageBox", "BoundStorageBoxSnapshot", + "BoundStorageBoxSubaccount", "CreateStorageBoxResponse", + "CreateStorageBoxSnapshotResponse", + "CreateStorageBoxSubaccountResponse", "DeleteStorageBoxResponse", + "DeleteStorageBoxSnapshotResponse", + "DeleteStorageBoxSubaccountResponse", "StorageBox", "StorageBoxAccessSettings", "StorageBoxesClient", @@ -32,4 +45,7 @@ "StorageBoxSnapshotPlan", "StorageBoxSnapshotsPageResult", "StorageBoxStats", + "StorageBoxSubaccount", + "StorageBoxSubaccountAccessSettings", + "StorageBoxSubaccountsPageResult", ] diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 19da0d5f..267b2c0f 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -9,8 +9,10 @@ from .domain import ( CreateStorageBoxResponse, CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, DeleteStorageBoxResponse, DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, @@ -18,6 +20,8 @@ StorageBoxSnapshotPlan, StorageBoxSnapshotStats, StorageBoxStats, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, ) if TYPE_CHECKING: @@ -140,6 +144,38 @@ def _get_self(self) -> BoundStorageBoxSnapshot: # TODO: implement bound methods +class BoundStorageBoxSubaccount(BoundModelBase, StorageBoxSubaccount): + _client: StorageBoxesClient + + model = StorageBoxSubaccount + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxSubaccountAccessSettings.from_dict(raw) + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundStorageBoxSubaccount: + return self._client.get_subaccount_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + + # TODO: implement bound methods + + class StorageBoxesPageResult(NamedTuple): storage_boxes: list[BoundStorageBox] meta: Meta @@ -150,6 +186,11 @@ class StorageBoxSnapshotsPageResult(NamedTuple): meta: Meta +class StorageBoxSubaccountsPageResult(NamedTuple): + subaccounts: list[BoundStorageBoxSubaccount] + meta: Meta + + class StorageBoxesClient(ResourceClientBase): """ A client for the Storage Boxes API. @@ -796,3 +837,212 @@ def delete_snapshot( return DeleteStorageBoxSnapshotResponse( action=BoundAction(self._parent.actions, response["action"]), ) + + # Subaccounts + ########################################################################### + + def get_subaccount_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param storage_box: Storage Box to get the Subaccount from. + :param id: ID of the Subaccount. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts/{id}", + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def get_subaccount_by_username( + self, + storage_box: StorageBox | BoundStorageBox, + username: str, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: User name of the Subaccount. + """ + return self._get_first_by( + self.get_subaccount_list, + storage_box, + username=username, + ) + + def get_subaccount_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + params: dict[str, Any] = {} + if username is not None: + params["username"] = username + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + params=params, + ) + return StorageBoxSubaccountsPageResult( + subaccounts=[ + BoundStorageBoxSubaccount(self, item) + for item in response["subaccounts"] + ], + meta=Meta.parse_meta(response), + ) + + def get_subaccount_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_subaccount_list( + storage_box, + username=username, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_subaccount( + self, + storage_box: StorageBox | BoundStorageBox, + *, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access Settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + data: dict[str, Any] = { + "home_directory": home_directory, + "password": password, + } + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + json=data, + ) + return CreateStorageBoxSubaccountResponse( + subaccount=BoundStorageBoxSubaccount( + self, + response["subaccount"], + # API only returns a partial object. + complete=False, + ), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param subaccount: Storage Box Subaccount to update. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + json=data, + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def delete_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + + :param subaccount: Storage Box Subaccount to delete. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + ) + return DeleteStorageBoxSubaccountResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index 5414cbf1..7d94fcb2 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -10,7 +10,11 @@ from ..storage_box_types import BoundStorageBoxType, StorageBoxType if TYPE_CHECKING: - from .client import BoundStorageBox, BoundStorageBoxSnapshot + from .client import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + ) StorageBoxStatus = Literal[ "active", @@ -338,3 +342,129 @@ def __init__( action: BoundAction, ): self.action = action + + +# Subaccounts +############################################################################### + + +class StorageBoxSubaccount(BaseDomain, DomainIdentityMixin): + """ + Storage Box Subaccount Domain. + """ + + __api_properties__ = ( + "id", + "username", + "description", + "server", + "home_directory", + "access_settings", + "labels", + "storage_box", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + username: str | None = None, + description: str | None = None, + server: str | None = None, + home_directory: str | None = None, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + ): + self.id = id + self.username = username + self.description = description + self.server = server + self.home_directory = home_directory + self.access_settings = access_settings + self.labels = labels + self.storage_box = storage_box + self.created = isoparse(created) if created else None + + +class StorageBoxSubaccountAccessSettings(BaseDomain): + """ + Storage Box Subaccount Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "readonly", + ) + __slots__ = __api_properties__ + + def __init__( + self, + reachable_externally: bool | None = None, + samba_enabled: bool | None = None, + ssh_enabled: bool | None = None, + webdav_enabled: bool | None = None, + readonly: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.readonly = readonly + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + if self.reachable_externally is not None: + payload["reachable_externally"] = self.reachable_externally + if self.samba_enabled is not None: + payload["samba_enabled"] = self.samba_enabled + if self.ssh_enabled is not None: + payload["ssh_enabled"] = self.ssh_enabled + if self.webdav_enabled is not None: + payload["webdav_enabled"] = self.webdav_enabled + if self.readonly is not None: + payload["readonly"] = self.readonly + return payload + + +class CreateStorageBoxSubaccountResponse(BaseDomain): + """ + Create Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ( + "subaccount", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + subaccount: BoundStorageBoxSubaccount, + action: BoundAction, + ): + self.subaccount = subaccount + self.action = action + + +class DeleteStorageBoxSubaccountResponse(BaseDomain): + """ + Delete Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py index 561123dc..48f1696d 100644 --- a/tests/unit/storage_boxes/conftest.py +++ b/tests/unit/storage_boxes/conftest.py @@ -129,3 +129,53 @@ def storage_box_snapshot2(): "created": "2025-11-10T19:18:57Z", "storage_box": 42, } + + +@pytest.fixture() +def storage_box_subaccount1(): + return { + "id": 45, + "username": "u42-sub1", + "server": "u42-sub1.your-storagebox.de", + "home_directory": "tmp/", + "description": "Required by foo", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": { + "key": "value", + }, + "protection": { + "delete": False, + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_subaccount2(): + return { + "id": 46, + "username": "u42-sub2", + "server": "u42-sub2.your-storagebox.de", + "home_directory": "backup/", + "description": "", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": {}, + "protection": { + "delete": False, + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index c895ae38..1503120b 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -13,11 +13,14 @@ from hcloud.storage_boxes import ( BoundStorageBox, BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, StorageBox, StorageBoxAccessSettings, StorageBoxesClient, StorageBoxSnapshot, StorageBoxSnapshotPlan, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, ) from ..conftest import BoundModelTestCase, assert_bound_action1 @@ -34,7 +37,7 @@ def assert_bound_storage_box( def assert_bound_storage_box_snapshot( - o: BoundStorageBox, + o: BoundStorageBoxSnapshot, resource_client: StorageBoxesClient, ): assert isinstance(o, BoundStorageBoxSnapshot) @@ -43,6 +46,16 @@ def assert_bound_storage_box_snapshot( assert o.name == "storage-box-snapshot1" +def assert_bound_storage_box_subaccount( + o: BoundStorageBoxSubaccount, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSubaccount) + assert o._client is resource_client + assert o.id == 45 + assert o.username == "u42-sub1" + + class TestBoundStorageBox(BoundModelTestCase): methods = [] @@ -139,6 +152,63 @@ def test_reload( assert o.labels is not None +class TestBoundStorageBoxSubaccount(BoundModelTestCase): + methods = [] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ) -> BoundStorageBoxSubaccount: + return BoundStorageBoxSubaccount(resource_client, data=storage_box_subaccount1) + + def test_init(self, bound_model: BoundStorageBoxSubaccount, resource_client): + o = bound_model + + assert_bound_storage_box_subaccount(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.username == "u42-sub1" + assert o.description == "Required by foo" + assert o.server == "u42-sub1.your-storagebox.de" + assert o.home_directory == "tmp/" + assert o.access_settings.reachable_externally is True + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is True + assert o.access_settings.webdav_enabled is False + assert o.access_settings.readonly is False + assert o.labels == {"key": "value"} + assert o.created == isoparse("2025-11-10T19:18:57Z") + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + o = BoundStorageBoxSubaccount( + resource_client, data={"id": 45, "storage_box": 42} + ) + + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert o.labels is not None + + class TestStorageBoxClient: @pytest.fixture() def resource_client(self, client: Client) -> StorageBoxesClient: @@ -674,7 +744,7 @@ def test_create_snapshot( ): request_mock.return_value = { "snapshot": { - # Only a partial snapshot is returned + # Only a partial object is returned key: storage_box_snapshot1[key] for key in ["id", "storage_box"] }, @@ -749,3 +819,229 @@ def test_delete_snapshot( ) assert_bound_action1(result.action, resource_client._parent.actions) + + # Subaccounts + ########################################################################### + + def test_get_subaccount_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + result = resource_client.get_subaccount_by_id(StorageBox(42), 45) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"username": "u42-sub1"}, + {}, + ], + ) + def test_get_subaccount_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.subaccounts) == 2 + + result1 = result.subaccounts[0] + result2 = result.subaccounts[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"username": "u42-sub1"}, + {}, + ], + ) + def test_get_subaccount_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_subaccount_by_username( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccounts": [storage_box_subaccount1]} + + result = resource_client.get_subaccount_by_username(StorageBox(42), "u42-sub1") + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts", + params={"username": "u42-sub1"}, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_create_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1: dict, + action1_running, + ): + request_mock.return_value = { + "subaccount": { + # Only a partial object is returned + key: storage_box_subaccount1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_subaccount( + StorageBox(42), + home_directory="tmp", + password="secret", + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + readonly=False, + ), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts", + json={ + "home_directory": "tmp", + "password": "secret", + "access_settings": { + "reachable_externally": True, + "ssh_enabled": True, + "readonly": False, + }, + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.subaccount, BoundStorageBoxSubaccount) + assert result.subaccount._client is resource_client + assert result.subaccount.id == 45 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = { + "subaccount": storage_box_subaccount1, + } + + result = resource_client.update_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/subaccounts/45", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_delete_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_action1(result.action, resource_client._parent.actions)