Files
kubevirt.core/plugins/modules/kubevirt_vm.py
Javier Cano Cano 0475a8d39b 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>
2026-06-08 15:37:43 +02:00

668 lines
20 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright 2023 Red Hat, Inc.
# Based on the kubernetes.core.k8s module
# Apache License 2.0 (see LICENSE or http://www.apache.org/licenses/LICENSE-2.0)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = """
module: kubevirt_vm
short_description: Manage KubeVirt VirtualMachines
author:
- "KubeVirt.io Project (!UNKNOWN)"
description:
- 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.
extends_documentation_fragment:
- kubevirt.core.kubevirt_auth_options
options:
api_version:
description:
- Use this to set the API version of KubeVirt.
type: str
default: kubevirt.io/v1
name:
description:
- Specify the name of the C(VirtualMachine).
- This option is ignored when O(state=present) is not set.
- Mutually exclusive with O(generate_name).
type: str
generate_name:
description:
- Specify the basis of the C(VirtualMachine) name and random characters will be added automatically on the cluster to
generate a unique name.
- Only used when O(state=present).
- Mutually exclusive with O(name).
type: str
namespace:
description:
- Specify the namespace of the C(VirtualMachine).
type: str
required: yes
annotations:
description:
- Specify annotations to set on the C(VirtualMachine).
- Only used when O(state=present).
type: dict
labels:
description:
- Specify labels to set on the C(VirtualMachine).
type: dict
running:
description:
- Specify whether the C(VirtualMachine) should be running or not.
- Mutually exclusive with O(run_strategy).
- Defaults to O(running=yes) when O(running) and O(run_strategy) are not set.
type: bool
run_strategy:
description:
- Specify the C(RunStrategy) of the C(VirtualMachine).
- Mutually exclusive with O(running).
type: str
choices:
- Always
- Halted
- Manual
- 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).
- Only used when O(state=present).
type: dict
preference:
description:
- Specify the C(Preference) matcher of the C(VirtualMachine).
- Only used when O(state=present).
type: dict
data_volume_templates:
description:
- Specify the C(DataVolume) templates of the C(VirtualMachine).
- See U(https://kubevirt.io/api-reference/main/definitions.html#_v1_datavolumetemplatespec)
type: list
elements: 'dict'
spec:
description:
- Specify the template spec of the C(VirtualMachine).
- See U(https://kubevirt.io/api-reference/main/definitions.html#_v1_virtualmachineinstancespec)
type: dict
wait:
description:
- Whether to wait for the C(VirtualMachine) to end up in the ready state.
type: bool
default: no
wait_sleep:
description:
- Number of seconds to sleep between checks.
- Ignored if O(wait) is not set.
default: 5
type: int
wait_timeout:
description:
- How long in seconds to wait for the resource to end up in the ready state.
- Ignored if O(wait) is not set.
default: 120
type: int
delete_options:
description:
- Configure behavior when deleting an object.
- Only used when O(state=absent).
type: dict
suboptions:
propagationPolicy:
description:
- Use to control how dependent objects are deleted.
- If not specified, the default policy for the object type will be used. This may vary across object types.
type: str
choices:
- Foreground
- Background
- Orphan
preconditions:
description:
- Specify condition that must be met for delete to proceed.
type: dict
suboptions:
resourceVersion:
description:
- Specify the resource version of the target object.
type: str
uid:
description:
- Specify the C(UID) of the target object.
type: str
state:
description:
- 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.
type: bool
default: no
hidden_fields:
description:
- Hide fields matching this option in the result.
- An example might be O(hidden_fields=[metadata.managedFields])
or O(hidden_fields=[metadata.annotations[kubemacpool.io/transaction-timestamp]]).
type: list
elements: str
default: ['metadata.annotations[kubemacpool.io/transaction-timestamp]', metadata.managedFields]
version_added: 2.2.0
requirements:
- "python >= 3.9"
- "kubernetes >= 28.1.0"
- "PyYAML >= 3.11"
- "jsonpatch"
"""
EXAMPLES = """
- name: Create a VirtualMachine
kubevirt.core.kubevirt_vm:
state: present
name: testvm
namespace: default
labels:
app: test
instancetype:
name: u1.medium
preference:
name: fedora
spec:
domain:
devices:
interfaces:
- name: default
masquerade: {}
- name: bridge-network
bridge: {}
networks:
- name: default
pod: {}
- name: bridge-network
multus:
networkName: kindexgw
volumes:
- containerDisk:
image: quay.io/containerdisks/fedora:latest
name: containerdisk
- cloudInitNoCloud:
userData: |-
#cloud-config
# The default username is: fedora
ssh_authorized_keys:
- ssh-ed25519 AAAA...
name: cloudinit
- name: Create a VirtualMachine with a DataVolume template
kubevirt.core.kubevirt_vm:
state: present
name: testvm-with-dv
namespace: default
labels:
app: test
instancetype:
name: u1.medium
preference:
name: fedora
data_volume_templates:
- metadata:
name: testdv
spec:
source:
registry:
url: docker://quay.io/containerdisks/fedora:latest
storage:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
spec:
domain:
devices: {}
volumes:
- dataVolume:
name: testdv
name: datavolume
- cloudInitNoCloud:
userData: |-
#cloud-config
# The default username is: fedora
ssh_authorized_keys:
- ssh-ed25519 AAAA...
name: cloudinit
wait: true
- name: Delete a VirtualMachine
kubevirt.core.kubevirt_vm:
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 = """
result:
description:
- The created object. Will be empty in the case of a deletion.
type: complex
returned: success
contains:
changed:
description: Whether the C(VirtualMachine) was changed or not.
type: bool
sample: True
duration:
description: Elapsed time of the task in seconds.
returned: When O(wait=true).
type: int
sample: 48
method:
description: Method executed on the Kubernetes API.
returned: success
type: str
"""
# Monkey patch service.diff_objects to temporarily fix the changed logic
from ansible_collections.kubevirt.core.plugins.module_utils.diff import (
_patch_diff_objects,
)
from copy import deepcopy
from typing import Dict
from ansible_collections.kubernetes.core.plugins.module_utils.ansiblemodule import (
AnsibleModule,
)
from ansible_collections.kubernetes.core.plugins.module_utils.args_common import (
AUTH_ARG_SPEC,
COMMON_ARG_SPEC,
)
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 = {
"type": "Ready",
"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:
"""
create_vm constructs a VM from the module parameters.
"""
vm = {
"apiVersion": params["api_version"],
"kind": "VirtualMachine",
"metadata": {
"namespace": params["namespace"],
},
"spec": {
"template": {"spec": {"domain": {"devices": {}}}},
},
}
if (name := params.get("name")) is not None:
vm["metadata"]["name"] = name
if (generate_name := params.get("generate_name")) is not None:
vm["metadata"]["generateName"] = generate_name
template_metadata = {}
if (annotations := params.get("annotations")) is not None:
vm["metadata"]["annotations"] = annotations
template_metadata["annotations"] = annotations
if (labels := params.get("labels")) is not None:
vm["metadata"]["labels"] = labels
template_metadata["labels"] = labels
if template_metadata:
vm["spec"]["template"]["metadata"] = template_metadata
if (run_strategy := params.get("run_strategy")) is not None:
vm["spec"]["runStrategy"] = run_strategy
vm["spec"]["running"] = None
else:
vm["spec"]["runStrategy"] = None
vm["spec"]["running"] = (
running if (running := params.get("running")) is not None else True
)
if (instancetype := params.get("instancetype")) is not None:
vm["spec"]["instancetype"] = instancetype
if (preference := params.get("preference")) is not None:
vm["spec"]["preference"] = preference
if (data_volume_templates := params.get("data_volume_templates")) is not None:
vm["spec"]["dataVolumeTemplates"] = data_volume_templates
if (spec := params.get("spec")) is not None:
vm["spec"]["template"]["spec"] = spec
return vm
def set_wait_condition(module: AnsibleK8SModule) -> None:
"""
set_wait_condition sets the wait_condition to allow waiting for the ready
state of the VirtualMachine depending on the module parameters running
and run_strategy.
"""
if (
module.params["running"] is False
or (run_strategy := module.params["run_strategy"]) == "Halted"
):
module.params["wait_condition"] = WAIT_CONDITION_VMI_NOT_EXISTS
elif run_strategy != "Manual":
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.
"""
spec = {
"api_version": {"default": "kubevirt.io/v1"},
"name": {},
"generate_name": {},
"namespace": {"required": True},
"annotations": {"type": "dict"},
"labels": {"type": "dict"},
"running": {"type": "bool"},
"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"},
"spec": {"type": "dict"},
"wait": {"type": "bool", "default": False},
"wait_sleep": {"type": "int", "default": 5},
"wait_timeout": {"type": "int", "default": 120},
"delete_options": {
"type": "dict",
"default": None,
"options": {
"propagationPolicy": {
"choices": ["Foreground", "Background", "Orphan"]
},
"preconditions": {
"type": "dict",
"options": {
"resourceVersion": {"type": "str"},
"uid": {"type": "str"},
},
},
},
},
"hidden_fields": {
"type": "list",
"elements": "str",
"default": [
"metadata.annotations[kubemacpool.io/transaction-timestamp]",
"metadata.managedFields",
],
},
}
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
def main() -> None:
"""
main instantiates the AnsibleK8SModule, creates the resource
definition and runs the module.
"""
module = AnsibleK8SModule(
module_class=AnsibleModule,
argument_spec=arg_spec(),
mutually_exclusive=[
("name", "generate_name"),
("running", "run_strategy"),
],
required_one_of=[
("name", "generate_name"),
],
required_if=[
("state", "started", ("name",)),
("state", "stopped", ("name",)),
("state", "restarted", ("name",)),
],
supports_check_mode=True,
)
result = _apply_definition(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)
module.exit_json(**result)
if __name__ == "__main__":
_patch_diff_objects()
main()