Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions examples/runbook/bmi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# BMI (Bare-Metal Instance) example runbook.
#
# Deploys two GB200 BMIs behind a jumphost using the Bicep template at
# ``lisa/sut_orchestrator/bmi/bmi.bicep`` (compiled to
# ``autogen_bmi_template.json``) and runs the smoke test suite against them.
#
# Required variables (override via -v on the command line or env vars):
# - subscription_id : Azure subscription for the RG.
# - bmi_image_sig : SIG image version resource ID.
# - bmi_private_key_file : SSH key whose ``.pub`` is installed on BMIs.
#
# Optional auth overrides:
# - jumphost_password : If set, jumphost uses password auth.
# Leave empty to auto-generate an SSH
# keypair (recommended).
#
# Optional jumphost overrides (have sensible defaults):
# - jumphost_vm_size : Jumphost VM size (default Standard_DS2_v2).
# - jumphost_image : Jumphost image URN
# (publisher:offer:sku:version).
# - jumphost_username : Jumphost admin user (default lisatest).

name: bmi_smoke
test_project: BMI
test_pass: smoke

import_builtin_tests: true

variable:
- name: subscription_id
value: ""
- name: location
value: "southeastus5"
- name: bmi_vm_size
value: "ND144isr_ETH_GB200_metal_v6"
- name: bmi_host_sku
value: "GPCv6GB200S186_ETH_metal-Type1"
- name: bmi_image_sig
value: ""
- name: bmi_admin_username
value: "azhpcuser"
- name: bmi_private_key_file
value: "./id_rsa"
- name: jumphost_vm_size
value: "Standard_DS2_v2"
- name: jumphost_image
value: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"
- name: jumphost_username
value: "lisatest"
- name: jumphost_password
value: ""
is_secret: true

platform:
- type: bmi
bmi:
subscription_id: $(subscription_id)
location: $(location)
bmi_vm_size: $(bmi_vm_size)
bmi_host_sku: $(bmi_host_sku)
bmi_image_sig: $(bmi_image_sig)
bmi_admin_username: $(bmi_admin_username)
bmi_private_key_file: $(bmi_private_key_file)
jumphost_vm_size: $(jumphost_vm_size)
jumphost_image: $(jumphost_image)
jumphost_username: $(jumphost_username)
jumphost_password: $(jumphost_password)
delete_on_cleanup: true

testcase:
- criteria:
area: provisioning
priority: 0
8 changes: 8 additions & 0 deletions lisa/mixin_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
import lisa.runners.lisa_runner # noqa: F401
import lisa.sut_orchestrator.ready # noqa: F401

# BMI (Azure bare-metal instance) platform. Optional: depends on azure-mgmt-*
# and paramiko. Imported lazily so a missing azure SDK does not break LISA
# for users that only run other platforms.
try:
import lisa.sut_orchestrator.bmi.platform_ # noqa: F401
except ModuleNotFoundError as e:
print(f"bmi package is not installed. [{e}]")
Comment on lines +26 to +29

