Skip to content

Commit 32f4b8f

Browse files
authored
[CCLM] Verify VM boot_id, add post-CCLM validation tests (#4108)
##### Short description: - Check VM boot_id before and after CCLM - Allow connection to the VM Console in the remote cluster with the kubeconfig - Add post-CCLM validation tests Jira: https://issues.redhat.com/browse/CNV-60016 ##### More details: Split from #3926 ##### What this PR does / why we need it: ##### Which issue(s) this PR fixes: ##### Special notes for reviewer: Assisted by Claude AI and Cursor AI ##### jira-ticket: <!-- full-ticket-url needs to be provided. This would add a link to the pull request to the jira and close it when the pull request is merged If the task is not tracked by a Jira ticket, just write "NONE". --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Expanded cross-cluster VM migration test coverage with additional VM scenarios and state verification. * Added post-migration validation for VM boot integrity and file persistence. * New tests for VM state transitions and cleanup operations. * **Refactor** * Consolidated test constants for broader reusability across test modules. * Refactored VM file verification utilities for improved test modularity. * Enhanced console utility support for remote cluster operations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent dc6328c commit 32f4b8f

File tree

12 files changed

+290
-91
lines changed

12 files changed

+290
-91
lines changed

tests/storage/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@
1818
HTTPS = "https"
1919

2020
QUAY_FEDORA_CONTAINER_IMAGE = f"docker://{Images.Fedora.FEDORA_CONTAINER_IMAGE}"
21+
22+
TEST_FILE_NAME = "test-file.txt"
23+
TEST_FILE_CONTENT = "test-content"

tests/storage/cross_cluster_live_migration/conftest.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import logging
2+
import os
23
import re
4+
import tempfile
35
from copy import deepcopy
46

57
import pytest
68
import requests
9+
import yaml
710
from kubernetes.dynamic.exceptions import NotFoundError
811
from ocp_resources.data_source import DataSource
912
from ocp_resources.forklift_controller import ForkliftController
@@ -25,8 +28,10 @@
2528
)
2629
from pytest_testconfig import config as py_config
2730

31+
from tests.storage.constants import TEST_FILE_CONTENT, TEST_FILE_NAME
2832
from tests.storage.cross_cluster_live_migration.utils import (
2933
enable_feature_gate_and_configure_hco_live_migration_network,
34+
get_vm_boot_id_via_console,
3035
)
3136
from utilities.constants import (
3237
OS_FLAVOR_RHEL,
@@ -38,7 +43,7 @@
3843
Images,
3944
)
4045
from utilities.infra import create_ns, get_hyperconverged_resource
41-
from utilities.storage import data_volume_template_with_source_ref_dict
46+
from utilities.storage import data_volume_template_with_source_ref_dict, write_file
4247
from utilities.virt import VirtualMachineForTests, running_vm
4348

4449
LOGGER = logging.getLogger(__name__)
@@ -109,6 +114,50 @@ def remote_cluster_auth_token(remote_admin_client):
109114
raise NotFoundError("Unable to extract authentication token from remote admin client")
110115

111116

