Skip to content

Commit 9a9cc4e

Browse files
committed
Add PXE boot support and VM tests for LiveOS images
Add the grub handling needed to PXE (network) boot Azure Linux LiveOS images, plus VM tests that exercise it. Product (toolkit/tools/pkg/imagecustomizerlib): - Expand BLS Type #1 boot entries into explicit grub menuentries for the PXE grub config, since grub cannot enumerate boot/loader/entries over TFTP. This is done per distro through a new DistroHandler.UpdateLiveOSGrubCfgForPxe hook and a shared BLS renderer. Tests (test/vmtests): - test_pxe.py: bootstrap and full-OS PXE boot tests for AZL3 and AZL4, driven by a transient libvirt NAT network (dnsmasq DHCP and TFTP) and an HTTP server over the extracted PXE artifacts (pxe_server.py). - The tests are skipped on Azure Linux hosts, whose edk2-ovmf lacks the UEFI network stack (EDK II NetworkPkg) and so cannot netboot a VM. They run on Ubuntu hosts, whose OVMF includes it.
1 parent af7f4d0 commit 9a9cc4e

22 files changed

Lines changed: 1023 additions & 71 deletions
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import logging
5+
import platform
6+
import random
7+
import string
8+
import tarfile
9+
from pathlib import Path
10+
from typing import List, Tuple
11+
12+
import libvirt # type: ignore
13+
import pytest
14+
from docker import DockerClient
15+
16+
from ..conftest import TEST_CONFIGS_DIR
17+
from ..utils.closeable import Closeable
18+
from ..utils.host_utils import get_host_distro
19+
from ..utils.imagecustomizer import (
20+
add_preview_features_to_config,
21+
add_pxe_bootstrap_base_url_to_config,
22+
add_ssh_to_config,
23+
run_image_customizer,
24+
)
25+
from ..utils.libvirt_utils import VmSpec, create_libvirt_domain_xml
26+
from ..utils.libvirt_vm import LibvirtVm
27+
from ..utils.pxe_server import PXE_HTTP_PORT, PXE_NETWORK_GATEWAY_IP, PxeEnvironment
28+
from ..utils.user_utils import get_username
29+
from .test_min_change import run_basic_checks
30+
31+
# The full-OS image is downloaded into RAM during PXE bootstrap, so the VM needs more memory than a disk/ISO boot.
32+
PXE_VM_MEMORY_MIB = 8192
33+
PXE_VM_CORE_COUNT = 4
34+
35+
# PXE boot adds a firmware netboot phase plus an over-the-network bootstrap-image download before the OS requests its
36+
# DHCP lease, so it needs additional time to boot.
37+
PXE_BOOT_IP_WAIT_TIME_EXTRA_SECONDS = 600
38+
39+
40+
def run_pxe_test(
41+
docker_client: DockerClient,
42+
image_customizer_container_url: str,
43+
input_image: Path,
44+
input_image_azl_release: int,
45+
initramfs_type: str,
46+
config_path: Path,
47+
ssh_key: Tuple[str, Path],
48+
test_temp_dir: Path,
49+
test_instance_name: str,
50+
logs_dir: Path,
51+
libvirt_conn: libvirt.virConnect,
52+
close_list: List[Closeable],
53+
) -> None:
54+
55+
ssh_public_key, ssh_private_key_path = ssh_key
56+
57+
if platform.machine() == "x86_64":
58+
boot_loader_file = "bootx64.efi"
59+
else:
60+
boot_loader_file = "bootaa64.efi"
61+
62+
username = get_username()
63+
64+
modified_config_path = add_ssh_to_config(config_path, username, ssh_public_key, close_list)
65+
66+
if initramfs_type == "bootstrap":
67+
bootstrap_base_url = f"http://{PXE_NETWORK_GATEWAY_IP}:{PXE_HTTP_PORT}"
68+
modified_config_path = add_pxe_bootstrap_base_url_to_config(
69+
modified_config_path, bootstrap_base_url, close_list
70+
)
71+
72+
pxe_tar_path = test_temp_dir.joinpath("pxe-artifacts.tar.gz")
73+
run_image_customizer(
74+
docker_client,
75+
image_customizer_container_url,
76+
"customize",
77+
modified_config_path,
78+
"pxe-tar",
79+
pxe_tar_path,
80+
image_file=input_image,
81+
)
82+
83+
pxe_artifacts_dir = test_temp_dir.joinpath("pxe-artifacts")
84+
pxe_artifacts_dir.mkdir()
85+
with tarfile.open(pxe_tar_path, "r:gz") as tar:
86+
tar.extractall(pxe_artifacts_dir)
87+
88+
customized_name = (
89+
"pxe_"
90+
+ initramfs_type.replace("-", "_")
91+
+ "_"
92+
+ get_host_distro()
93+
+ "_efi_azl"
94+
+ str(input_image_azl_release)
95+
+ "_to_efi"
96+
)
97+
customized_log_path = str(logs_dir) + "/" + customized_name
98+
http_log_file_path = Path(customized_log_path + ".http.log")
99+
vm_console_log_file_path = customized_log_path + ".console.log"
100+
101+
suffix = "".join(random.choice(string.ascii_lowercase) for _ in range(5))
102+
network_name = test_instance_name + "-pxe"
103+
bridge_name = "pxebr" + suffix
104+
105+
pxe_env = PxeEnvironment(
106+
libvirt_conn,
107+
network_name,
108+
bridge_name,
109+
pxe_artifacts_dir,
110+
boot_loader_file,
111+
http_log_file_path,
112+
)
113+
close_list.append(pxe_env)
114+
115+
# Create the VM: no disk, boots from the PXE network.
116+
vm_spec = VmSpec(
117+
test_instance_name,
118+
PXE_VM_MEMORY_MIB,
119+
PXE_VM_CORE_COUNT,
120+
None,
121+
"efi",
122+
secure_boot=False,
123+
pxe_boot=True,
124+
network_name=pxe_env.network_name,
125+
)
126+
domain_xml = create_libvirt_domain_xml(libvirt_conn, vm_spec)
127+
logging.debug(f"\n\ndomain_xml = {domain_xml}\n\n")
128+
129+
vm = LibvirtVm(test_instance_name, domain_xml, vm_console_log_file_path, libvirt_conn)
130+
close_list.append(vm)
131+
132+
# Start the VM.
133+
vm.start()
134+
135+
# Connect to the VM and run the basic boot validation.
136+
with vm.create_ssh_client(
137+
ssh_private_key_path,
138+
test_temp_dir,
139+
username,
140+
ip_wait_time_extra=PXE_BOOT_IP_WAIT_TIME_EXTRA_SECONDS,
141+
) as ssh_client:
142+
run_basic_checks(ssh_client, input_image_azl_release, test_temp_dir)
143+
144+
145+
@pytest.mark.skipif(
146+
get_host_distro() == "azurelinux",
147+
reason="PXE requires a network-enabled host UEFI firmware (EDK II NetworkPkg), which only Ubuntu hosts provide",
148+
)
149+
def test_pxe_bootstrap_efi_azl3(
150+
docker_client: DockerClient,
151+
image_customizer_container_url: str,
152+
core_efi_azl3: Path,
153+
ssh_key: Tuple[str, Path],
154+
test_temp_dir: Path,
155+
test_instance_name: str,
156+
logs_dir: Path,
157+
libvirt_conn: libvirt.virConnect,
158+
close_list: List[Closeable],
159+
) -> None:
160+
azl_release = 3
161+
config_path = TEST_CONFIGS_DIR.joinpath("pxe-bootstrap-vm-azl3.yaml")
162+
163+
run_pxe_test(
164+
docker_client,
165+
image_customizer_container_url,
166+
core_efi_azl3,
167+
azl_release,
168+
"bootstrap",
169+
config_path,
170+
ssh_key,
171+
test_temp_dir,
172+
test_instance_name,
173+
logs_dir,
174+
libvirt_conn,
175+
close_list,
176+
)
177+
178+
179+
@pytest.mark.skipif(
180+
get_host_distro() == "azurelinux",
181+
reason="PXE requires a network-enabled host UEFI firmware (EDK II NetworkPkg), which only Ubuntu hosts provide",
182+
)
183+
def test_pxe_bootstrap_efi_azl4(
184+
docker_client: DockerClient,
185+
image_customizer_container_url: str,
186+
core_efi_azl4: Path,
187+
ssh_key: Tuple[str, Path],
188+
test_temp_dir: Path,
189+
test_instance_name: str,
190+
logs_dir: Path,
191+
libvirt_conn: libvirt.virConnect,
192+
close_list: List[Closeable],
193+
) -> None:
194+
azl_release = 4
195+
config_path = TEST_CONFIGS_DIR.joinpath("pxe-bootstrap-vm-azl4.yaml")
196+
config_path = add_preview_features_to_config(config_path, "preview-distro-version", close_list)
197+
198+
run_pxe_test(
199+
docker_client,
200+
image_customizer_container_url,
201+
core_efi_azl4,
202+
azl_release,
203+
"bootstrap",
204+
config_path,
205+
ssh_key,
206+
test_temp_dir,
207+
test_instance_name,
208+
logs_dir,
209+
libvirt_conn,
210+
close_list,
211+
)
212+
213+
214+
@pytest.mark.skipif(
215+
get_host_distro() == "azurelinux",
216+
reason="PXE requires a network-enabled host UEFI firmware (EDK II NetworkPkg), which only Ubuntu hosts provide",
217+
)
218+
def test_pxe_full_os_efi_azl3(
219+
docker_client: DockerClient,
220+
image_customizer_container_url: str,
221+
core_efi_azl3: Path,
222+
ssh_key: Tuple[str, Path],
223+
test_temp_dir: Path,
224+
test_instance_name: str,
225+
logs_dir: Path,
226+
libvirt_conn: libvirt.virConnect,
227+
close_list: List[Closeable],
228+
) -> None:
229+
azl_release = 3
230+
config_path = TEST_CONFIGS_DIR.joinpath("pxe-full-os-vm-azl3.yaml")
231+
232+
run_pxe_test(
233+
docker_client,
234+
image_customizer_container_url,
235+
core_efi_azl3,
236+
azl_release,
237+
"full-os",
238+
config_path,
239+
ssh_key,
240+
test_temp_dir,
241+
test_instance_name,
242+
logs_dir,
243+
libvirt_conn,
244+
close_list,
245+
)
246+
247+
248+
@pytest.mark.skipif(
249+
get_host_distro() == "azurelinux",
250+
reason="PXE requires a network-enabled host UEFI firmware (EDK II NetworkPkg), which only Ubuntu hosts provide",
251+
)
252+
def test_pxe_full_os_efi_azl4(
253+
docker_client: DockerClient,
254+
image_customizer_container_url: str,
255+
core_efi_azl4: Path,
256+
ssh_key: Tuple[str, Path],
257+
test_temp_dir: Path,
258+
test_instance_name: str,
259+
logs_dir: Path,
260+
libvirt_conn: libvirt.virConnect,
261+
close_list: List[Closeable],
262+
) -> None:
263+
azl_release = 4
264+
config_path = TEST_CONFIGS_DIR.joinpath("pxe-full-os-vm-azl4.yaml")
265+
config_path = add_preview_features_to_config(config_path, "preview-distro-version", close_list)
266+
267+
run_pxe_test(
268+
docker_client,
269+
image_customizer_container_url,
270+
core_efi_azl4,
271+
azl_release,
272+
"full-os",
273+
config_path,
274+
ssh_key,
275+
test_temp_dir,
276+
test_instance_name,
277+
logs_dir,
278+
libvirt_conn,
279+
close_list,
280+
)