try:
import lisa.notifiers.html # noqa: F401
except ModuleNotFoundError as e:
Expand Down
79 changes: 79 additions & 0 deletions lisa/nic.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,13 @@ def _load_nics(self) -> None:
sriov_match = self.__nic_pci_device_regex.search(line)
if sriov_match:
nic_name, lower, pci_slot = sriov_match.groups()
# Skip virtual master devices (VRF, bridge, bond) that own
# physical NICs via /sys/class/net/<master>/lower_* symlinks.
# These are not real interfaces and have no device/driver
# backing, so they must not be added to self.nics (which is
# iterated by _get_nics_driver, load_nics_info, etc.).
if nic_name not in self._nic_names:
continue
used_module = lspci.get_used_module(pci_slot)
self.append(
NicInfo(
Expand Down Expand Up @@ -665,13 +672,85 @@ def _get_default_nic(self) -> None:
self.default_nic, self.default_nic_route = self._node.tools[
Ip
].get_default_route_info()
# On baremetal/VRF-enabled hosts, the default route may go via a
# virtual master device (VRF, bridge, bond) which has no PCI/driver
# backing under /sys/class/net/<name>/device. Resolve such masters to
# an enslaved physical NIC so downstream tools (driver lookup,
# datapath detection, perf tests) operate on a real interface.
if self.default_nic not in self._nic_names:
resolved = self._resolve_master_to_slave_nic(self.default_nic)
if resolved:
self._node.log.debug(
f"Default route NIC '{self.default_nic}' is a virtual "
f"master device; using enslaved physical NIC "
f"'{resolved}' instead."
)
self.default_nic = resolved
else:
# No slave under the master (e.g. VRF with the physical NIC
# not enslaved). Fall back to the first physical NIC that
# owns a global-scope IPv4 address.
resolved = self._find_first_nic_with_global_ipv4()
if resolved:
self._node.log.debug(
f"Default route NIC '{self.default_nic}' is not a "
f"physical interface and has no slaves; using "
f"physical NIC '{resolved}' (first with global "
f"IPv4) instead."
)
self.default_nic = resolved
assert_that(self.default_nic in self._nic_names).described_as(
(
f"ERROR: NIC name found as default {self.default_nic} "
f"was not in original list of nics {repr(self._nic_names)}."
)
).is_true()

def _resolve_master_to_slave_nic(self, master: str) -> str:
# Find a physical NIC enslaved to `master` (VRF, bridge, bond).
# `ip -o link show` lines containing "master <name>" identify slaves.
# Returns the first slave that is also in self._nic_names.
result = self._node.execute(
"ip -o link show",
shell=True,
sudo=True,
)
if result.exit_code != 0:
return ""
for line in result.stdout.splitlines():
if f"master {master} " not in line and not line.rstrip().endswith(
f"master {master}"
):
continue
# Line format: "<idx>: <name>[@<parent>]: <flags> ... master <m> ..."
parts = line.split(":", 2)
if len(parts) < 2:
continue
name = parts[1].strip().split("@")[0].strip()
if name in self._nic_names:
return name
return ""

def _find_first_nic_with_global_ipv4(self) -> str:
# Pick the first physical NIC carrying a global-scope IPv4 address.
# `ip -o -4 addr show scope global` lists one address per line:
# "<idx>: <name> inet 10.0.1.4/24 ..."
result = self._node.execute(
"ip -o -4 addr show scope global",
shell=True,
sudo=True,
)
if result.exit_code != 0:
return ""
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[1].split("@")[0]
if name in self._nic_names:
return name
return ""

def is_module_reloadable(self, module_name: str) -> bool:
return self._node.tools[KernelConfig].is_built_as_module(
self._device_module_map[module_name].config
Expand Down
8 changes: 7 additions & 1 deletion lisa/sut_orchestrator/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
from lisa.util.constants import PLATFORM_BAREMETAL, PLATFORM_HYPERV, PLATFORM_READY
from lisa.util.constants import (
PLATFORM_BAREMETAL,
PLATFORM_BMI,
PLATFORM_HYPERV,
PLATFORM_READY,
)

AZURE = "azure"
QEMU = "qemu"
Expand All @@ -10,3 +15,4 @@
READY = PLATFORM_READY
BAREMETAL = PLATFORM_BAREMETAL
HYPERV = PLATFORM_HYPERV
BMI = PLATFORM_BMI
45 changes: 42 additions & 3 deletions lisa/sut_orchestrator/azure/credential.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from dataclasses import dataclass
from enum import Enum
from typing import Any, Type, cast
from typing import Any, Optional, Type, cast

from azure.identity import (
AzureCliCredential,
Expand Down Expand Up @@ -101,7 +101,9 @@ def __init__(
if runbook.type:
self._credential_type = runbook.type
else:
self._credential_type = AzureCredentialType.DefaultAzureCredential # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development # noqa E501
self._credential_type = (
AzureCredentialType.DefaultAzureCredential
) # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development # noqa E501

self._log.debug(f"Credential type: {self._credential_type}")
self._cloud = cloud
Expand Down Expand Up @@ -169,7 +171,7 @@ def get_credential(self) -> Any:
return AzureCredential with related schema
"""
additional_tenants = ["*"] if self._allow_all_tenants else None
return DefaultAzureCredential( # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development # noqa E501
return DefaultAzureCredential( # CodeQL [SM05139] Okay use of DefaultAzureCredential as it is only used in development # noqa E501
cloud=self._cloud,
additionally_allowed_tenants=additional_tenants,
)
Expand Down Expand Up @@ -427,3 +429,40 @@ def get_credential(self) -> Any:
tenant_id=self._tenant_id,
additionally_allowed_tenants=additionally_allowed_tenants,
)


def build_compute_credential(
*,
credential_schema: Optional[AzureCredentialSchema],
service_principal_tenant_id: str,
service_principal_client_id: str,
service_principal_key: str,
azure_arm_access_token: str,
cloud: Cloud,
logger: Logger,
) -> Any:
"""
Build a TokenCredential from the same auth surface AzurePlatformSchema
exposes (credential subobject + service-principal fields + ARM token).
Mirrors ``AzurePlatform._initialize_credential`` resolution order so
other Azure-based platforms (e.g. BMI) get identical auth behavior.
"""
if credential_schema:
factory = subclasses.Factory[AzureCredential](AzureCredential)
azure_credential: AzureCredential = factory.create_by_runbook(
credential_schema, cloud=cloud, logger=logger
)
return azure_credential.get_credential()

if service_principal_tenant_id:
os.environ["AZURE_TENANT_ID"] = service_principal_tenant_id
if service_principal_client_id:
os.environ["AZURE_CLIENT_ID"] = service_principal_client_id
if service_principal_key:
os.environ["AZURE_CLIENT_SECRET"] = service_principal_key

return get_static_access_token(
azure_arm_access_token
) or DefaultAzureCredential( # CodeQL [SM05139] dev fallback # noqa E501
authority=cloud.endpoints.active_directory,
)
5 changes: 5 additions & 0 deletions lisa/sut_orchestrator/bmi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

# Register BMI pluggy hookspecs on package import so extensions can hook in.
from . import hooks # noqa: F401
Loading
Loading