117+
@pytest.fixture(scope="session")
118+
def remote_cluster_kubeconfig(remote_admin_client, remote_cluster_auth_token):
119+
"""
120+
Generate a kubeconfig file from the remote admin client credentials.
121+
Returns the path to the generated kubeconfig file.
122+
"""
123+
# Extract cluster information from the client
124+
cluster_host = remote_admin_client.configuration.host
125+
cluster_name = "remote-cluster"
126+
user_name = "remote-admin"
127+
context_name = "remote-context"
128+
129+
# Create kubeconfig structure
130+
kubeconfig_dict = {
131+
"apiVersion": "v1",
132+
"kind": "Config",
133+
"clusters": [
134+
{
135+
"name": cluster_name,
136+
"cluster": {
137+
"server": cluster_host,
138+
"insecure-skip-tls-verify": True,
139+
},
140+
}
141+
],
142+
"users": [{"name": user_name, "user": {"token": remote_cluster_auth_token}}],
143+
"contexts": [{"name": context_name, "context": {"cluster": cluster_name, "user": user_name}}],
144+
"current-context": context_name,
145+
}
146+
147+
# Use TemporaryDirectory context manager for automatic cleanup
148+
with tempfile.TemporaryDirectory(suffix="-remote-kubeconfig") as temp_dir:
149+
kubeconfig_path = os.path.join(temp_dir, "kubeconfig")
150+
151+
# Write kubeconfig file with secure permissions (0o600 = rw-------)
152+
# Using os.open with O_CREAT ensures the file is created with restricted permissions from the start
153+
file_descriptor = os.open(kubeconfig_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
154+
with os.fdopen(file_descriptor, "w") as kubeconfig_file:
155+
yaml.safe_dump(data=kubeconfig_dict, stream=kubeconfig_file)
156+
157+
LOGGER.info(f"Created remote cluster kubeconfig at: {kubeconfig_path}")
158+
yield kubeconfig_path
159+
160+
112161
@pytest.fixture(scope="session")
113162
def remote_cluster_hco_namespace(remote_admin_client):
114163
return Namespace(client=remote_admin_client, name=py_config["hco_namespace"], ensure_exists=True)
@@ -402,7 +451,7 @@ def vm_for_cclm_from_template_with_data_source(
402451
),
403452
memory_guest=Images.Rhel.DEFAULT_MEMORY_SIZE,
404453
) as vm:
405-
running_vm(vm=vm, check_ssh_connectivity=False) # False because we can't ssh to a VM in the remote cluster
454+
vm.start()
406455
yield vm
407456

408457

@@ -422,7 +471,7 @@ def vm_for_cclm_with_instance_type(
422471
storage_class=py_config["default_storage_class"],
423472
),
424473
) as vm:
425-
running_vm(vm=vm, check_ssh_connectivity=False) # False because we can't ssh to a VM in the remote cluster
474+
vm.start()
426475
yield vm
427476

428477

@@ -437,6 +486,53 @@ def vms_for_cclm(request):
437486
yield vms
438487

439488