test/vmtests/vmtests/utils/host_utils.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
def get_host_distro() -> str:
66
file_path = "/etc/os-release"
7-
name_value = ""
7+
id_value = ""
88
with open(file_path, "r") as file:
99
for line in file:
1010
if line.startswith("ID="):
11-
name_value = line.strip().split("=", 1)[1] # Get the value part
11+
id_value = line.strip().split("=", 1)[1]
1212
break
13-
if name_value == "":
13+
14+
if id_value == "":
1415
raise Exception("ID field not found in os-release file")
1516

16-
return name_value
17+
return id_value

test/vmtests/vmtests/utils/imagecustomizer.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,38 @@ def add_preview_features_to_config(config_path: Path, preview_feature: str, clos
209209
return path
210210

211211

212+
def add_pxe_bootstrap_base_url_to_config(
213+
config_path: Path, bootstrap_base_url: str, close_list: List[Closeable]
214+
) -> Path:
215+
"""Modify an image customizer config file to set the PXE bootstrap base URL.
216+
217+
This URL is baked into the generated PXE artifacts (grub.cfg's root=live:<url>/image.iso), so it must match where
218+
the bootstrap image is actually served at boot time.
219+
220+
Args:
221+
config_path: Path to the base config file
222+
bootstrap_base_url: Base URL where the bootstrap image is served at boot time
223+
close_list: List of resources to be cleaned up
224+
225+
Returns:
226+
Path to the modified config file
227+
"""
228+
config_str = config_path.read_text()
229+
config = yaml.safe_load(config_str)
230+
231+
pxe = dict_get_or_set(config, "pxe", {})
232+
pxe["bootstrapBaseUrl"] = bootstrap_base_url
233+
234+
# Write out new config file to a temporary file.
235+
fd, modified_config_path = tempfile.mkstemp(prefix=config_path.name + "~", suffix=".tmp", dir=config_path.parent)
236+
with fdopen(fd, mode="w") as file:
237+
yaml.safe_dump(config, file)
238+
239+
path = Path(modified_config_path)
240+
close_list.append(RemoveFileOnClose(path))
241+
return path
242+
243+
212244
def dict_get_or_set(dictionary: Dict[Any, Any], value_name: str, default: Any = None) -> Any:
213245
value = dictionary.get(value_name)
214246
if value is None:

0 commit comments

Comments
 (0)