mirror of
https://github.com/kubevirt/kubevirt.core.git
synced 2026-07-25 00:44:35 +00:00
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:
10
examples/play-restart.yml
Normal file
10
examples/play-restart.yml
Normal 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
10
examples/play-start.yml
Normal 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
10
examples/play-stop.yml
Normal 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
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user