Skip to content

Commit

Permalink
Merge pull request #245 from rackerlabs/PUC-433-create-ironic-hooks-t…
Browse files Browse the repository at this point in the history
…o-set-resource-class

feat: create ironic hooks to set resource class
  • Loading branch information
cardoe authored Aug 30, 2024
2 parents 049fa76 + 3c434b5 commit d835258
Show file tree
Hide file tree
Showing 16 changed files with 3,693 additions and 3 deletions.
26 changes: 24 additions & 2 deletions .github/workflows/code-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ on:
branches:
- main
paths:
- "python/**"
- "python/ironic-understack/**"
- "python/understack-workflows/**"
- ".github/workflows/code-test.yaml"
pull_request:
paths:
- "python/**"
- "python/ironic-understack/**"
- "python/understack-workflows/**"
- ".github/workflows/code-test.yaml"
workflow_dispatch:

Expand All @@ -34,3 +36,23 @@ jobs:
with:
coverageFile: python/understack-workflows/coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
ironic-understack:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./python/ironic-understack

steps:
- uses: actions/checkout@v4
- run: pipx install poetry==1.7.1 && poetry self add 'poetry-dynamic-versioning[plugin]'
- uses: actions/setup-python@v5
with:
python-version-file: python/ironic-understack/pyproject.toml
cache: "poetry"
- run: poetry install --sync --with test
- run: poetry build
- run: "poetry run pytest --cov-report xml:coverage.xml"
- uses: orgoro/coverage@v3.2
with:
coverageFile: python/ironic-understack/coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
4 changes: 3 additions & 1 deletion containers/Dockerfile.ironic
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ RUN apt-get update && \
genisoimage \
isolinux \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN /var/lib/openstack/bin/python -m pip install --no-cache sushy-oem-idrac==5.0.0

COPY python/ironic-understack /tmp/ironic-understack
RUN /var/lib/openstack/bin/python -m pip install --no-cache --no-cache-dir /tmp/ironic-understack sushy-oem-idrac==5.0.0
1 change: 1 addition & 0 deletions python/ironic-understack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Understack Ironic plugin
Empty file.
18 changes: 18 additions & 0 deletions python/ironic-understack/ironic_understack/conf.py
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 python/ironic-understack/ironic_understack/flavor_spec.py
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
13 changes: 13 additions & 0 deletions python/ironic-understack/ironic_understack/machine.py
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

30 changes: 30 additions & 0 deletions python/ironic-understack/ironic_understack/matcher.py
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)
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 python/ironic-understack/ironic_understack/resource_class.py
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.
Loading

0 comments on commit d835258

Please sign in to comment.