diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e3d64eb3..d14b43af 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,10 @@ History ======= +v1.12.0 (2021-04-06) +--------------------- +* Feature: Add support for managed Certificates + v1.11.0 (2021-03-11) --------------------- * Feature: Add support for Firewalls diff --git a/hcloud/__version__.py b/hcloud/__version__.py index c1e4aa04..342b3ec8 100644 --- a/hcloud/__version__.py +++ b/hcloud/__version__.py @@ -1 +1 @@ -VERSION = '1.11.0' +VERSION = '1.12.0' diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index d0e52944..bdadb626 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -1,12 +1,55 @@ # -*- coding: utf-8 -*- +from hcloud.actions.client import BoundAction from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin -from hcloud.certificates.domain import Certificate +from hcloud.certificates.domain import Certificate, CreateManagedCertificateResponse, ManagedCertificateStatus, ManagedCertificateError +from hcloud.core.domain import add_meta_to_result class BoundCertificate(BoundModelBase): model = Certificate + def __init__(self, client, data, complete=True): + status = data.get('status') + if status is not None: + error_data = status.get('error') + error = None + if error_data: + error = ManagedCertificateError(code=error_data['code'], message=error_data['message']) + data['status'] = ManagedCertificateStatus( + issuance=status['issuance'], + renewal=status['renewal'], + error=error) + super(BoundCertificate, self).__init__(client, data, complete) + + def get_actions_list(self, status=None, sort=None, page=None, per_page=None): + # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction, Meta]] + """Returns all action objects for a Certificate. + + :param status: List[str] (optional) + Response will have only actions with specified statuses. Choices: `running` `success` `error` + :param sort: List[str] (optional) + Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + :param page: int (optional) + Specifies the page to fetch + :param per_page: int (optional) + Specifies how many results are returned by page + :return: (List[:class:`BoundAction `], :class:`Meta `) + """ + return self._client.get_actions_list(self, status, sort, page, per_page) + + def get_actions(self, status=None, sort=None): + # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] + """Returns all action objects for a Certificate. + + :param status: List[str] (optional) + Response will have only actions with specified statuses. Choices: `running` `success` `error` + :param sort: List[str] (optional) + Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + :return: List[:class:`BoundAction `] + """ + return self._client.get_actions(self, status, sort) + def update(self, name=None, labels=None): # type: (Optional[str], Optional[Dict[str, str]]) -> BoundCertificate """Updates an certificate. You can update an certificate name and the certificate labels. @@ -26,6 +69,13 @@ def delete(self): """ return self._client.delete(self) + def retry_issuance(self): + # type: () -> BoundAction + """Retry a failed Certificate issuance or renewal. + :return: BoundAction + """ + return self._client.retry_issuance(self) + class CertificatesClient(ClientEntityBase, GetEntityByNameMixin): results_list_attribute_name = 'certificates' @@ -102,7 +152,8 @@ def get_by_name(self, name): def create(self, name, certificate, private_key, labels=None): # type: (str, str, Optional[Dict[str, str]]) -> BoundCertificate - """Creates a new Certificate with the given name, certificate and private_key. + """Creates a new Certificate with the given name, certificate and private_key. This methods allows only creating + custom uploaded certificates. If you want to create a managed certificate use :func:`~hcloud.certificates.client.CertificatesClient.create_managed` :param name: str :param certificate: str @@ -116,13 +167,37 @@ def create(self, name, certificate, private_key, labels=None): data = { 'name': name, 'certificate': certificate, - 'private_key': private_key + 'private_key': private_key, + 'type': Certificate.TYPE_UPLOADED } if labels is not None: data['labels'] = labels response = self._client.request(url="/certificates", method="POST", json=data) return BoundCertificate(self, response['certificate']) + def create_managed(self, name, domain_names, labels=None): + # type: (str, List[str], Optional[Dict[str, str]]) -> CreateManagedCertificateResponse + """Creates a new managed Certificate with the given name and domain names. This methods allows only creating + managed certificates for domains that are using the Hetzner DNS service. If you want to create a custom uploaded certificate use :func:`~hcloud.certificates.client.CertificatesClient.create` + + :param name: str + :param domain_names: List[str] + Domains and subdomains that should be contained in the Certificate + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundCertificate ` + """ + data = { + 'name': name, + 'type': Certificate.TYPE_MANAGED, + 'domain_names': domain_names + } + if labels is not None: + data['labels'] = labels + response = self._client.request(url="/certificates", method="POST", json=data) + return CreateManagedCertificateResponse(certificate=BoundCertificate(self, response['certificate']), + action=BoundAction(self._client.actions, response['action'])) + def update(self, certificate, name=None, labels=None): # type: (Certificate, Optional[str], Optional[Dict[str, str]]) -> BoundCertificate """Updates a Certificate. You can update a certificate name and labels. @@ -155,3 +230,67 @@ def delete(self, certificate): """ # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised return True + + def get_actions_list( + self, certificate, status=None, sort=None, page=None, per_page=None + ): + # type: (Certificate, Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction], Meta] + """Returns all action objects for a Certificate. + + :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` + :param status: List[str] (optional) + Response will have only actions with specified statuses. Choices: `running` `success` `error` + :param sort: List[str] (optional) + Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + :param page: int (optional) + Specifies the page to fetch + :param per_page: int (optional) + Specifies how many results are returned by page + :return: (List[:class:`BoundAction `], :class:`Meta `) + """ + params = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + url="/certificates/{certificate_id}/actions".format(certificate_id=certificate.id), + method="GET", + params=params, + ) + actions = [ + BoundAction(self._client.actions, action_data) + for action_data in response["actions"] + ] + return add_meta_to_result(actions, response, "actions") + + def get_actions(self, certificate, status=None, sort=None): + # type: (Certificate, Optional[List[str]], Optional[List[str]]) -> List[BoundAction] + """Returns all action objects for a Certificate. + + :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` + :param status: List[str] (optional) + Response will have only actions with specified statuses. Choices: `running` `success` `error` + :param sort: List[str] (optional) + Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + :return: List[:class:`BoundAction `] + """ + return super(CertificatesClient, self).get_actions( + certificate, status=status, sort=sort + ) + + def retry_issuance(self, certificate): + # type: (Certificate) -> BoundAction + """Returns all action objects for a Certificate. + + :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` + :return: :class:`BoundAction ` + """ + response = self._client.request(url="/certificates/{certificate_id}/actions/retry".format(certificate_id=certificate.id), + method="POST") + return BoundAction(self._client.actions, response['action']) diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py index 1b34b3a9..483e0a17 100644 --- a/hcloud/certificates/domain.py +++ b/hcloud/certificates/domain.py @@ -20,6 +20,8 @@ class Certificate(BaseDomain, DomainIdentityMixin): User-defined labels (key-value pairs) :param created: datetime Point in time when the certificate was created + :param type: str Type of Certificate + :param status: ManagedCertificateStatus Current status of a type managed Certificate, always none for type uploaded Certificates """ __slots__ = ( "id", @@ -31,7 +33,11 @@ class Certificate(BaseDomain, DomainIdentityMixin): "fingerprint", "created", "labels", + "type", + "status" ) + TYPE_UPLOADED = "uploaded" + TYPE_MANAGED = "managed" def __init__( self, @@ -44,9 +50,12 @@ def __init__( fingerprint=None, created=None, labels=None, + type=None, + status=None, ): self.id = id self.name = name + self.type = type self.certificate = certificate self.domain_names = domain_names self.fingerprint = fingerprint @@ -54,3 +63,56 @@ def __init__( self.not_valid_after = isoparse(not_valid_after) if not_valid_after else None self.created = isoparse(created) if created else None self.labels = labels + self.status = status + + +class ManagedCertificateStatus(BaseDomain): + """ManagedCertificateStatus Domain + + :param issuance: str + Status of the issuance process of the Certificate + :param renewal: str + Status of the renewal process of the Certificate + :param error: ManagedCertificateError + If issuance or renewal reports failure, this property contains information about what happened + """ + + def __init__(self, issuance=None, renewal=None, error=None): + self.issuance = issuance + self.renewal = renewal + self.error = error + + +class ManagedCertificateError(BaseDomain): + """ManagedCertificateError Domain + + :param code: str + Error code identifying the error + :param message: + Message detailing the error + """ + def __init__(self, code=None, message=None): + self.code = code + self.message = message + + +class CreateManagedCertificateResponse(BaseDomain): + """Create Managed Certificate Response Domain + + :param certificate: :class:`BoundCertificate ` + The created server + :param action: :class:`BoundAction ` + Shows the progress of the certificate creation + """ + __slots__ = ( + "certificate", + "action", + ) + + def __init__( + self, + certificate, # type: BoundCertificate + action, # type: BoundAction + ): + self.certificate = certificate + self.action = action diff --git a/tests/unit/certificates/conftest.py b/tests/unit/certificates/conftest.py index 007867d5..7a2acd52 100644 --- a/tests/unit/certificates/conftest.py +++ b/tests/unit/certificates/conftest.py @@ -7,6 +7,7 @@ def certificate_response(): "certificate": { "id": 2323, "name": "My Certificate", + "type": "managed", "labels": {}, "certificate": "-----BEGIN CERTIFICATE-----\n...", "created": "2019-01-08T12:10:00+00:00", @@ -17,7 +18,73 @@ def certificate_response(): "webmail.example.com", "www.example.com" ], - "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": { + "issuance": "failed", + "renewal": "scheduled", + "error": { + "code": "error_code", + "message": "error message" + } + }, + "used_by": [ + { + "id": 42, + "type": "server" + } + ] + } + } + + +@pytest.fixture() +def create_managed_certificate_response(): + return { + "certificate": { + "id": 2323, + "name": "My Certificate", + "type": "managed", + "labels": {}, + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": [ + "example.com", + "webmail.example.com", + "www.example.com" + ], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": { + "issuance": "pending", + "renewal": "scheduled", + "error": None + }, + "used_by": [ + { + "id": 42, + "type": "load_balancer" + } + ] + }, + "action": { + "id": 14, + "command": "issue_certificate", + "status": "success", + "progress": 100, + "started": "2021-01-30T23:55:00+00:00", + "finished": "2021-01-30T23:57:00+00:00", + "resources": [ + { + "id": 896, + "type": "certificate" + } + ], + "error": { + "code": "action_failed", + "message": "Action failed" + } + } } @@ -30,6 +97,7 @@ def two_certificates_response(): "id": 2323, "name": "My Certificate", "labels": {}, + "type": "uploaded", "certificate": "-----BEGIN CERTIFICATE-----\n...", "created": "2019-01-08T12:10:00+00:00", "not_valid_before": "2019-01-08T10:00:00+00:00", @@ -39,12 +107,20 @@ def two_certificates_response(): "webmail.example.com", "www.example.com" ], - "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [ + { + "id": 42, + "type": "load_balancer" + } + ] }, { "id": 2324, "name": "My website cert", "labels": {}, + "type": "uploaded", "certificate": "-----BEGIN CERTIFICATE-----\n...", "created": "2019-01-08T12:10:00+00:00", "not_valid_before": "2019-01-08T10:00:00+00:00", @@ -54,7 +130,14 @@ def two_certificates_response(): "webmail.example.com", "www.example.com" ], - "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [ + { + "id": 42, + "type": "load_balancer" + } + ] } ] } @@ -68,6 +151,7 @@ def one_certificates_response(): "id": 2323, "name": "My Certificate", "labels": {}, + "type": "uploaded", "certificate": "-----BEGIN CERTIFICATE-----\n...", "created": "2019-01-08T12:10:00+00:00", "not_valid_before": "2019-01-08T10:00:00+00:00", @@ -77,7 +161,14 @@ def one_certificates_response(): "webmail.example.com", "www.example.com" ], - "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [ + { + "id": 42, + "type": "load_balancer" + } + ] } ] } @@ -90,6 +181,7 @@ def response_update_certificate(): "id": 2323, "name": "New name", "labels": {}, + "type": "uploaded", "certificate": "-----BEGIN CERTIFICATE-----\n...", "created": "2019-01-08T12:10:00+00:00", "not_valid_before": "2019-01-08T10:00:00+00:00", @@ -99,6 +191,64 @@ def response_update_certificate(): "webmail.example.com", "www.example.com" ], - "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [ + { + "id": 42, + "type": "load_balancer" + } + ] + + } + } + + +@pytest.fixture() +def response_get_actions(): + return { + "actions": [ + { + "id": 13, + "command": "change_protection", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [ + { + "id": 14, + "type": "certificate" + } + ], + "error": { + "code": "action_failed", + "message": "Action failed" + } + } + ] + } + + +@pytest.fixture() +def response_retry_issuance_action(): + return { + "action": { + "id": 14, + "command": "issue_certificate", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50+00:00", + "finished": None, + "resources": [ + { + "id": 42, + "type": "certificate" + } + ], + "error": { + "code": "action_failed", + "message": "Action failed" + } } } diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index c979dafa..1e840d24 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -1,8 +1,9 @@ import pytest import mock +from hcloud.actions.client import BoundAction from hcloud.certificates.client import CertificatesClient, BoundCertificate -from hcloud.certificates.domain import Certificate +from hcloud.certificates.domain import Certificate, ManagedCertificateStatus class TestBoundCertificate(object): @@ -11,6 +12,42 @@ class TestBoundCertificate(object): def bound_certificate(self, hetzner_client): return BoundCertificate(client=hetzner_client.certificates, data=dict(id=14)) + @pytest.mark.parametrize( + "params", + [ + { + "page": 1, + "per_page": 10}, + {} + + ] + ) + def test_get_actions_list(self, hetzner_client, bound_certificate, response_get_actions, params): + hetzner_client.request.return_value = response_get_actions + result = bound_certificate.get_actions_list(**params) + hetzner_client.request.assert_called_with(url="/certificates/14/actions", method="GET", params=params) + + actions = result.actions + assert result.meta is None + + assert len(actions) == 1 + assert isinstance(actions[0], BoundAction) + assert actions[0].id == 13 + assert actions[0].command == "change_protection" + + def test_get_actions(self, hetzner_client, bound_certificate, response_get_actions): + hetzner_client.request.return_value = response_get_actions + actions = bound_certificate.get_actions() + + params = {'page': 1, 'per_page': 50} + + hetzner_client.request.assert_called_with(url="/certificates/14/actions", method="GET", params=params) + + assert len(actions) == 1 + assert isinstance(actions[0], BoundAction) + assert actions[0].id == 13 + assert actions[0].command == "change_protection" + def test_bound_certificate_init(self, certificate_response): bound_certificate = BoundCertificate( client=mock.MagicMock(), @@ -19,12 +56,18 @@ def test_bound_certificate_init(self, certificate_response): assert bound_certificate.id == 2323 assert bound_certificate.name == "My Certificate" + assert bound_certificate.type == "managed" assert bound_certificate.fingerprint == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" assert bound_certificate.certificate == "-----BEGIN CERTIFICATE-----\n..." assert len(bound_certificate.domain_names) == 3 assert bound_certificate.domain_names[0] == "example.com" assert bound_certificate.domain_names[1] == "webmail.example.com" assert bound_certificate.domain_names[2] == "www.example.com" + assert isinstance(bound_certificate.status, ManagedCertificateStatus) + assert bound_certificate.status.issuance == "failed" + assert bound_certificate.status.renewal == "scheduled" + assert bound_certificate.status.error.code == "error_code" + assert bound_certificate.status.error.message == "error message" def test_update(self, hetzner_client, bound_certificate, response_update_certificate): hetzner_client.request.return_value = response_update_certificate @@ -41,6 +84,14 @@ def test_delete(self, hetzner_client, bound_certificate, generic_action): assert delete_success is True + def test_retry_issuance(self, hetzner_client, bound_certificate, response_retry_issuance_action): + hetzner_client.request.return_value = response_retry_issuance_action + action = bound_certificate.retry_issuance() + hetzner_client.request.assert_called_with(url="/certificates/14/actions/retry", method="POST") + + assert action.id == 14 + assert action.command == "issue_certificate" + class TestCertificatesClient(object): @@ -123,17 +174,38 @@ def test_get_by_name(self, certificates_client, one_certificates_response): def test_create(self, certificates_client, certificate_response): certificates_client._client.request.return_value = certificate_response - certificate = certificates_client.create(name="My Certificate", certificate="-----BEGIN CERTIFICATE-----\n...", private_key="-----BEGIN PRIVATE KEY-----\n...") - certificates_client._client.request.assert_called_with(url="/certificates", method="POST", json={"name": "My Certificate", "certificate": "-----BEGIN CERTIFICATE-----\n...", "private_key": "-----BEGIN PRIVATE KEY-----\n..."}) + certificate = certificates_client.create(name="My Certificate", certificate="-----BEGIN CERTIFICATE-----\n...", + private_key="-----BEGIN PRIVATE KEY-----\n...") + certificates_client._client.request.assert_called_with(url="/certificates", method="POST", + json={"name": "My Certificate", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...", + "type": "uploaded"}) assert certificate.id == 2323 assert certificate.name == "My Certificate" + def test_create_managed(self, certificates_client, create_managed_certificate_response): + certificates_client._client.request.return_value = create_managed_certificate_response + create_managed_certificate_rsp = certificates_client.create_managed(name="My Certificate", + domain_names=["example.com", + "*.example.org"]) + certificates_client._client.request.assert_called_with(url="/certificates", method="POST", + json={"name": "My Certificate", + "domain_names": ["example.com", "*.example.org"], + "type": "managed"}) + + assert create_managed_certificate_rsp.certificate.id == 2323 + assert create_managed_certificate_rsp.certificate.name == "My Certificate" + assert create_managed_certificate_rsp.action.id == 14 + assert create_managed_certificate_rsp.action.command == "issue_certificate" + @pytest.mark.parametrize("certificate", [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))]) def test_update(self, certificates_client, certificate, response_update_certificate): certificates_client._client.request.return_value = response_update_certificate certificate = certificates_client.update(certificate, name="New name") - certificates_client._client.request.assert_called_with(url="/certificates/1", method="PUT", json={"name": "New name"}) + certificates_client._client.request.assert_called_with(url="/certificates/1", method="PUT", + json={"name": "New name"}) assert certificate.id == 2323 assert certificate.name == "New name" @@ -145,3 +217,12 @@ def test_delete(self, certificates_client, certificate, generic_action): certificates_client._client.request.assert_called_with(url="/certificates/1", method="DELETE") assert delete_success is True + + @pytest.mark.parametrize("certificate", [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))]) + def test_retry_issuance(self, certificates_client, certificate, response_retry_issuance_action): + certificates_client._client.request.return_value = response_retry_issuance_action + action = certificates_client.retry_issuance(certificate) + certificates_client._client.request.assert_called_with(url="/certificates/1/actions/retry", method="POST") + + assert action.id == 14 + assert action.command == "issue_certificate"