From 5ea6fc89bf1075c9f0f65960e821429a292f9947 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 28 Nov 2023 14:20:59 -0500 Subject: [PATCH 1/4] Add support for region and type migrations; add migration_type field --- plugins/modules/instance.py | 117 +++++++++++++++++- .../instance_region_migration/tasks/main.yaml | 52 ++++++++ .../instance_type_change/tasks/main.yaml | 77 ++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 tests/integration/targets/instance_region_migration/tasks/main.yaml create mode 100644 tests/integration/targets/instance_type_change/tasks/main.yaml diff --git a/plugins/modules/instance.py b/plugins/modules/instance.py index 8d15580c..1cba30e7 100644 --- a/plugins/modules/instance.py +++ b/plugins/modules/instance.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Union, cast import ansible_collections.linode.cloud.plugins.module_utils.doc_fragments.instance as docs +import polling from ansible_collections.linode.cloud.plugins.module_utils.linode_common import ( LinodeModuleBase, ) @@ -23,6 +24,7 @@ filter_null_values_recursive, paginated_list_to_json, parse_linode_types, + poll_condition, ) from ansible_specdoc.objects import ( FieldType, @@ -447,7 +449,7 @@ ), "wait_timeout": SpecField( type=FieldType.integer, - default=240, + default=1500, description=[ "The amount of time, in seconds, to wait for an instance to " 'have status "running".' @@ -471,6 +473,21 @@ ], default=False, ), + "migration_type": SpecField( + type=FieldType.string, + description=[ + "The type of migration to use for Region and Type migrations." + ], + choices=["cold", "warm"], + default="cold", + ), + "auto_disk_resize": SpecField( + type=FieldType.bool, + description=[ + "Whether implicitly created disks should be resized during a type change operation." + ], + default=False, + ), } SPECDOC_META = SpecDocMeta( @@ -864,6 +881,96 @@ def _update_firewall(self) -> None: "and 'firewall_device' modules." ) + def _wait_for_instance_status(self, status: str) -> None: + def poll_func() -> bool: + self._instance.invalidate() + return self._instance.status == status + + try: + poll_condition(poll_func, 4, self._timeout_ctx.seconds_remaining) + except polling.TimeoutException: + self.fail( + f"failed to wait for instance to reach status {status}: timeout period expired" + ) + + def _update_type(self): + """ + Handles updates on the type field. + """ + + new_type = self.module.params.get("type") + auto_disk_resize = self.module.params.get("auto_disk_resize") + migration_type = self.module.params.get("migration_type") + + previously_booted = self._instance.status == "running" + + if new_type is None or new_type == self._instance.type.id: + return + + resize_poller = self.client.polling.event_poller_create( + "linode", "linode_resize", entity_id=self._instance.id + ) + + self.client.polling.wait_for_entity_free( + "linode", + self._instance.id, + timeout=self._timeout_ctx.seconds_remaining, + ) + + self._instance.resize( + new_type=new_type, + allow_auto_disk_resize=auto_disk_resize, + migration_type=migration_type, + ) + + self.register_action( + f"Resized instance from type {self._instance.type.id} to {new_type}" + ) + + resize_poller.wait_for_next_event_finished( + timeout=self._timeout_ctx.seconds_remaining + ) + + # The boot process for the instance is handled implicitly by the resize operation, + # so we wait for the instance to reach running status if necessary. + if previously_booted: + self._wait_for_instance_status("running") + + def _update_region(self): + """ + Handles updates on the region field. + """ + + new_region = self.module.params.get("region") + migration_type = self.module.params.get("migration_type") + + if new_region is None or new_region == self._instance.region.id: + return + + migration_poller = self.client.polling.event_poller_create( + "linode", "linode_migrate_datacenter", entity_id=self._instance.id + ) + + self.client.polling.wait_for_entity_free( + "linode", + self._instance.id, + timeout=self._timeout_ctx.seconds_remaining, + ) + + # TODO: Include type change in request if possible + self._instance.initiate_migration( + region=new_region, + migration_type=migration_type, + ) + + self.register_action( + f"Migrated Instance from {self._instance.region.id} to {new_region}" + ) + + migration_poller.wait_for_next_event_finished( + timeout=self._timeout_ctx.seconds_remaining + ) + def _update_config( self, config: Config, config_params: Dict[str, Any] ) -> None: @@ -1037,6 +1144,8 @@ def _update_instance(self) -> None: "boot_config_label", "reboot", "backups_enabled", + "type", + "region", ): continue @@ -1094,6 +1203,12 @@ def _update_instance(self) -> None: # Handle updating on the target Firewall ID self._update_firewall() + # Handle migrating the instance if necessary + self._update_region() + + # Handle updating the instance type + self._update_type() + def _handle_instance_boot(self) -> None: boot_status = self.module.params.get("booted") should_poll = self.module.params.get("wait") diff --git a/tests/integration/targets/instance_region_migration/tasks/main.yaml b/tests/integration/targets/instance_region_migration/tasks/main.yaml new file mode 100644 index 00000000..20c0c0cd --- /dev/null +++ b/tests/integration/targets/instance_region_migration/tasks/main.yaml @@ -0,0 +1,52 @@ +- name: instance_region_migrations + block: + - set_fact: + r: "{{ 1000000000 | random }}" + + - linode.cloud.instance: + label: 'ansible-test-{{ r }}' + region: us-mia + type: g6-nanode-1 + image: linode/ubuntu23.10 + state: present + register: create + + - assert: + that: + - create.changed + - create.instance.region == "us-mia" + + - linode.cloud.instance: + label: 'ansible-test-{{ r }}' + region: us-iad + type: g6-nanode-1 + image: linode/ubuntu23.10 + migration_type: warm + state: present + register: migrated + + - assert: + that: + - migrated.changed + - migrated.instance.region == "us-iad" + + always: + - ignore_errors: yes + block: + - linode.cloud.instance: + label: '{{ create.instance.label }}' + state: absent + register: delete + + - assert: + that: + - delete.changed + - delete.instance.id == create.instance.id + + environment: + LINODE_UA_PREFIX: '{{ ua_prefix }}' + LINODE_API_TOKEN: '{{ api_token }}' + LINODE_API_URL: '{{ api_url }}' + LINODE_API_VERSION: '{{ api_version }}' + LINODE_CA: '{{ ca_file or "" }}' + diff --git a/tests/integration/targets/instance_type_change/tasks/main.yaml b/tests/integration/targets/instance_type_change/tasks/main.yaml new file mode 100644 index 00000000..82e6c29c --- /dev/null +++ b/tests/integration/targets/instance_type_change/tasks/main.yaml @@ -0,0 +1,77 @@ +- name: instance_type_change + block: + - set_fact: + r: "{{ 1000000000 | random }}" + + - linode.cloud.instance: + label: 'ansible-test-{{ r }}' + region: us-mia + type: g6-nanode-1 + image: linode/ubuntu23.10 + state: present + register: create + + - assert: + that: + - create.changed + - create.instance.type == "g6-nanode-1" + + - linode.cloud.type_list: + filters: + - name: label + values: Linode 2GB + register: type_info + + - name: Resize the instance with resizing disks + linode.cloud.instance: + label: 'ansible-test-{{ r }}' + region: us-mia + type: g6-standard-1 + image: linode/ubuntu23.10 + auto_disk_resize: true + state: present + register: resize_disks + + - assert: + that: + - resize_disks.changed + - resize_disks.instance.type == "g6-standard-1" + - resize_disks.disks[0].size == type_info.types[0].disk - 512 + + - name: Run a warm resize without resizing disks + linode.cloud.instance: + label: 'ansible-test-{{ r }}' + region: us-mia + type: g6-standard-2 + image: linode/ubuntu23.10 + migration_type: warm + state: present + register: resize_disks + + - assert: + that: + - resize_disks.changed + - resize_disks.instance.type == "g6-standard-2" + - resize_disks.disks[0].size == type_info.types[0].disk - 512 + + + always: + - ignore_errors: yes + block: + - linode.cloud.instance: + label: '{{ create.instance.label }}' + state: absent + register: delete + + - assert: + that: + - delete.changed + - delete.instance.id == create.instance.id + + environment: + LINODE_UA_PREFIX: '{{ ua_prefix }}' + LINODE_API_TOKEN: '{{ api_token }}' + LINODE_API_URL: '{{ api_url }}' + LINODE_API_VERSION: '{{ api_version }}' + LINODE_CA: '{{ ca_file or "" }}' + From 635806f1538dc179563c27b175d013e1ba01cc72 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 28 Nov 2023 14:27:47 -0500 Subject: [PATCH 2/4] fix lint --- docs/inventory/instance.rst | 6 +++--- docs/modules/instance.md | 4 +++- plugins/modules/instance.py | 4 ++-- pyproject.toml | 1 + requirements-dev.txt | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/inventory/instance.rst b/docs/inventory/instance.rst index 69ab6f74..195d8afa 100644 --- a/docs/inventory/instance.rst +++ b/docs/inventory/instance.rst @@ -94,13 +94,13 @@ Parameters **default_value (type=str):** \• The default value when the host variable's value is an empty string. - \• This option is mutually exclusive with \ :literal:`trailing\_separator`\ . + \• This option is mutually exclusive with \ :literal:`keyed\_groups[].trailing\_separator`\ . **trailing_separator (type=bool, default=True):** - \• Set this option to \ :emphasis:`False`\ to omit the \ :literal:`separator`\ after the host variable when the value is an empty string. + \• Set this option to \ :literal:`False`\ to omit the \ :literal:`keyed\_groups[].separator`\ after the host variable when the value is an empty string. - \• This option is mutually exclusive with \ :literal:`default\_value`\ . + \• This option is mutually exclusive with \ :literal:`keyed\_groups[].default\_value`\ . diff --git a/docs/modules/instance.md b/docs/modules/instance.md index 8057cf69..628ed827 100644 --- a/docs/modules/instance.md +++ b/docs/modules/instance.md @@ -124,9 +124,11 @@ Manage Linode Instances, Configs, and Disks. | [`metadata` (sub-options)](#metadata) |
`dict`
|
Optional
| Fields relating to the Linode Metadata service. | | `backups_enabled` |
`bool`
|
Optional
| Enroll Instance in Linode Backup service. | | `wait` |
`bool`
|
Optional
| Wait for the instance to have status "running" before returning. **(Default: `True`)** | -| `wait_timeout` |
`int`
|
Optional
| The amount of time, in seconds, to wait for an instance to have status "running". **(Default: `240`)** | +| `wait_timeout` |
`int`
|
Optional
| The amount of time, in seconds, to wait for an instance to have status "running". **(Default: `1500`)** | | [`additional_ipv4` (sub-options)](#additional_ipv4) |
`list`
|
Optional
| Additional ipv4 addresses to allocate. | | `rebooted` |
`bool`
|
Optional
| If true, the Linode Instance will be rebooted. NOTE: The instance will only be rebooted if it was previously in a running state. To ensure your Linode will always be rebooted, consider also setting the `booted` field. **(Default: `False`)** | +| `migration_type` |
`str`
|
Optional
| The type of migration to use for Region and Type migrations. **(Choices: `cold`, `warm`; Default: `cold`)** | +| `auto_disk_resize` |
`bool`
|
Optional
| Whether implicitly created disks should be resized during a type change operation. **(Default: `False`)** | ### configs diff --git a/plugins/modules/instance.py b/plugins/modules/instance.py index 1cba30e7..a3bcd70f 100644 --- a/plugins/modules/instance.py +++ b/plugins/modules/instance.py @@ -893,7 +893,7 @@ def poll_func() -> bool: f"failed to wait for instance to reach status {status}: timeout period expired" ) - def _update_type(self): + def _update_type(self) -> None: """ Handles updates on the type field. """ @@ -936,7 +936,7 @@ def _update_type(self): if previously_booted: self._wait_for_instance_status("running") - def _update_region(self): + def _update_region(self) -> None: """ Handles updates on the region field. """ diff --git a/pyproject.toml b/pyproject.toml index 3b583334..37b68655 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ disable = [ "missing-timeout", "use-sequence-for-iteration", "broad-exception-raised", + "fixme" ] py-version = "3.8" extension-pkg-whitelist = "math" diff --git a/requirements-dev.txt b/requirements-dev.txt index bcd48c2c..1600415f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ boto3>=1.26.0 botocore>=1.29.0 pylint>=2.15.5 -ansible-doc-extractor==0.1.10 +ansible-doc-extractor>=0.1.10 mypy>=1.3.0 ansible>=7.5.0 Jinja2>=3.0.1 From 7fa2170cb65c7c929db206029da7c77c84edb1bb Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 4 Dec 2023 10:33:09 -0500 Subject: [PATCH 3/4] Add type checks to update_region and update_type --- plugins/modules/instance.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/plugins/modules/instance.py b/plugins/modules/instance.py index a3bcd70f..9f714997 100644 --- a/plugins/modules/instance.py +++ b/plugins/modules/instance.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Union, cast import ansible_collections.linode.cloud.plugins.module_utils.doc_fragments.instance as docs +import linode_api4 import polling from ansible_collections.linode.cloud.plugins.module_utils.linode_common import ( LinodeModuleBase, @@ -902,9 +903,16 @@ def _update_type(self) -> None: auto_disk_resize = self.module.params.get("auto_disk_resize") migration_type = self.module.params.get("migration_type") + # Graceful handling for a potential edge case + # where the type is stored as a string rather than + # an instance of the Type class. + current_type = self._instance.type + if isinstance(current_type, linode_api4.Type): + current_type = current_type.id + previously_booted = self._instance.status == "running" - if new_type is None or new_type == self._instance.type.id: + if new_type is None or new_type == current_type: return resize_poller = self.client.polling.event_poller_create( @@ -944,7 +952,14 @@ def _update_region(self) -> None: new_region = self.module.params.get("region") migration_type = self.module.params.get("migration_type") - if new_region is None or new_region == self._instance.region.id: + # Graceful handling for a potential edge case + # where the region is stored as a string rather than + # an instance of the Region class. + current_region = self._instance.region + if isinstance(current_region, linode_api4.Region): + current_region = current_region.id + + if new_region is None or new_region == current_region: return migration_poller = self.client.polling.event_poller_create( From 4523b30c67e98d7b510aee3150a05c457ad44db2 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 5 Dec 2023 12:43:54 -0500 Subject: [PATCH 4/4] Point requirements.txt at project branch --- plugins/modules/instance.py | 3 ++- requirements.txt | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/modules/instance.py b/plugins/modules/instance.py index 9f714997..878739bf 100644 --- a/plugins/modules/instance.py +++ b/plugins/modules/instance.py @@ -972,7 +972,8 @@ def _update_region(self) -> None: timeout=self._timeout_ctx.seconds_remaining, ) - # TODO: Include type change in request if possible + # TODO: Include type change in request if necessary + # so only one migration needs to be run. self._instance.initiate_migration( region=new_region, migration_type=migration_type, diff --git a/requirements.txt b/requirements.txt index 380149c1..980077a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ -linode-api4>=5.10.0 +# TODO: Revert once Unified Migration support is released in linode_api4 +# linode-api4>=5.10.0 +git+https://github.com/linode/linode_api4-python@proj/unified-migrations polling>=0.3.2 types-requests==2.31.0.10 ansible-specdoc>=0.0.14 \ No newline at end of file