feat(kubevirt_vm): add state=start/stop/restart to manage VM lifecycle

It adds support for starting, stopping, and restarting VirtualMachines.
The operations are performed through the KubeVirt restart subresource
API (PUT to `subresources.kubevirt.io/v1`). These calls are extracted
into a single shared helper function `_call_subresource()` to handle the
common flow, i.e., check mode, API call and wait conditions.
Moreover, it adds the optional parameter  `grace_period_seconds` for
stop and start operations.

Additionally, it adds unit tests and integration tests for all
operations.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Javier Cano Cano <jcanocan@redhat.com>
This commit is contained in:
Javier Cano Cano
2026-05-11 15:48:59 +02:00
parent 19cc611159
commit 0475a8d39b
6 changed files with 840 additions and 13 deletions

View File

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

View File

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