-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #245 from rackerlabs/PUC-433-create-ironic-hooks-t…
…o-set-resource-class feat: create ironic hooks to set resource class
- Loading branch information
Showing
16 changed files
with
3,693 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Understack Ironic plugin |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
from oslo_config import cfg | ||
CONF = cfg.CONF | ||
|
||
|
||
def setup_conf(): | ||
grp = cfg.OptGroup("ironic_understack") | ||
opts = [ | ||
cfg.StrOpt( | ||
"flavors_dir", | ||
help="directory storing Flavor description YAML files", | ||
default="/var/lib/understack/flavors/undercloud-nautobot-device-types.git/flavors", | ||
) | ||
] | ||
cfg.CONF.register_group(grp) | ||
cfg.CONF.register_opts(opts, group=grp) | ||
|
||
|
||
setup_conf() |
100 changes: 100 additions & 0 deletions
100
python/ironic-understack/ironic_understack/flavor_spec.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import os | ||
from dataclasses import dataclass | ||
|
||
import yaml | ||
|
||
from ironic_understack.machine import Machine | ||
|
||
|
||
@dataclass | ||
class FlavorSpec: | ||
name: str | ||
memory_gb: int | ||
cpu_cores: int | ||
cpu_models: list[str] | ||
drives: list[int] | ||
devices: list[str] | ||
|
||
@staticmethod | ||
def from_yaml(yaml_str: str) -> "FlavorSpec": | ||
data = yaml.safe_load(yaml_str) | ||
return FlavorSpec( | ||
name=data["name"], | ||
memory_gb=data["memory_gb"], | ||
cpu_cores=data["cpu_cores"], | ||
cpu_models=data["cpu_models"], | ||
drives=data["drives"], | ||
devices=data["devices"], | ||
) | ||
|
||
@staticmethod | ||
def from_directory(directory: str = "/etc/flavors/") -> list["FlavorSpec"]: | ||
flavor_specs = [] | ||
for root, _, files in os.walk(directory): | ||
for filename in files: | ||
if filename.endswith(".yaml") or filename.endswith(".yml"): | ||
filepath = os.path.join(root, filename) | ||
try: | ||
with open(filepath, "r") as file: | ||
yaml_content = file.read() | ||
flavor_spec = FlavorSpec.from_yaml(yaml_content) | ||
flavor_specs.append(flavor_spec) | ||
except yaml.YAMLError as e: | ||
print(f"Error parsing YAML file {filename}: {e}") | ||
except Exception as e: | ||
print(f"Error processing file {filename}: {e}") | ||
return flavor_specs | ||
|
||
def score_machine(self, machine: Machine): | ||
# Scoring Rules: | ||
# | ||
# 1. 100% match gets highest priority, no further evaluation needed | ||
# 2. If the machine has less memory size than specified in the flavor, | ||
# it cannot be used - the score should be 0. | ||
# 3. If the machine has smaller disk size than specified in the flavor, | ||
# it cannot be used - the score should be 0. | ||
# 4. Machine must match the flavor on one of the CPU models exactly. | ||
# 5. If the machine has exact amount memory as specified in flavor, but | ||
# more disk space it is less desirable than the machine that matches | ||
# exactly on both disk and memory. | ||
# 6. If the machine has exact amount of disk as specified in flavor, | ||
# but more memory space it is less desirable than the machine that | ||
# matches exactly on both disk and memory. | ||
|
||
|
||
# Rule 1: 100% match gets the highest priority | ||
if ( | ||
machine.memory_gb == self.memory_gb and | ||
machine.disk_gb in self.drives and | ||
machine.cpu in self.cpu_models | ||
): | ||
return 100 | ||
|
||
# Rule 2: If machine has less memory than specified in the flavor, it cannot be used | ||
if machine.memory_gb < self.memory_gb: | ||
return 0 | ||
|
||
# Rule 3: If machine has smaller disk than specified in the flavor, it cannot be used | ||
if any(machine.disk_gb < drive for drive in self.drives): | ||
return 0 | ||
|
||
# Rule 4: Machine must match the flavor on one of the CPU models exactly | ||
if machine.cpu not in self.cpu_models: | ||
return 0 | ||
|
||
# Rule 5 and 6: Rank based on exact matches or excess capacity | ||
score = 0 | ||
|
||
# Exact memory match gives preference | ||
if machine.memory_gb == self.memory_gb: | ||
score += 10 | ||
elif machine.memory_gb > self.memory_gb: | ||
score += 5 # Less desirable but still usable | ||
|
||
# Exact disk match gives preference | ||
if machine.disk_gb in self.drives: | ||
score += 10 | ||
elif all(machine.disk_gb > drive for drive in self.drives): | ||
score += 5 # Less desirable but still usable | ||
|
||
return score |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from dataclasses import dataclass | ||
|
||
|
||
@dataclass | ||
class Machine: | ||
memory_mb: int | ||
cpu: str | ||
disk_gb: int | ||
|
||
@property | ||
def memory_gb(self) -> int: | ||
return self.memory_mb // 1024 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
from ironic_understack.machine import Machine | ||
from ironic_understack.flavor_spec import FlavorSpec | ||
|
||
|
||
class Matcher: | ||
def __init__(self, flavors: list[FlavorSpec]): | ||
self.flavors = flavors | ||
|
||
def match(self, machine: Machine) -> list[FlavorSpec]: | ||
""" | ||
Find list of all flavors that the machine is eligible for. | ||
""" | ||
results = [] | ||
for flavor in self.flavors: | ||
score = flavor.score_machine(machine) | ||
if score > 0: | ||
results.append(flavor) | ||
return results | ||
|
||
def pick_best_flavor(self, machine: Machine) -> FlavorSpec | None: | ||
""" | ||
Obtains list of all flavors that particular machine can be classified | ||
as, then tries to select "the best" one. | ||
""" | ||
|
||
possible = self.match(machine) | ||
|
||
if len(possible) == 0: | ||
return None | ||
return max(possible, key=lambda flv: flv.memory_gb) |
111 changes: 111 additions & 0 deletions
111
python/ironic-understack/ironic_understack/redfish_inspect_understack.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | ||
# not use this file except in compliance with the License. You may obtain | ||
# a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||
# License for the specific language governing permissions and limitations | ||
# under the License. | ||
""" | ||
Redfish Inspect Interface modified for Understack | ||
""" | ||
|
||
from ironic.drivers.drac import IDRACHardware | ||
from ironic.drivers.modules.drac.inspect import DracRedfishInspect | ||
from ironic.drivers.modules.inspect_utils import get_inspection_data | ||
from ironic.drivers.modules.redfish.inspect import RedfishInspect | ||
from ironic.drivers.redfish import RedfishHardware | ||
from ironic_understack.flavor_spec import FlavorSpec | ||
from ironic_understack.machine import Machine | ||
from ironic_understack.matcher import Matcher | ||
from ironic_understack.conf import CONF | ||
from oslo_log import log | ||
from oslo_utils import units | ||
|
||
LOG = log.getLogger(__name__) | ||
FLAVORS = FlavorSpec.from_directory(CONF.ironic_understack.flavors_dir) | ||
LOG.info(f"Loaded {len(FLAVORS)} flavor specifications.") | ||
|
||
|
||
class FlavorInspectMixin: | ||
def inspect_hardware(self, task): | ||
"""Inspect hardware to get the hardware properties. | ||
Inspects hardware to get the essential properties. | ||
It fails if any of the essential properties | ||
are not received from the node. | ||
:param task: a TaskManager instance. | ||
:raises: HardwareInspectionFailure if essential properties | ||
could not be retrieved successfully. | ||
:returns: The resulting state of inspection. | ||
""" | ||
upstream_state = super().inspect_hardware(task) # pyright: ignore reportAttributeAccessIssue | ||
|
||
inspection_data = get_inspection_data(task.node, task.context) | ||
|
||
inventory = inspection_data or {} | ||
if not inventory: | ||
LOG.warn(f"No inventory found for node {task.node}") | ||
|
||
inventory = inventory["inventory"] | ||
LOG.debug(f"Retrieved {inspection_data=}") | ||
|
||
if not (inventory.get("memory") and "physical_mb" in inventory["memory"]): | ||
LOG.warn("No memory_mb property detected, skipping flavor setting.") | ||
return upstream_state | ||
|
||
if not (inventory.get("disks") and inventory["disks"][0].get("size")): | ||
LOG.warn("No disks detected, skipping flavor setting.") | ||
return upstream_state | ||
|
||
if not (inventory.get("cpu") and inventory["cpu"]["model_name"]): | ||
LOG.warn("No CPUS detected, skipping flavor setting.") | ||
return upstream_state | ||
|
||
smallest_disk_gb = min([disk["size"] / units.Gi for disk in inventory["disks"]]) | ||
machine = Machine( | ||
memory_mb=inventory["memory"]["physical_mb"], | ||
disk_gb=smallest_disk_gb, | ||
cpu=inventory["cpu"]["model_name"], | ||
) | ||
|
||
matcher = Matcher(FLAVORS) | ||
best_flavor = matcher.pick_best_flavor(machine) | ||
if not best_flavor: | ||
LOG.warn(f"No flavor matched for {task.node.uuid}") | ||
return upstream_state | ||
LOG.info(f"Matched {task.node.uuid} to flavor {best_flavor}") | ||
|
||
task.node.resource_class = f"baremetal.{best_flavor.name}" | ||
task.node.save() | ||
|
||
return upstream_state | ||
|
||
|
||
class UnderstackRedfishInspect(FlavorInspectMixin, RedfishInspect): | ||
def __init__(self, *args, **kwargs) -> None: | ||
super().__init__(*args, **kwargs) | ||
patched_ifaces = RedfishHardware().supported_inspect_interfaces | ||
patched_ifaces.append(UnderstackDracRedfishInspect) | ||
setattr( | ||
RedfishHardware, | ||
"supported_inspect_interfaces", | ||
property(lambda _: patched_ifaces), | ||
) | ||
|
||
|
||
class UnderstackDracRedfishInspect(FlavorInspectMixin, DracRedfishInspect): | ||
def __init__(self, *args, **kwargs) -> None: | ||
super().__init__(*args, **kwargs) | ||
patched_ifaces = IDRACHardware().supported_inspect_interfaces | ||
patched_ifaces.append(UnderstackDracRedfishInspect) | ||
setattr( | ||
IDRACHardware, | ||
"supported_inspect_interfaces", | ||
property(lambda _: patched_ifaces), | ||
) |
59 changes: 59 additions & 0 deletions
59
python/ironic-understack/ironic_understack/resource_class.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
# from ironic.drivers.modules.inspector.hooks import base | ||
from ironic.common import exception | ||
from ironic.drivers.modules.inspector.hooks import base | ||
from ironic_understack.flavor_spec import FlavorSpec | ||
from ironic_understack.machine import Machine | ||
from ironic_understack.matcher import Matcher | ||
from oslo_log import log as logging | ||
|
||
LOG = logging.getLogger(__name__) | ||
|
||
FLAVORS = FlavorSpec.from_directory(CONF.ironic_understack.flavors_dir) | ||
LOG.info(f"Loaded {len(FLAVORS)} flavor specifications.") | ||
|
||
|
||
class NoMatchError(Exception): | ||
pass | ||
|
||
|
||
class UndercloudResourceClassHook(base.InspectionHook): | ||
"""Hook to set the node's resource_class based on the inventory.""" | ||
|
||
def __call__(self, task, inventory, plugin_data): | ||
"""Update node resource_class with deducted flavor.""" | ||
|
||
try: | ||
memory_mb = inventory["memory"]["physical_mb"] | ||
disk_size_gb = int(int(inventory["disks"][0]["size"]) / 10**9) | ||
cpu_model_name = inventory["cpu"]["model_name"] | ||
|
||
machine = Machine( | ||
memory_mb=memory_mb, cpu=cpu_model_name, disk_gb=disk_size_gb | ||
) | ||
|
||
resource_class_name = self.classify(machine) | ||
|
||
LOG.info( | ||
"Discovered resources_class: %s for node %s", | ||
resource_class_name, | ||
task.node.uuid, | ||
) | ||
task.node.resource_class = resource_class_name | ||
task.node.save() | ||
except (KeyError, ValueError, TypeError): | ||
msg = ( | ||
f"Inventory has missing hardware information for node {task.node.uuid}." | ||
) | ||
LOG.error(msg) | ||
raise exception.InvalidNodeInventory(node=task.node.uuid, reason=msg) | ||
except NoMatchError: | ||
msg = f"No matching flavor found for {task.node.uuid}" | ||
LOG.error(msg) | ||
|
||
def classify(self, machine): | ||
matcher = Matcher(FLAVORS) | ||
flavor = matcher.pick_best_flavor(machine) | ||
if not flavor: | ||
raise NoMatchError(f"No flavor found for {machine}") | ||
else: | ||
return flavor |
Empty file.
Oops, something went wrong.