Merge pull request #242 from jcanocan/start-stop-restart-vms

feat(kubevirt_vm): add `state=started/stopped/restarted` to manage VM lifecycle
This commit is contained in:
kubevirt-bot
2026-06-08 17:18:06 +02:00
committed by GitHub
6 changed files with 840 additions and 13 deletions

10
examples/play-restart.yml Normal file
View File

@@ -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

10
examples/play-start.yml Normal file
View File

@@ -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

10
examples/play-stop.yml Normal file
View File

@@ -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

View File

@@ -11,13 +11,13 @@ __metaclass__ = type
DOCUMENTATION = """ DOCUMENTATION = """
module: kubevirt_vm module: kubevirt_vm
short_description: Create or delete KubeVirt VirtualMachines short_description: Manage KubeVirt VirtualMachines
author: author:
- "KubeVirt.io Project (!UNKNOWN)" - "KubeVirt.io Project (!UNKNOWN)"
description: 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. - Pass options to create the VirtualMachine as module arguments.
- Authenticate using either a config file, certificates, password or token. - Authenticate using either a config file, certificates, password or token.
- Supports check mode. - Supports check mode.
@@ -76,6 +76,13 @@ options:
- RerunOnFailure - RerunOnFailure
- Once - Once
version_added: 2.0.0 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: instancetype:
description: description:
- Specify the C(Instancetype) matcher of the C(VirtualMachine). - Specify the C(Instancetype) matcher of the C(VirtualMachine).
@@ -144,15 +151,21 @@ options:
type: str type: str
state: state:
description: 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. - 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=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=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 type: str
default: present default: present
choices: choices:
- absent - absent
- present - present
- restarted
- started
- stopped
force: force:
description: description:
- If set to O(force=yes), and O(state=present) is set, an existing object will be replaced. - 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 name: testvm
namespace: default namespace: default
state: absent 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 = """ 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 ( from ansible_collections.kubernetes.core.plugins.module_utils.k8s import (
runner, 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 ( from ansible_collections.kubernetes.core.plugins.module_utils.k8s.core import (
AnsibleK8SModule, AnsibleK8SModule,
) )
from ansible_collections.kubernetes.core.plugins.module_utils.k8s.exceptions import ( from ansible_collections.kubernetes.core.plugins.module_utils.k8s.exceptions import (
CoreException, 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_READY = {"type": "Ready", "status": True}
WAIT_CONDITION_VMI_NOT_EXISTS = { WAIT_CONDITION_VMI_NOT_EXISTS = {
@@ -313,6 +367,8 @@ WAIT_CONDITION_VMI_NOT_EXISTS = {
"status": False, "status": False,
"reason": "VMINotExists", "reason": "VMINotExists",
} }
SUBRESOURCE_API = "subresources.kubevirt.io/v1"
VM_IS_ALREADY_IN_DESIRED_STATE_ERROR_CODE = 409
def create_vm(params: Dict) -> Dict: def create_vm(params: Dict) -> Dict:
@@ -380,6 +436,133 @@ def set_wait_condition(module: AnsibleK8SModule) -> None:
module.params["wait_condition"] = WAIT_CONDITION_READY 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: def arg_spec() -> Dict:
""" """
arg_spec defines the argument spec of this module. arg_spec defines the argument spec of this module.
@@ -395,6 +578,7 @@ def arg_spec() -> Dict:
"run_strategy": { "run_strategy": {
"choices": ["Always", "Halted", "Manual", "RerunOnFailure", "Once"] "choices": ["Always", "Halted", "Manual", "RerunOnFailure", "Once"]
}, },
"grace_period_seconds": {"type": "int"},
"instancetype": {"type": "dict"}, "instancetype": {"type": "dict"},
"preference": {"type": "dict"}, "preference": {"type": "dict"},
"data_volume_templates": {"type": "list", "elements": "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(AUTH_ARG_SPEC))
spec.update(deepcopy(COMMON_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 return spec
@@ -448,19 +639,27 @@ def main() -> None:
required_one_of=[ required_one_of=[
("name", "generate_name"), ("name", "generate_name"),
], ],
required_if=[
("state", "started", ("name",)),
("state", "stopped", ("name",)),
("state", "restarted", ("name",)),
],
supports_check_mode=True, supports_check_mode=True,
) )
# Set resource_definition to our constructed VM result = _apply_definition(module)
module.params["resource_definition"] = create_vm(module.params)
# Set wait_condition to allow waiting for the ready state of the VirtualMachine subresource_actions = {
set_wait_condition(module) "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: module.exit_json(**result)
runner.run_module(module)
except CoreException as exc:
module.fail_from_exception(exc)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -21,6 +21,341 @@
recreate.diff.before.metadata.annotations.get('kubemacpool.io/transaction-timestamp') and recreate.diff.before.metadata.annotations.get('kubemacpool.io/transaction-timestamp') and
not recreate.diff.after.metadata.annotations 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 - name: Delete VM
connection: local connection: local
gather_facts: false gather_facts: false

View File

@@ -11,6 +11,7 @@ import pytest
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible_collections.kubernetes.core.plugins.module_utils.k8s import runner from ansible_collections.kubernetes.core.plugins.module_utils.k8s import runner
from ansible_collections.kubevirt.core.plugins.modules import kubevirt_vm 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 ( from ansible_collections.kubevirt.core.tests.unit.utils.ansible_module_mock import (
AnsibleFailJson, AnsibleFailJson,
AnsibleExitJson, AnsibleExitJson,
@@ -159,6 +160,7 @@ MODULE_PARAMS_DEFAULT = {
"state": "present", "state": "present",
"force": False, "force": False,
"delete_options": None, "delete_options": None,
"grace_period_seconds": None,
} }
MODULE_PARAMS_CREATE = MODULE_PARAMS_DEFAULT | { 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(AnsibleModule, "exit_json", exit_json)
mocker.patch.object(runner, "get_api_client") mocker.patch.object(runner, "get_api_client")
perform_action = mocker.patch.object( mock_perform_action = mocker.patch.object(
runner, runner,
"perform_action", "perform_action",
return_value={ 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): with pytest.raises(AnsibleExitJson), patch_module_args(module_params):
kubevirt_vm.main() kubevirt_vm.main()
perform_action.assert_called_once_with( mock_perform_action.assert_called_once_with(
mocker.ANY, mocker.ANY,
vm_definition, vm_definition,
k8s_module_params, k8s_module_params,
@@ -711,3 +713,264 @@ def test_set_wait_condition(mocker, params, expected):
kubevirt_vm.set_wait_condition(module) kubevirt_vm.set_wait_condition(module)
assert module.params == params | expected 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"]