diff --git a/examples/play-restart.yml b/examples/play-restart.yml new file mode 100644 index 0000000..9daf3f2 --- /dev/null +++ b/examples/play-restart.yml @@ -0,0 +1,10 @@ +--- +- name: Playbook restarting a virtual machine + hosts: localhost + tasks: + - name: Restart VM + kubevirt.core.kubevirt_vm: + state: restarted + name: testvm + namespace: default + wait: true diff --git a/examples/play-start.yml b/examples/play-start.yml new file mode 100644 index 0000000..c0816ba --- /dev/null +++ b/examples/play-start.yml @@ -0,0 +1,10 @@ +--- +- name: Playbook starting a virtual machine + hosts: localhost + tasks: + - name: Start VM + kubevirt.core.kubevirt_vm: + state: started + name: testvm + namespace: default + wait: true diff --git a/examples/play-stop.yml b/examples/play-stop.yml new file mode 100644 index 0000000..0ae2aa1 --- /dev/null +++ b/examples/play-stop.yml @@ -0,0 +1,10 @@ +--- +- name: Playbook stopping a virtual machine + hosts: localhost + tasks: + - name: Stop VM + kubevirt.core.kubevirt_vm: + state: stopped + name: testvm + namespace: default + wait: true diff --git a/plugins/modules/kubevirt_vm.py b/plugins/modules/kubevirt_vm.py index 4e7799c..c401bf2 100644 --- a/plugins/modules/kubevirt_vm.py +++ b/plugins/modules/kubevirt_vm.py @@ -11,13 +11,13 @@ __metaclass__ = type DOCUMENTATION = """ module: kubevirt_vm -short_description: Create or delete KubeVirt VirtualMachines +short_description: Manage KubeVirt VirtualMachines author: - "KubeVirt.io Project (!UNKNOWN)" description: -- Use the Kubernetes Python client to perform create or delete operations on KubeVirt VirtualMachines. +- Use the Kubernetes Python client to perform create, delete, start, stop, or restart operations on KubeVirt VirtualMachines. - Pass options to create the VirtualMachine as module arguments. - Authenticate using either a config file, certificates, password or token. - Supports check mode. @@ -76,6 +76,13 @@ options: - RerunOnFailure - Once version_added: 2.0.0 + grace_period_seconds: + description: + - Specify the grace period in seconds for stopping or restarting the C(VirtualMachine). + - Only used when O(state=stopped) or O(state=restarted). + - When used with O(state=restarted), the only supported values are V(null) (use the default grace period) and V(0) (force restart immediately). + type: int + version_added: 2.3.0 instancetype: description: - Specify the C(Instancetype) matcher of the C(VirtualMachine). @@ -144,15 +151,21 @@ options: type: str state: description: - - Determines if an object should be created, patched, or deleted. + - Determines if an object should be created, patched, deleted, started, stopped, or restarted. - When set to O(state=present), an object will be created, if it does not already exist. - If set to O(state=absent), an existing object will be deleted. - If set to O(state=present), an existing object will be patched, if its attributes differ from those specified. + - If set to O(state=started), it will be ensured that the C(VirtualMachine) is running. Added in version 2.3.0. + - If set to O(state=stopped), it will be ensured that the C(VirtualMachine) is stopped. Added in version 2.3.0. + - If set to O(state=restarted), it will be ensured that the C(VirtualMachine) is restarted. Added in version 2.3.0. type: str default: present choices: - absent - present + - restarted + - started + - stopped force: description: - If set to O(force=yes), and O(state=present) is set, an existing object will be replaced. @@ -258,6 +271,35 @@ EXAMPLES = """ name: testvm namespace: default state: absent + +- name: Start a VirtualMachine + kubevirt.core.kubevirt_vm: + name: testvm + namespace: default + state: started + wait: true + +- name: Stop a VirtualMachine + kubevirt.core.kubevirt_vm: + name: testvm + namespace: default + state: stopped + wait: true + +- name: Restart a VirtualMachine + kubevirt.core.kubevirt_vm: + name: testvm + namespace: default + state: restarted + wait: true + +- name: Force restart a VirtualMachine + kubevirt.core.kubevirt_vm: + name: testvm + namespace: default + state: restarted + grace_period_seconds: 0 + wait: true """ RETURN = """ @@ -300,12 +342,24 @@ from ansible_collections.kubernetes.core.plugins.module_utils.args_common import from ansible_collections.kubernetes.core.plugins.module_utils.k8s import ( runner, ) +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.client import ( + get_api_client, + K8SClient, +) from ansible_collections.kubernetes.core.plugins.module_utils.k8s.core import ( AnsibleK8SModule, ) from ansible_collections.kubernetes.core.plugins.module_utils.k8s.exceptions import ( CoreException, ) +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.service import ( + K8sService, +) + +try: + from kubernetes.client.exceptions import ApiException +except ImportError: + pass WAIT_CONDITION_READY = {"type": "Ready", "status": True} WAIT_CONDITION_VMI_NOT_EXISTS = { @@ -313,6 +367,8 @@ WAIT_CONDITION_VMI_NOT_EXISTS = { "status": False, "reason": "VMINotExists", } +SUBRESOURCE_API = "subresources.kubevirt.io/v1" +VM_IS_ALREADY_IN_DESIRED_STATE_ERROR_CODE = 409 def create_vm(params: Dict) -> Dict: @@ -380,6 +436,133 @@ def set_wait_condition(module: AnsibleK8SModule) -> None: module.params["wait_condition"] = WAIT_CONDITION_READY +def _apply_definition(module: AnsibleK8SModule) -> Dict: + definition = create_vm(module.params) + + original_state = module.params["state"] + original_wait = module.params.get("wait") + if original_state not in ("present", "absent"): + module.params["state"] = "present" + # Don't wait during the CRUD step for subresource states; + # the subresource action handles the final wait. + module.params["wait"] = False + # Don't override running/runStrategy unless the user explicitly set + # them, so the CRUD step won't unintentionally change the VM's + # lifecycle state before the subresource action runs. + if ( + module.params.get("running") is None + and module.params.get("run_strategy") is None + ): + definition["spec"].pop("running", None) + definition["spec"].pop("runStrategy", None) + + module.params["resource_definition"] = definition + + set_wait_condition(module) + + captured = {} + original_exit_json = module.exit_json + module.exit_json = lambda **kwargs: captured.update(kwargs) + try: + runner.run_module(module) + except CoreException as exc: + module.fail_from_exception(exc) + finally: + module.exit_json = original_exit_json + module.params["state"] = original_state + module.params["wait"] = original_wait + + return captured + + +def _call_subresource( + client: K8SClient, + module: AnsibleK8SModule, + action: str, + body: Dict, + wait_condition: Dict, + ignore_conflict: bool = False, +) -> Dict: + name = module.params["name"] + namespace = module.params["namespace"] + + path = ( + f"/apis/{SUBRESOURCE_API}" + f"/namespaces/{namespace}" + f"/virtualmachines/{name}/{action}" + ) + + try: + client.client.request("put", path, body=body, header_params={"Accept": "*/*"}) + except ApiException as exc: + # 409 Conflict means the VM is already in the desired state or + # its RunStrategy does not support manual start requests. + if ignore_conflict and exc.status == VM_IS_ALREADY_IN_DESIRED_STATE_ERROR_CODE: + return {"changed": False} + module.fail_json( + msg=f"Failed to {action} VirtualMachine '{name}': {exc}", + ) + + result = {"changed": True} + + if module.params.get("wait"): + try: + svc = K8sService(client, module) + wait_result = svc.find( + kind="VirtualMachine", + api_version=module.params["api_version"], + name=name, + namespace=namespace, + wait=True, + wait_sleep=module.params["wait_sleep"], + wait_timeout=module.params["wait_timeout"], + condition=wait_condition, + ) + result.update(wait_result) + except CoreException as exc: + module.fail_from_exception(exc) + + return result + + +def start_vm(client: K8SClient, module: AnsibleK8SModule) -> Dict: + if module.check_mode: + return {"changed": True} + + return _call_subresource( + client, module, "start", None, WAIT_CONDITION_READY, ignore_conflict=True + ) + + +def stop_vm(client: K8SClient, module: AnsibleK8SModule) -> Dict: + if module.check_mode: + return {"changed": True} + + grace_period_seconds = module.params.get("grace_period_seconds") + body = None + if grace_period_seconds is not None: + body = {"gracePeriod": grace_period_seconds} + return _call_subresource( + client, + module, + "stop", + body, + WAIT_CONDITION_VMI_NOT_EXISTS, + ignore_conflict=True, + ) + + +def restart_vm(client: K8SClient, module: AnsibleK8SModule) -> Dict: + if module.check_mode: + return {"changed": True} + + grace_period_seconds = module.params.get("grace_period_seconds") + body = None + if grace_period_seconds is not None: + body = {"gracePeriodSeconds": grace_period_seconds} + return _call_subresource(client, module, "restart", body, WAIT_CONDITION_READY) + + def arg_spec() -> Dict: """ arg_spec defines the argument spec of this module. @@ -395,6 +578,7 @@ def arg_spec() -> Dict: "run_strategy": { "choices": ["Always", "Halted", "Manual", "RerunOnFailure", "Once"] }, + "grace_period_seconds": {"type": "int"}, "instancetype": {"type": "dict"}, "preference": {"type": "dict"}, "data_volume_templates": {"type": "list", "elements": "dict"}, @@ -430,6 +614,13 @@ def arg_spec() -> Dict: spec.update(deepcopy(AUTH_ARG_SPEC)) spec.update(deepcopy(COMMON_ARG_SPEC)) + # Override state choices from COMMON_ARG_SPEC which only defines + # ["present", "absent"], adding our subresource action states. + spec["state"] = { + "default": "present", + "choices": ["absent", "present", "restarted", "started", "stopped"], + } + return spec @@ -448,19 +639,27 @@ def main() -> None: required_one_of=[ ("name", "generate_name"), ], + required_if=[ + ("state", "started", ("name",)), + ("state", "stopped", ("name",)), + ("state", "restarted", ("name",)), + ], supports_check_mode=True, ) - # Set resource_definition to our constructed VM - module.params["resource_definition"] = create_vm(module.params) + result = _apply_definition(module) - # Set wait_condition to allow waiting for the ready state of the VirtualMachine - set_wait_condition(module) + subresource_actions = { + "started": start_vm, + "stopped": stop_vm, + "restarted": restart_vm, + } + if (action := subresource_actions.get(module.params["state"])) is not None: + client = get_api_client(module) + action_result = action(client, module) + result["changed"] = result["changed"] or action_result.get("changed", False) - try: - runner.run_module(module) - except CoreException as exc: - module.fail_from_exception(exc) + module.exit_json(**result) if __name__ == "__main__": diff --git a/tests/integration/targets/kubevirt_vm/verify.yml.j2 b/tests/integration/targets/kubevirt_vm/verify.yml.j2 index 1f631c1..ddcd120 100644 --- a/tests/integration/targets/kubevirt_vm/verify.yml.j2 +++ b/tests/integration/targets/kubevirt_vm/verify.yml.j2 @@ -21,6 +21,341 @@ recreate.diff.before.metadata.annotations.get('kubemacpool.io/transaction-timestamp') and not recreate.diff.after.metadata.annotations +- name: Stop VM + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Stop the VM + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: stopped + wait: true + wait_timeout: 300 + register: stop_result + - name: Assert VM was stopped + ansible.builtin.assert: + that: + - stop_result.changed + +- name: Verify VM is stopped + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Get VM info + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: vm_info + - name: Assert VM is not ready + ansible.builtin.assert: + that: + - not vm_info.resources[0].status.get('ready', False) + +- name: Stop already-stopped VM is idempotent + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Stop the VM again + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: stopped + register: stop_again + - name: Assert module reported no changes + ansible.builtin.assert: + that: + - not stop_again.changed + +- name: Start VM + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Start the VM + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: started + wait: true + wait_timeout: 600 + register: start_result + - name: Assert VM was started + ansible.builtin.assert: + that: + - start_result.changed + +- name: Verify VM is running after start + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Get VM info + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + wait: true + wait_timeout: 600 + register: vm_info + - name: Assert VM is ready + ansible.builtin.assert: + that: + - vm_info.resources[0].status.ready + +- name: Start already-running VM is idempotent + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Start the VM again + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: started + register: start_again + - name: Assert module reported no changes + ansible.builtin.assert: + that: + - not start_again.changed + +- name: Verify VM definition is untouched when only name and state started are provided + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Snapshot VM definition before + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: before + - name: Run module with only name and state + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: started + register: noop_result + - name: Snapshot VM definition after + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: after + - name: Assert definition was not changed + ansible.builtin.assert: + that: + - not noop_result.changed + - before.resources[0].spec.template.spec == after.resources[0].spec.template.spec + - before.resources[0].metadata.labels == after.resources[0].metadata.labels + - before.resources[0].metadata.annotations == after.resources[0].metadata.annotations + +- name: Verify VM definition is untouched when only name and state restarted are provided + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Snapshot VM definition before + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: before + - name: Run module with only name and state restarted + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: restarted + wait: true + wait_timeout: 600 + register: restart_noop_result + - name: Snapshot VM definition after + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + wait: true + wait_timeout: 600 + register: after + - name: Assert definition was not changed + ansible.builtin.assert: + that: + - restart_noop_result.changed + - before.resources[0].spec.template.spec == after.resources[0].spec.template.spec + - before.resources[0].metadata.labels == after.resources[0].metadata.labels + - before.resources[0].metadata.annotations == after.resources[0].metadata.annotations + +- name: Verify VM definition is untouched when only name and state stopped are provided + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Snapshot VM definition before + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: before + - name: Run module with only name and state stopped + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: stopped + wait: true + wait_timeout: 300 + register: stop_noop_result + - name: Snapshot VM definition after + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: after + - name: Assert definition was not changed + ansible.builtin.assert: + that: + - stop_noop_result.changed + - before.resources[0].spec.template.spec == after.resources[0].spec.template.spec + - before.resources[0].metadata.labels == after.resources[0].metadata.labels + - before.resources[0].metadata.annotations == after.resources[0].metadata.annotations + +- name: Re-start VM for remaining tests + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Start the VM again + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: started + wait: true + wait_timeout: 600 + +- name: Restart VM + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Restart the VM + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: restarted + wait: true + wait_timeout: 600 + register: restart_result + - name: Assert VM was restarted + ansible.builtin.assert: + that: + - restart_result.changed + +- name: Verify VM is running after restart + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Get VM info + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + wait: true + wait_timeout: 600 + register: vm_info + - name: Assert VM is ready + ansible.builtin.assert: + that: + - vm_info.resources[0].status.ready + +- name: Update labels and stop VM at the same time + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Add labels and stop the VM + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: stopped + labels: + environment: stopped + wait: true + wait_timeout: 300 + register: combined_stop + - name: Assert VM was changed + ansible.builtin.assert: + that: + - combined_stop.changed + - name: Get VM info + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + register: vm_info + - name: Assert labels were applied and VM is stopped + ansible.builtin.assert: + that: + - not vm_info.resources[0].status.get('ready', False) + - vm_info.resources[0].metadata.labels.environment == 'stopped' + +- name: Update labels and start VM at the same time + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Update labels and start the VM + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: started + labels: + environment: started + wait: true + wait_timeout: 600 + register: combined_start + - name: Assert VM was changed + ansible.builtin.assert: + that: + - combined_start.changed + - name: Get VM info + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + wait: true + wait_timeout: 600 + register: vm_info + - name: Assert labels were applied and VM is running + ansible.builtin.assert: + that: + - vm_info.resources[0].status.ready + - vm_info.resources[0].metadata.labels.environment == 'started' + +- name: Update labels and restart VM at the same time + connection: local + gather_facts: false + hosts: localhost + tasks: + - name: Update labels and restart the VM + kubevirt.core.kubevirt_vm: + name: testvm + namespace: {{ NAMESPACE }} + state: restarted + labels: + environment: restarted + wait: true + wait_timeout: 600 + register: combined_restart + - name: Assert VM was changed + ansible.builtin.assert: + that: + - combined_restart.changed + - name: Get VM info + kubevirt.core.kubevirt_vm_info: + name: testvm + namespace: {{ NAMESPACE }} + wait: true + wait_timeout: 600 + register: vm_info + - name: Assert labels were applied and VM is running + ansible.builtin.assert: + that: + - vm_info.resources[0].status.ready + - vm_info.resources[0].metadata.labels.environment == 'restarted' + - name: Delete VM connection: local gather_facts: false diff --git a/tests/unit/plugins/modules/test_kubevirt_vm.py b/tests/unit/plugins/modules/test_kubevirt_vm.py index bff63da..9ced106 100644 --- a/tests/unit/plugins/modules/test_kubevirt_vm.py +++ b/tests/unit/plugins/modules/test_kubevirt_vm.py @@ -11,6 +11,7 @@ import pytest from ansible.module_utils.basic import AnsibleModule from ansible_collections.kubernetes.core.plugins.module_utils.k8s import runner from ansible_collections.kubevirt.core.plugins.modules import kubevirt_vm +from kubernetes.client.exceptions import ApiException from ansible_collections.kubevirt.core.tests.unit.utils.ansible_module_mock import ( AnsibleFailJson, AnsibleExitJson, @@ -159,6 +160,7 @@ MODULE_PARAMS_DEFAULT = { "state": "present", "force": False, "delete_options": None, + "grace_period_seconds": None, } MODULE_PARAMS_CREATE = MODULE_PARAMS_DEFAULT | { @@ -334,7 +336,7 @@ def test_module(mocker, module_params, k8s_module_params, vm_definition, method) mocker.patch.object(AnsibleModule, "exit_json", exit_json) mocker.patch.object(runner, "get_api_client") - perform_action = mocker.patch.object( + mock_perform_action = mocker.patch.object( runner, "perform_action", return_value={ @@ -347,7 +349,7 @@ def test_module(mocker, module_params, k8s_module_params, vm_definition, method) with pytest.raises(AnsibleExitJson), patch_module_args(module_params): kubevirt_vm.main() - perform_action.assert_called_once_with( + mock_perform_action.assert_called_once_with( mocker.ANY, vm_definition, k8s_module_params, @@ -711,3 +713,264 @@ def test_set_wait_condition(mocker, params, expected): kubevirt_vm.set_wait_condition(module) assert module.params == params | expected + + +MODULE_PARAMS_START = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": "started", +} + +MODULE_PARAMS_STOP = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": "stopped", +} + +MODULE_PARAMS_STOP_FORCE = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": "stopped", + "grace_period_seconds": 0, +} + +MODULE_PARAMS_RESTART = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": "restarted", +} + +MODULE_PARAMS_RESTART_FORCE = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": "restarted", + "grace_period_seconds": 0, +} + +SUBRESOURCE_BASE_PATH = ( + f"/apis/{kubevirt_vm.SUBRESOURCE_API}/namespaces/default/virtualmachines/testvm" +) + + +@pytest.mark.parametrize( + "module_params,action,expected_body", + [ + (MODULE_PARAMS_START, "start", None), + (MODULE_PARAMS_STOP, "stop", None), + (MODULE_PARAMS_STOP_FORCE, "stop", {"gracePeriod": 0}), + (MODULE_PARAMS_RESTART, "restart", None), + (MODULE_PARAMS_RESTART_FORCE, "restart", {"gracePeriodSeconds": 0}), + ], +) +def test_subresource_action(mocker, module_params, action, expected_body): + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + mocker.patch.object( + kubevirt_vm, "_apply_definition", return_value={"changed": False} + ) + mock_client = mocker.Mock() + mocker.patch.object(kubevirt_vm, "get_api_client", return_value=mock_client) + + with pytest.raises(AnsibleExitJson) as exc, patch_module_args(module_params): + kubevirt_vm.main() + + mock_client.client.request.assert_called_once_with( + "put", + f"{SUBRESOURCE_BASE_PATH}/{action}", + body=expected_body, + header_params={"Accept": "*/*"}, + ) + assert exc.value.args[0]["changed"] is True + + +@pytest.mark.parametrize("state", ["started", "stopped", "restarted"]) +def test_subresource_action_check_mode(mocker, state): + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + mocker.patch.object( + kubevirt_vm, "_apply_definition", return_value={"changed": False} + ) + mock_client = mocker.Mock() + mocker.patch.object(kubevirt_vm, "get_api_client", return_value=mock_client) + + params = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": state, + "_ansible_check_mode": True, + } + with pytest.raises(AnsibleExitJson) as exc, patch_module_args(params): + kubevirt_vm.main() + + mock_client.client.request.assert_not_called() + assert exc.value.args[0]["changed"] is True + + +@pytest.mark.parametrize("state", ["started", "stopped", "restarted"]) +def test_subresource_action_fails_without_name(mocker, state): + mocker.patch.object(AnsibleModule, "fail_json", fail_json) + + params = MODULE_PARAMS_DEFAULT | { + "namespace": "default", + "state": state, + "generate_name": "testvm-", + } + with pytest.raises(AnsibleFailJson), patch_module_args(params): + kubevirt_vm.main() + + +@pytest.mark.parametrize( + "state,action", + [ + ("started", "start"), + ("stopped", "stop"), + ("restarted", "restart"), + ], +) +def test_subresource_action_api_error(mocker, state, action): + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + mocker.patch.object(AnsibleModule, "fail_json", fail_json) + mocker.patch.object( + kubevirt_vm, "_apply_definition", return_value={"changed": False} + ) + + mock_client = mocker.Mock() + mock_client.client.request.side_effect = ApiException( + status=422, reason="API error" + ) + mocker.patch.object(kubevirt_vm, "get_api_client", return_value=mock_client) + + params = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": state, + } + with pytest.raises(AnsibleFailJson) as exc, patch_module_args(params): + kubevirt_vm.main() + + assert f"Failed to {action} VirtualMachine" in exc.value.args[0]["msg"] + + +@pytest.mark.parametrize( + "state,expected_condition", + [ + ("started", kubevirt_vm.WAIT_CONDITION_READY), + ("stopped", kubevirt_vm.WAIT_CONDITION_VMI_NOT_EXISTS), + ("restarted", kubevirt_vm.WAIT_CONDITION_READY), + ], +) +def test_subresource_action_wait(mocker, state, expected_condition): + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + mocker.patch.object( + kubevirt_vm, "_apply_definition", return_value={"changed": False} + ) + mock_client = mocker.Mock() + mocker.patch.object(kubevirt_vm, "get_api_client", return_value=mock_client) + + mock_svc = mocker.Mock() + mock_svc.find.return_value = {"resources": [], "api_found": True} + mocker.patch.object(kubevirt_vm, "K8sService", return_value=mock_svc) + + params = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": state, + "wait": True, + } + with pytest.raises(AnsibleExitJson) as exc, patch_module_args(params): + kubevirt_vm.main() + + mock_svc.find.assert_called_once_with( + kind="VirtualMachine", + api_version="kubevirt.io/v1", + name="testvm", + namespace="default", + wait=True, + wait_sleep=5, + wait_timeout=5, + condition=expected_condition, + ) + assert exc.value.args[0]["changed"] is True + + +@pytest.mark.parametrize("state", ["started", "stopped"]) +def test_subresource_action_idempotent(mocker, state): + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + mocker.patch.object( + kubevirt_vm, "_apply_definition", return_value={"changed": False} + ) + + mock_client = mocker.Mock() + mock_client.client.request.side_effect = ApiException(status=409, reason="Conflict") + mocker.patch.object(kubevirt_vm, "get_api_client", return_value=mock_client) + + params = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": state, + } + with pytest.raises(AnsibleExitJson) as exc, patch_module_args(params): + kubevirt_vm.main() + + assert exc.value.args[0]["changed"] is False + + +@pytest.mark.parametrize( + "state,action,definition_changed", + [ + ("started", "start", True), + ("stopped", "stop", True), + ("restarted", "restart", True), + ("started", "start", False), + ("stopped", "stop", False), + ("restarted", "restart", False), + ], +) +def test_subresource_action_with_definition(mocker, state, action, definition_changed): + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + + mock_client = mocker.Mock() + mocker.patch.object(kubevirt_vm, "get_api_client", return_value=mock_client) + + def fake_run_module(module): + module.exit_json(changed=definition_changed, method="update", result={}) + + mocker.patch.object(runner, "run_module", side_effect=fake_run_module) + + params = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": state, + "labels": {"app": "test"}, + } + with pytest.raises(AnsibleExitJson) as exc, patch_module_args(params): + kubevirt_vm.main() + + runner.run_module.assert_called_once() + mock_client.client.request.assert_called_once_with( + "put", + f"{SUBRESOURCE_BASE_PATH}/{action}", + body=None, + header_params={"Accept": "*/*"}, + ) + assert exc.value.args[0]["changed"] is (definition_changed or True) + + +def test_apply_definition_propagates_runner_error(mocker): + from ansible_collections.kubernetes.core.plugins.module_utils.k8s.exceptions import ( + CoreException, + ) + + mocker.patch.object(AnsibleModule, "exit_json", exit_json) + mocker.patch.object(AnsibleModule, "fail_json", fail_json) + mocker.patch.object( + runner, "run_module", side_effect=CoreException("cluster unreachable") + ) + + params = MODULE_PARAMS_DEFAULT | { + "name": "testvm", + "namespace": "default", + "state": "started", + } + with pytest.raises(AnsibleFailJson) as exc, patch_module_args(params): + kubevirt_vm.main() + + assert "cluster unreachable" in exc.value.args[0]["msg"]