489+
@pytest.fixture(scope="class")
490+
def booted_vms_for_cclm(vms_for_cclm):
491+
for vm in vms_for_cclm:
492+
running_vm(vm=vm, check_ssh_connectivity=False) # False because we can't ssh to a VM in the remote cluster
493+
return vms_for_cclm
494+
495+
496+
@pytest.fixture(scope="class")
497+
def vms_boot_id_before_cclm(booted_vms_for_cclm, remote_cluster_kubeconfig):
498+
return {
499+
vm.name: get_vm_boot_id_via_console(vm=vm, kubeconfig=remote_cluster_kubeconfig) for vm in booted_vms_for_cclm
500+
}
501+
502+
503+
@pytest.fixture(scope="class")
504+
def written_file_to_vms_before_cclm(booted_vms_for_cclm, remote_cluster_kubeconfig):
505+
for vm in booted_vms_for_cclm:
506+
write_file(
507+
vm=vm,
508+
filename=TEST_FILE_NAME,
509+
content=TEST_FILE_CONTENT,
510+
stop_vm=False,
511+
kubeconfig=remote_cluster_kubeconfig,
512+
)
513+
return booted_vms_for_cclm
514+
515+
516+
@pytest.fixture(scope="class")
517+
def local_vms_after_cclm_migration(admin_client, namespace, vms_for_cclm):
518+
"""
519+
Returns List of VirtualMachineForTests objects referencing VMs in the local cluster
520+
"""
521+
local_vms = []
522+
for vm in vms_for_cclm:
523+
local_vm = VirtualMachineForTests(
524+
name=vm.name,
525+
namespace=namespace.name,
526+
os_flavor=vm.os_flavor,
527+
client=admin_client,
528+
generate_unique_name=False,
529+
)
530+
local_vm.username = vm.username
531+
local_vm.password = vm.password
532+
local_vms.append(local_vm)
533+
return local_vms
534+
535+
440536
@pytest.fixture(scope="class")
441537
def mtv_migration_plan(
442538
admin_client,
Lines changed: 49 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import pytest
22

3-
from tests.storage.cross_cluster_live_migration.utils import verify_compute_live_migration_after_cclm
3+
from tests.storage.constants import TEST_FILE_CONTENT, TEST_FILE_NAME
4+
from tests.storage.cross_cluster_live_migration.utils import (
5+
assert_vms_are_stopped,
6+
assert_vms_can_be_deleted,
7+
verify_compute_live_migration_after_cclm,
8+
verify_vms_boot_id_after_cross_cluster_live_migration,
9+
)
10+
from tests.storage.utils import check_file_in_vm
411
from utilities.constants import TIMEOUT_10MIN
512

6-
TESTS_CLASS_NAME_VM_FROM_TEMPLATE_WITH_DATA_SOURCE = "CCLMvmFromTemplateWithDataSource"
7-
TESTS_CLASS_NAME_VM_WITH_INSTANCE_TYPE = "CCLMvmWithInstanceType"
13+
TESTS_CLASS_NAME_SEVERAL_VMS = "TestCCLMSeveralVMs"
814

915
pytestmark = [
1016
pytest.mark.cclm,
@@ -21,18 +27,23 @@
2127
"vms_for_cclm",
2228
[
2329
pytest.param(
24-
{"vms_fixtures": ["vm_for_cclm_from_template_with_data_source"]},
30+
{
31+
"vms_fixtures": [
32+
"vm_for_cclm_from_template_with_data_source",
33+
"vm_for_cclm_with_instance_type",
34+
]
35+
},
2536
)
2637
],
2738
indirect=True,
2839
)
29-
class TestCCLMvmFromTemplateWithDataSource:
30-
@pytest.mark.polarion("CNV-11910")
31-
@pytest.mark.dependency(
32-
name=f"{TESTS_CLASS_NAME_VM_FROM_TEMPLATE_WITH_DATA_SOURCE}::test_migrate_vm_from_remote_to_local_cluster"
33-
)
40+
class TestCCLMSeveralVMs:
41+
@pytest.mark.polarion("CNV-11995")
42+
@pytest.mark.dependency(name=f"{TESTS_CLASS_NAME_SEVERAL_VMS}::test_migrate_vm_from_remote_to_local_cluster")
3443
def test_migrate_vm_from_remote_to_local_cluster(
3544
self,
45+
written_file_to_vms_before_cclm,
46+
vms_boot_id_before_cclm,
3647
mtv_migration,
3748
):
3849
mtv_migration.wait_for_condition(
@@ -42,42 +53,35 @@ def test_migrate_vm_from_remote_to_local_cluster(
4253
stop_condition=mtv_migration.Status.FAILED,
4354
)
4455

45-
@pytest.mark.dependency(
46-
depends=[f"{TESTS_CLASS_NAME_VM_FROM_TEMPLATE_WITH_DATA_SOURCE}::test_migrate_vm_from_remote_to_local_cluster"]
47-
)
48-
@pytest.mark.polarion("CNV-12038")
49-
def test_compute_live_migrate_vms_after_cclm(self, admin_client, namespace, vms_for_cclm):
50-
verify_compute_live_migration_after_cclm(client=admin_client, namespace=namespace, vms_list=vms_for_cclm)
56+
@pytest.mark.dependency(depends=[f"{TESTS_CLASS_NAME_SEVERAL_VMS}::test_migrate_vm_from_remote_to_local_cluster"])
57+
@pytest.mark.polarion("CNV-11910")
58+
def test_verify_vms_not_rebooted_after_migration(self, local_vms_after_cclm_migration, vms_boot_id_before_cclm):
59+
verify_vms_boot_id_after_cross_cluster_live_migration(
60+
local_vms=local_vms_after_cclm_migration, initial_boot_id=vms_boot_id_before_cclm
61+
)
5162

63+
@pytest.mark.dependency(depends=[f"{TESTS_CLASS_NAME_SEVERAL_VMS}::test_migrate_vm_from_remote_to_local_cluster"])
64+
@pytest.mark.polarion("CNV-14332")
65+
def test_verify_file_persisted_after_migration(self, local_vms_after_cclm_migration):
66+
for vm in local_vms_after_cclm_migration:
67+
check_file_in_vm(
68+
vm=vm,
69+
file_name=TEST_FILE_NAME,
70+
file_content=TEST_FILE_CONTENT,
71+
username=vm.username,
72+
password=vm.password,
73+
)
5274

53-
@pytest.mark.parametrize(
54-
"vms_for_cclm",
55-
[
56-
pytest.param(
57-
{"vms_fixtures": ["vm_for_cclm_with_instance_type"]},
58-
),
59-
],
60-
indirect=True,
61-
)
62-
class TestCCLMvmWithInstanceType:
63-
@pytest.mark.polarion("CNV-12013")
64-
@pytest.mark.dependency(
65-
name=f"{TESTS_CLASS_NAME_VM_WITH_INSTANCE_TYPE}::test_migrate_vm_from_remote_to_local_cluster"
66-
)
67-
def test_migrate_vm_from_remote_to_local_cluster(
68-
self,
69-
mtv_migration,
70-
):
71-
mtv_migration.wait_for_condition(
72-
condition=mtv_migration.Condition.Type.SUCCEEDED,
73-
status=mtv_migration.Condition.Status.TRUE,
74-
timeout=TIMEOUT_10MIN,
75-
stop_condition=mtv_migration.Status.FAILED,
76-
)
75+
@pytest.mark.dependency(depends=[f"{TESTS_CLASS_NAME_SEVERAL_VMS}::test_migrate_vm_from_remote_to_local_cluster"])
76+
@pytest.mark.polarion("CNV-14333")
77+
def test_source_vms_are_stopped_after_cclm(self, vms_for_cclm):
78+
assert_vms_are_stopped(vms=vms_for_cclm)
79+
80+
@pytest.mark.dependency(depends=[f"{TESTS_CLASS_NAME_SEVERAL_VMS}::test_migrate_vm_from_remote_to_local_cluster"])
81+
@pytest.mark.polarion("CNV-12038")
82+
def test_compute_live_migrate_vms_after_cclm(self, local_vms_after_cclm_migration):
83+
verify_compute_live_migration_after_cclm(local_vms=local_vms_after_cclm_migration)
7784

78-
@pytest.mark.dependency(
79-
depends=[f"{TESTS_CLASS_NAME_VM_WITH_INSTANCE_TYPE}::test_migrate_vm_from_remote_to_local_cluster"]
80-
)
81-
@pytest.mark.polarion("CNV-12474")
82-
def test_compute_live_migrate_vms_after_cclm(self, admin_client, namespace, vms_for_cclm):
83-
verify_compute_live_migration_after_cclm(client=admin_client, namespace=namespace, vms_list=vms_for_cclm)
85+
@pytest.mark.polarion("CNV-14334")
86+
def test_source_vms_can_be_deleted(self, vms_for_cclm):
87+
assert_vms_can_be_deleted(vms=vms_for_cclm)

tests/storage/cross_cluster_live_migration/utils.py

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ocp_resources.namespace import Namespace
88
from ocp_resources.network_attachment_definition import NetworkAttachmentDefinition
99

10+
from utilities import console
1011
from utilities.constants import VIRT_HANDLER
1112
from utilities.hco import ResourceEditorValidateHCOReconcile
1213
from utilities.infra import get_daemonset_by_name
@@ -73,32 +74,74 @@ def enable_feature_gate_and_configure_hco_live_migration_network(
7374
)
7475

7576

76-
def verify_compute_live_migration_after_cclm(
77-
client: DynamicClient, namespace: Namespace, vms_list: list[VirtualMachineForTests]
78-
) -> None:
77+
def verify_compute_live_migration_after_cclm(local_vms: list[VirtualMachineForTests]) -> None:
7978
"""
8079
Verify compute live migration for VMs after Cross-Cluster Live Migration (CCLM).
8180
82-
This function creates local VM references for each VM that was migrated from the remote cluster,
83-
preserves their credentials, and attempts to perform compute live migration on each VM.
84-
8581
Args:
86-
client: DynamicClient
87-
namespace: The namespace where the VMs are located in the target cluster
88-
vms_list: List of VirtualMachineForTests objects to be migrated
82+
local_vms: List of VirtualMachineForTests objects in the local cluster
8983
9084
Raises:
9185
AssertionError: If any VM migration fails, with details of all failed migrations
9286
"""
9387
vms_failed_migration = {}
94-
for vm in vms_list:
95-
local_vm = VirtualMachineForTests(
96-
name=vm.name, namespace=namespace.name, client=client, generate_unique_name=False
97-
)
98-
local_vm.username = vm.username
99-
local_vm.password = vm.password
88+
for vm in local_vms:
10089
try:
101-
migrate_vm_and_verify(vm=local_vm, check_ssh_connectivity=True)
90+
migrate_vm_and_verify(vm=vm, check_ssh_connectivity=True)
10291
except Exception as migration_exception:
103-
vms_failed_migration[local_vm.name] = migration_exception
92+
vms_failed_migration[vm.name] = migration_exception
10493
assert not vms_failed_migration, f"Failed VM migrations: {vms_failed_migration}"
94+
95+
96+
def get_vm_boot_id_via_console(
97+
vm: VirtualMachineForTests, kubeconfig: str | None = None, username: str | None = None, password: str | None = None
98+
) -> str:
99+
with console.Console(vm=vm, kubeconfig=kubeconfig, username=username, password=password) as vm_console:
100+
vm_console.sendline("cat /proc/sys/kernel/random/boot_id")
101+
vm_console.expect([r"#", r"\$"])
102+
raw_output = vm_console.before
103+
LOGGER.info(f"Boot ID from VM console (raw output): '{raw_output}'")
104+
return raw_output
105+
106+
107+
def verify_vms_boot_id_after_cross_cluster_live_migration(
108+
local_vms: list[VirtualMachineForTests],
109+
initial_boot_id: dict[str, str],
110+
) -> None:
111+
"""
112+
Verify that VMs have not rebooted after cross-cluster live migration.
113+
114+
Args:
115+
local_vms: List of VirtualMachineForTests objects in the local cluster
116+
initial_boot_id: Dictionary mapping VM names to their initial boot IDs
117+
118+
Raises:
119+
AssertionError: If any VM has rebooted (boot ID changed)
120+
"""
121+
rebooted_vms = {}
122+
for vm in local_vms:
123+
current_boot_id = get_vm_boot_id_via_console(vm=vm, username=vm.username, password=vm.password)
124+
if initial_boot_id[vm.name] != current_boot_id:
125+
rebooted_vms[vm.name] = {"initial": initial_boot_id[vm.name], "current": current_boot_id}
126+
assert not rebooted_vms, f"Boot id changed for VMs:\n {rebooted_vms}"
127+
128+
129+
def assert_vms_are_stopped(vms: list[VirtualMachineForTests]) -> None:
130+
not_stopped_vms = {}
131+
for vm in vms:
132+
vm_status = vm.printable_status
133+
if vm_status != vm.Status.STOPPED:
134+
not_stopped_vms[vm.name] = vm_status
135+
assert not not_stopped_vms, f"Source VMs are not stopped: {not_stopped_vms}"
136+
137+
138+
def assert_vms_can_be_deleted(vms: list[VirtualMachineForTests]) -> None:
139+
vms_failed_cleanup = {}
140+
for vm in vms:
141+
try:
142+
cleanup_result = vm.clean_up()
143+
if cleanup_result is not True:
144+
vms_failed_cleanup[vm.name] = f"vm.clean_up() returned {cleanup_result}"
145+
except Exception as cleanup_exception:
146+
vms_failed_cleanup[vm.name] = str(cleanup_exception)
147+
assert not vms_failed_cleanup, f"Failed to clean up source VMs: {vms_failed_cleanup}"

0 commit comments

Comments
 (0)