diff --git a/changelogs/fragments/per-location-server-types.yml b/changelogs/fragments/per-location-server-types.yml new file mode 100644 index 0000000..ab361ee --- /dev/null +++ b/changelogs/fragments/per-location-server-types.yml @@ -0,0 +1,3 @@ +minor_changes: + - server_type_info - Return new Server Type ``locations`` property. The new property defines a list of supported Locations and additional per Locations details such as deprecations information. + - server_type_info - Deprecate Server Type ``deprecation`` property. The property will gradually be phased out as per Locations deprecations are being announced. Please use the new per Locations deprecation information instead. diff --git a/examples/validate-server-type.yml b/examples/validate-server-type.yml new file mode 100644 index 0000000..2692348 --- /dev/null +++ b/examples/validate-server-type.yml @@ -0,0 +1,35 @@ +--- +- name: Validate server type + hosts: localhost + connection: local + tasks: + - name: Fetch location info + hetzner.hcloud.location_info: + name: fsn1 + register: location + + - name: Fetch server type info + hetzner.hcloud.server_type_info: + name: cx22 + register: server_type + + - name: Ensure server type exists + ansible.builtin.assert: + fail_msg: server type does not exists + that: + - server_type.hcloud_server_type_info | count == 1 + + - name: Extract server type location info + ansible.builtin.set_fact: + server_type_location: > + {{ + server_type.hcloud_server_type_info[0].locations + | selectattr("name", "eq", location.hcloud_location_info[0].name) + | first + }} + + - name: Ensure server type is not deprecated + ansible.builtin.assert: + fail_msg: server type is deprecated in location + that: + - server_type_location.deprecation is none diff --git a/plugins/module_utils/deprecation.py b/plugins/module_utils/deprecation.py new file mode 100644 index 0000000..7e13cea --- /dev/null +++ b/plugins/module_utils/deprecation.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import warnings +from datetime import datetime, timezone + +from ansible.module_utils.basic import AnsibleModule + +from .vendor.hcloud.locations import BoundLocation +from .vendor.hcloud.server_types import BoundServerType, ServerTypeLocation + +DEPRECATED_EXISTING_SERVERS = """ +Existing servers of that type will continue to work as before and no action is \ +required on your part. +""".strip() + + +def deprecated_server_type_warning( + module: AnsibleModule, + server_type: BoundServerType, + location: BoundLocation | None = None, +) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if server_type.deprecation is not None: + if server_type.deprecation.unavailable_after < datetime.now(timezone.utc): + module.warn( + str.format( + "Server type {server_type} is unavailable in all locations and can no longer be ordered. ", + server_type=server_type.name, + ) + + DEPRECATED_EXISTING_SERVERS, + ) + else: + module.warn( + str.format( + "Server type {server_type} is deprecated in all locations and will no longer be available " + "for order as of {unavailable_after}. ", + server_type=server_type.name, + unavailable_after=server_type.deprecation.unavailable_after.strftime("%Y-%m-%d"), + ) + + DEPRECATED_EXISTING_SERVERS, + ) + return + + deprecated_locations: list[ServerTypeLocation] = [] + unavailable_locations: list[ServerTypeLocation] = [] + + for o in server_type.locations or []: + if o.deprecation is not None: + deprecated_locations.append(o) + if o.deprecation.unavailable_after < datetime.now(timezone.utc): + unavailable_locations.append(o) + + if not deprecated_locations: + return + + # Warn when the server type is deprecated in the given location + if location: + found = [o for o in deprecated_locations if location.name == o.location.name] + if not found: + return + + deprecated_location = found[0] + + if deprecated_location in unavailable_locations: + module.warn( + str.format( + "Server type {server_type} is unavailable in {location} and can no longer be ordered. ", + server_type=server_type.name, + location=deprecated_location.location.name, + ) + + DEPRECATED_EXISTING_SERVERS, + ) + else: + module.warn( + str.format( + "Server type {server_type} is deprecated in {location} and will no longer be available " + "for order as of {unavailable_after}. ", + server_type=server_type.name, + location=deprecated_location.location.name, + unavailable_after=deprecated_location.deprecation.unavailable_after.strftime("%Y-%m-%d"), + ) + + DEPRECATED_EXISTING_SERVERS, + ) + + return + + # No location given, only warn when all locations are deprecated + if len(server_type.locations) != len(deprecated_locations): + return + + if unavailable_locations: + + if len(deprecated_locations) != len(unavailable_locations): + module.warn( + str.format( + "Server type {server_type} is deprecated in all locations ({deprecated_locations}) and can no " + "longer be ordered in some locations ({unavailable_locations}). ", + server_type=server_type.name, + deprecated_locations=",".join(o.location.name for o in deprecated_locations), + unavailable_locations=",".join(o.location.name for o in unavailable_locations), + ) + + DEPRECATED_EXISTING_SERVERS, + ) + else: + module.warn( + str.format( + "Server type {server_type} is unavailable in all locations ({unavailable_locations}) and can no " + "longer be ordered. ", + server_type=server_type.name, + unavailable_locations=",".join(o.location.name for o in unavailable_locations), + ) + + DEPRECATED_EXISTING_SERVERS, + ) + else: + module.warn( + str.format( + "Server type {server_type} is deprecated in all locations ({deprecated_locations}) and will no " + "longer be available for order. ", + server_type=server_type.name, + deprecated_locations=",".join(o.location.name for o in deprecated_locations), + ) + + DEPRECATED_EXISTING_SERVERS, + ) diff --git a/plugins/modules/server.py b/plugins/modules/server.py index dd1f52a..1da90fa 100644 --- a/plugins/modules/server.py +++ b/plugins/modules/server.py @@ -340,11 +340,12 @@ root_password: sample: YItygq1v3GYjjMomLaKc """ -from datetime import datetime, timedelta, timezone +from datetime import timedelta from typing import TYPE_CHECKING, Literal from ansible.module_utils.basic import AnsibleModule +from ..module_utils.deprecation import deprecated_server_type_warning from ..module_utils.hcloud import AnsibleHCloud from ..module_utils.vendor.hcloud import HCloudException from ..module_utils.vendor.hcloud.firewalls import FirewallResource @@ -408,7 +409,7 @@ class AnsibleHCloudServer(AnsibleHCloud): def _create_server(self): self.module.fail_on_missing_params(required_params=["name", "server_type", "image"]) - server_type = self._get_server_type() + server_type = self._client_get_by_name_or_id("server_types", self.module.params.get("server_type")) image = self._get_image(server_type) params = { @@ -458,18 +459,28 @@ class AnsibleHCloudServer(AnsibleHCloud): for name_or_id in self.module.params.get("firewalls") ] + server_type_location = None + if self.module.params.get("location") is None and self.module.params.get("datacenter") is None: # When not given, the API will choose the location. params["location"] = None params["datacenter"] = None elif self.module.params.get("location") is not None and self.module.params.get("datacenter") is None: params["location"] = self._client_get_by_name_or_id("locations", self.module.params.get("location")) + server_type_location = params["location"] elif self.module.params.get("location") is None and self.module.params.get("datacenter") is not None: params["datacenter"] = self._client_get_by_name_or_id("datacenters", self.module.params.get("datacenter")) + server_type_location = params["datacenter"].location if self.module.params.get("state") == "stopped" or self.module.params.get("state") == "created": params["start_after_create"] = False + deprecated_server_type_warning( + self.module, + server_type, + server_type_location, + ) + if not self.module.check_mode: try: resp = self.client.servers.create(**params) @@ -536,35 +547,6 @@ class AnsibleHCloudServer(AnsibleHCloud): ) return image - def _get_server_type(self) -> ServerType: - server_type = self._client_get_by_name_or_id("server_types", self.module.params.get("server_type")) - - self._check_and_warn_deprecated_server(server_type) - return server_type - - def _check_and_warn_deprecated_server(self, server_type: ServerType) -> None: - if server_type.deprecation is None: - return - - if server_type.deprecation.unavailable_after < datetime.now(timezone.utc): - self.module.warn( - f"Attention: The server plan {server_type.name} is deprecated and can " - "no longer be ordered. Existing servers of that plan will continue to " - "work as before and no action is required on your part. " - "It is possible to migrate this server to another server plan by setting " - "the server_type parameter on the hetzner.hcloud.server module." - ) - else: - server_type_unavailable_date = server_type.deprecation.unavailable_after.strftime("%Y-%m-%d") - self.module.warn( - f"Attention: The server plan {server_type.name} is deprecated and will " - f"no longer be available for order as of {server_type_unavailable_date}. " - "Existing servers of that plan will continue to work as before and no " - "action is required on your part. " - "It is possible to migrate this server to another server plan by setting " - "the server_type parameter on the hetzner.hcloud.server module." - ) - def _update_server(self) -> None: try: previous_server_status = self.hcloud_server.status @@ -686,16 +668,29 @@ class AnsibleHCloudServer(AnsibleHCloud): # Return if nothing changed if current.has_id_or_name(wanted): # Check if we should warn for using an deprecated server type - self._check_and_warn_deprecated_server(self.hcloud_server.server_type) + deprecated_server_type_warning( + self.module, + self.hcloud_server.server_type, + self.hcloud_server.datacenter.location, + ) return + server_type = self._client_get_by_name_or_id("server_types", wanted) + + # Check if we should warn for updating to a deprecated server type + deprecated_server_type_warning( + self.module, + server_type, + self.hcloud_server.datacenter.location, + ) + self.stop_server_if_forced() if not self.module.check_mode: upgrade_disk = self.module.params.get("upgrade_disk") action = self.hcloud_server.change_type( - server_type=self._get_server_type(), + server_type=server_type, upgrade_disk=upgrade_disk, ) # Upgrading a server takes 160 seconds on average, upgrading the disk should diff --git a/plugins/modules/server_type_info.py b/plugins/modules/server_type_info.py index d2dc5f9..c78bd38 100644 --- a/plugins/modules/server_type_info.py +++ b/plugins/modules/server_type_info.py @@ -100,6 +100,36 @@ hcloud_server_type_info: returned: always type: str sample: x86 + locations: + description: List of supported Locations + returned: always + type: list + contains: + id: + description: Numeric identifier of the Location + returned: always + type: int + sample: 1 + name: + description: Name of the Location + returned: always + type: str + sample: fsn1 + deprecation: + description: Wether the Server Type is deprecated in the Location. + returned: when deprecated + type: dict + contains: + announced: + description: Date of the deprecation announcement. + returned: when deprecated + type: str + sample: "2025-09-09T09:00:00Z" + unavailable_after: + description: Date after which the Server Type will be unavailable for new order. + returned: when deprecated + type: str + sample: "2025-12-09T09:00:00Z" included_traffic: description: | Free traffic per month in bytes @@ -113,6 +143,9 @@ hcloud_server_type_info: description: | Describes if, when & how the resources was deprecated. If this field is set to None the resource is not deprecated. If it has a value, it is considered deprecated. + + B(Deprecated): This field is deprecated and will gradually be phased starting 24 September 2025. Use the locations field instead. + See U(https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types). returned: success type: dict contains: @@ -163,6 +196,21 @@ class AnsibleHCloudServerTypeInfo(AnsibleHCloud): "storage_type": server_type.storage_type, "cpu_type": server_type.cpu_type, "architecture": server_type.architecture, + "locations": [ + { + "id": o.location.id, + "name": o.location.name, + "deprecation": ( + { + "announced": o.deprecation.announced.isoformat(), + "unavailable_after": o.deprecation.unavailable_after.isoformat(), + } + if o.deprecation is not None + else None + ), + } + for o in server_type.locations or [] + ], "included_traffic": server_type.included_traffic, "deprecation": ( { diff --git a/tests/integration/targets/server_type_info/defaults/main/main.yml b/tests/integration/targets/server_type_info/defaults/main/main.yml index ef29c4b..fd368ec 100644 --- a/tests/integration/targets/server_type_info/defaults/main/main.yml +++ b/tests/integration/targets/server_type_info/defaults/main/main.yml @@ -1,4 +1,4 @@ # Copyright: (c) 2019, Hetzner Cloud GmbH # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) --- -hcloud_server_type_id_deprecated: 2 # cx11-ceph +hcloud_server_type_id_deprecated: 1 # cx11 diff --git a/tests/integration/targets/server_type_info/tasks/test.yml b/tests/integration/targets/server_type_info/tasks/test.yml index 37b3897..a173d8b 100644 --- a/tests/integration/targets/server_type_info/tasks/test.yml +++ b/tests/integration/targets/server_type_info/tasks/test.yml @@ -65,5 +65,10 @@ that: - result.hcloud_server_type_info | list | count == 1 - result.hcloud_server_type_info[0].deprecation is not none - - result.hcloud_server_type_info[0].deprecation.announced == '2021-11-09T09:00:00+00:00' - - result.hcloud_server_type_info[0].deprecation.unavailable_after == '2021-12-01T00:00:00+00:00' + - result.hcloud_server_type_info[0].deprecation.announced == '2024-06-06T08:00:00+00:00' + - result.hcloud_server_type_info[0].deprecation.unavailable_after == '2024-09-06T00:00:00+00:00' + - result.hcloud_server_type_info[0].locations[0].id == 2 + - result.hcloud_server_type_info[0].locations[0].name == "nbg1" + - result.hcloud_server_type_info[0].locations[0].deprecation is not none + - result.hcloud_server_type_info[0].locations[0].deprecation.announced == '2024-06-06T08:00:00+00:00' + - result.hcloud_server_type_info[0].locations[0].deprecation.unavailable_after == '2024-09-06T00:00:00+00:00' diff --git a/tests/unit/module_utils/test_deprecation.py b/tests/unit/module_utils/test_deprecation.py new file mode 100644 index 0000000..e7a9449 --- /dev/null +++ b/tests/unit/module_utils/test_deprecation.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest import mock + +import pytest +from ansible_collections.hetzner.hcloud.plugins.module_utils.deprecation import ( + deprecated_server_type_warning, +) +from ansible_collections.hetzner.hcloud.plugins.module_utils.vendor.hcloud.locations import ( + BoundLocation, +) +from ansible_collections.hetzner.hcloud.plugins.module_utils.vendor.hcloud.server_types import ( + BoundServerType, +) + +PAST = datetime.now(timezone.utc) - timedelta(days=14) +FUTURE = datetime.now(timezone.utc) + timedelta(days=14) + +LOCATION_FSN = { + "id": 1, + "name": "fsn1", +} +LOCATION_NBG = { + "id": 2, + "name": "nbg1", +} +DEPRECATION_NONE = { + "deprecation": None, +} +DEPRECATION_DEPRECATED = { + "deprecation": { + "announced": PAST.isoformat(), + "unavailable_after": FUTURE.isoformat(), + }, +} +DEPRECATION_UNAVAILABLE = { + "deprecation": { + "announced": PAST.isoformat(), + "unavailable_after": PAST.isoformat(), + }, +} + + +@pytest.mark.parametrize( + ("server_type", "location", "calls"), + [ + ( + BoundServerType( + mock.Mock(), + {"name": "cx22", "locations": []}, + ), + BoundLocation(mock.Mock(), LOCATION_FSN), + [], + ), + # - Deprecated (backward compatible) + ( + BoundServerType( + mock.Mock(), + {"name": "cx22", **DEPRECATION_DEPRECATED}, + ), + None, + [ + mock.call( + "Server type cx22 is deprecated in all locations and will no longer " + f"be available for order as of {FUTURE.strftime('%Y-%m-%d')}. " + "Existing servers of that type will continue to work as before and " + "no action is required on your part." + ) + ], + ), + # - Unavailable (backward compatible) + ( + BoundServerType( + mock.Mock(), + {"name": "cx22", **DEPRECATION_UNAVAILABLE}, + ), + None, + [ + mock.call( + "Server type cx22 is unavailable in all locations and can no longer " + "be ordered. Existing servers of that type will continue to work as " + "before and no action is required on your part." + ) + ], + ), + # - SOME locations are deprecated + # - Given location is NOT deprecated + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_NONE}, + {**LOCATION_NBG, **DEPRECATION_DEPRECATED}, + ], + }, + ), + BoundLocation(mock.Mock(), LOCATION_FSN), + [], + ), + # - SOME locations are deprecated + # - Given location is deprecated + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_NONE}, + {**LOCATION_NBG, **DEPRECATION_DEPRECATED}, + ], + }, + ), + BoundLocation(mock.Mock(), LOCATION_NBG), + [ + mock.call( + "Server type cx22 is deprecated in nbg1 and will no longer be available " + f"for order as of {FUTURE.strftime('%Y-%m-%d')}. Existing servers of " + "that type will continue to work as before and no action is required " + "on your part." + ) + ], + ), + # - SOME locations are unavailable + # - Given location is unavailable + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_NONE}, + {**LOCATION_NBG, **DEPRECATION_UNAVAILABLE}, + ], + }, + ), + BoundLocation(mock.Mock(), LOCATION_NBG), + [ + mock.call( + "Server type cx22 is unavailable in nbg1 and can no longer be ordered. " + "Existing servers of that type will continue to work as before and no " + "action is required on your part." + ) + ], + ), + # - SOME locations are deprecated + # - Location is not given + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_NONE}, + {**LOCATION_NBG, **DEPRECATION_DEPRECATED}, + ], + }, + ), + None, + [], + ), + # - SOME locations are unavailable + # - Location is not given + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_NONE}, + {**LOCATION_NBG, **DEPRECATION_UNAVAILABLE}, + ], + }, + ), + None, + [], + ), + # - SOME locations are deprecated + # - SOME locations are unavailable + # - Location is not given + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_DEPRECATED}, + {**LOCATION_NBG, **DEPRECATION_UNAVAILABLE}, + ], + }, + ), + None, + [ + mock.call( + "Server type cx22 is deprecated in all locations (fsn1,nbg1) and " + "can no longer be ordered in some locations (nbg1). Existing servers" + " of that type will continue to work as before and no action is " + "required on your part." + ) + ], + ), + # - ALL locations are deprecated + # - Location is not given + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_DEPRECATED}, + {**LOCATION_NBG, **DEPRECATION_DEPRECATED}, + ], + }, + ), + None, + [ + mock.call( + "Server type cx22 is deprecated in all locations (fsn1,nbg1) and " + "will no longer be available for order. Existing servers of that " + "type will continue to work as before and no action is required on " + "your part." + ) + ], + ), + # - ALL locations are unavailable + # - Location is not given + ( + BoundServerType( + mock.Mock(), + { + "name": "cx22", + "locations": [ + {**LOCATION_FSN, **DEPRECATION_UNAVAILABLE}, + {**LOCATION_NBG, **DEPRECATION_UNAVAILABLE}, + ], + }, + ), + None, + [ + mock.call( + "Server type cx22 is unavailable in all locations (fsn1,nbg1) and " + "can no longer be ordered. Existing servers of that type will " + "continue to work as before and no action is required on your part." + ) + ], + ), + ], +) +def test_deprecated_server_type_warning(server_type, location, calls): + m = mock.Mock() + deprecated_server_type_warning(m, server_type, location) + m.warn.assert_has_calls(calls)