diff --git a/python/ironic-understack/ironic_understack/flavor_spec.py b/python/ironic-understack/ironic_understack/flavor_spec.py index c3939e104..d5277a133 100644 --- a/python/ironic-understack/ironic_understack/flavor_spec.py +++ b/python/ironic-understack/ironic_understack/flavor_spec.py @@ -3,6 +3,8 @@ import yaml +from ironic_understack.machine import Machine + @dataclass class FlavorSpec: @@ -42,3 +44,57 @@ def from_directory(directory: str = "/etc/flavors/") -> list["FlavorSpec"]: 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 diff --git a/python/ironic-understack/ironic_understack/matcher.py b/python/ironic-understack/ironic_understack/matcher.py index 025458a3a..8d9f59cfe 100644 --- a/python/ironic-understack/ironic_understack/matcher.py +++ b/python/ironic-understack/ironic_understack/matcher.py @@ -6,67 +6,13 @@ class Matcher: def __init__(self, flavors: list[FlavorSpec]): self.flavors = flavors - def score_machine_to_flavor(self, machine: Machine, flavor: FlavorSpec) -> int: - # 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 == flavor.memory_gb and - machine.disk_gb in flavor.drives and - machine.cpu in flavor.cpu_models - ): - return 100 - - # Rule 2: If machine has less memory than specified in the flavor, it cannot be used - if machine.memory_gb < flavor.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 flavor.drives): - return 0 - - # Rule 4: Machine must match the flavor on one of the CPU models exactly - if machine.cpu not in flavor.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 == flavor.memory_gb: - score += 10 - elif machine.memory_gb > flavor.memory_gb: - score += 5 # Less desirable but still usable - - # Exact disk match gives preference - if machine.disk_gb in flavor.drives: - score += 10 - elif all(machine.disk_gb > drive for drive in flavor.drives): - score += 5 # Less desirable but still usable - - return score - 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 = self.score_machine_to_flavor(machine, flavor) + score = flavor.score_machine(machine) if score > 0: results.append(flavor) return results diff --git a/python/ironic-understack/ironic_understack/tests/test_flavor_spec.py b/python/ironic-understack/ironic_understack/tests/test_flavor_spec.py index 8a3ae0586..a3e7e70a5 100644 --- a/python/ironic-understack/ironic_understack/tests/test_flavor_spec.py +++ b/python/ironic-understack/ironic_understack/tests/test_flavor_spec.py @@ -2,6 +2,7 @@ import pytest from ironic_understack.flavor_spec import FlavorSpec +from ironic_understack.machine import Machine @pytest.fixture @@ -91,3 +92,127 @@ def test_from_directory_with_real_files(yaml_directory): def test_empty_directory(tmp_path): specs = FlavorSpec.from_directory(str(tmp_path)) assert len(specs) == 0 + +@pytest.fixture +def machines(): + return [ + # 1024 GB, exact CPU, medium + Machine( + memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1000 + ), + # 800 GB, non-matching CPU + Machine(memory_mb=800000, cpu="Intel Xeon E5-2676 v3", disk_gb=500), + # 200 GB, exact CPU, medium + Machine(memory_mb=200000, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1500), + # 300 GB, non-matching CPU + Machine(memory_mb=300000, cpu="Intel Xeon E5-2676 v3", disk_gb=500), + # 409 GB, exact CPU, large + Machine(memory_mb=409600, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=2000), + ] + + +@pytest.fixture +def flavors(): + return [ + FlavorSpec( + name="small", + memory_gb=100, + cpu_cores=13, + cpu_models=["AMD EPYC 9254 245-Core Processor", "Pentium 60"], + drives=[500, 500], + devices=[], + ), + FlavorSpec( + name="medium", + memory_gb=200, + cpu_cores=15, + cpu_models=["AMD EPYC 9254 245-Core Processor", "Intel 80386DX"], + drives=[1500, 1500], + devices=[], + ), + FlavorSpec( + name="large", + memory_gb=400, + cpu_cores=27, + cpu_models=["AMD EPYC 9254 245-Core Processor"], + drives=[1800, 1800], + devices=[], + ), + ] + +def test_exact_match(flavors): + machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) + assert flavors[0].score_machine(machine) == 100 + assert flavors[1].score_machine(machine) == 0 + + +def test_memory_too_small(flavors): + machine = Machine(memory_mb=51200, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 + + +def test_disk_too_small(flavors): + machine = Machine(memory_mb=204800, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=100) + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 + + +def test_cpu_model_not_matching(flavors): + machine = Machine(memory_mb=102400, cpu="Non-Existent CPU Model", disk_gb=500) + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 + + +def test_memory_match_but_more_disk(flavors): + machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1000) + assert flavors[0].score_machine(machine) > 0 + + +def test_disk_match_but_more_memory(flavors): + machine = Machine(memory_mb=204800, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) + + assert flavors[0].score_machine(machine) > 0 + assert flavors[1].score_machine(machine) == 0 + assert flavors[2].score_machine(machine) == 0 + +# Edge cases +def test_memory_slightly_less(flavors): + # Machine with slightly less memory than required by the smallest flavor + machine = Machine(memory_mb=102300, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) + # Should not match because memory is slightly less + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 + + +def test_disk_slightly_less(flavors): + # Machine with slightly less disk space than required by the smallest flavor + machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=499) + # Should not match because disk space is slightly less + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 + + +def test_memory_exact_disk_slightly_more(flavors): + # Machine with exact memory but slightly more disk space than required + machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=501) + assert flavors[0].score_machine(machine) > 0 + assert flavors[1].score_machine(machine) == 0 + assert flavors[2].score_machine(machine) == 0 + + +def test_disk_exact_memory_slightly_more(flavors): + # Machine with exact disk space but slightly more memory than required + machine = Machine(memory_mb=102500, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) + assert flavors[0].score_machine(machine) > 0 + assert flavors[1].score_machine(machine) == 0 + assert flavors[2].score_machine(machine) == 0 + + +def test_cpu_model_not_exact_but_memory_and_disk_match(flavors): + # Machine with exact memory and disk space but CPU model is close but not exact + machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor v2", disk_gb=500) + # Should not match because CPU model is not exactly listed + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 + + +def test_large_flavor_memory_slightly_less_disk_exact(flavors): + # Machine with slightly less memory than required for the medium flavor, exact disk space + machine = Machine(memory_mb=204600, cpu="Intel 80386DX", disk_gb=1800) + # Should not match because memory is slightly less than required + assert all(flavor.score_machine(machine) for flavor in flavors) == 0 diff --git a/python/ironic-understack/ironic_understack/tests/test_machine.py b/python/ironic-understack/ironic_understack/tests/test_machine.py new file mode 100644 index 000000000..a17bb6b43 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_machine.py @@ -0,0 +1,19 @@ +import pytest +from ironic_understack.machine import Machine + +def test_memory_gb_property(): + # Test a machine with exactly 1 GB of memory + machine = Machine(memory_mb=1024, cpu="x86", disk_gb=50) + assert machine.memory_gb == 1 + + # Test a machine with 2 GB of memory + machine = Machine(memory_mb=2048, cpu="x86", disk_gb=50) + assert machine.memory_gb == 2 + + # Test a machine with non-exact GB memory (should floor the value) + machine = Machine(memory_mb=3072, cpu="x86", disk_gb=50) + assert machine.memory_gb == 3 + + # Test a machine with less than 1 GB of memory + machine = Machine(memory_mb=512, cpu="x86", disk_gb=50) + assert machine.memory_gb == 0 diff --git a/python/ironic-understack/ironic_understack/tests/test_matcher.py b/python/ironic-understack/ironic_understack/tests/test_matcher.py index 3c3384171..fbc7558fc 100644 --- a/python/ironic-understack/ironic_understack/tests/test_matcher.py +++ b/python/ironic-understack/ironic_understack/tests/test_matcher.py @@ -3,169 +3,43 @@ from ironic_understack.machine import Machine from ironic_understack.matcher import Matcher - @pytest.fixture -def machines(): +def sample_flavors(): return [ - # 1024 GB, exact CPU, medium - Machine( - memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1000 - ), - # 800 GB, non-matching CPU - Machine(memory_mb=800000, cpu="Intel Xeon E5-2676 v3", disk_gb=500), - # 200 GB, exact CPU, medium - Machine(memory_mb=200000, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1500), - # 300 GB, non-matching CPU - Machine(memory_mb=300000, cpu="Intel Xeon E5-2676 v3", disk_gb=500), - # 409 GB, exact CPU, large - Machine(memory_mb=409600, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=2000), + FlavorSpec(name="small", memory_gb=4, cpu_cores=2, cpu_models=["x86", "ARM"], drives=[20], devices=[]), + FlavorSpec(name="medium", memory_gb=8, cpu_cores=4, cpu_models=["x86"], drives=[40], devices=[]), + FlavorSpec(name="large", memory_gb=16, cpu_cores=8, cpu_models=["x86"], drives=[80], devices=[]), ] - @pytest.fixture -def flavors(): - return [ - FlavorSpec( - name="small", - memory_gb=100, - cpu_cores=13, - cpu_models=["AMD EPYC 9254 245-Core Processor", "Pentium 60"], - drives=[500, 500], - devices=[], - ), - FlavorSpec( - name="medium", - memory_gb=200, - cpu_cores=15, - cpu_models=["AMD EPYC 9254 245-Core Processor", "Intel 80386DX"], - drives=[1500, 1500], - devices=[], - ), - FlavorSpec( - name="large", - memory_gb=400, - cpu_cores=27, - cpu_models=["AMD EPYC 9254 245-Core Processor"], - drives=[1800, 1800], - devices=[], - ), - ] - -def test_exact_match(flavors): - machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 1 - assert matched_flavors[0].name == "small" - - -def test_memory_too_small(flavors): - machine = Machine(memory_mb=51200, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 - - -def test_disk_too_small(flavors): - machine = Machine(memory_mb=204800, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=100) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 +def matcher(sample_flavors): + return Matcher(flavors=sample_flavors) - -def test_cpu_model_not_matching(flavors): - machine = Machine(memory_mb=102400, cpu="Non-Existent CPU Model", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 - - -def test_memory_match_but_more_disk(flavors): - machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1000) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 1 - assert matched_flavors[0].name == "small" - - -def test_disk_match_but_more_memory(flavors): - machine = Machine(memory_mb=204800, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 1 - assert matched_flavors[0].name == "small" - - -def test_pick_best_flavor(flavors): - machine = Machine(memory_mb=204800, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=1500) - matcher = Matcher(flavors) +@pytest.fixture +def machine(): + return Machine(memory_mb=8192, cpu="x86", disk_gb=50) + +def test_match(matcher, machine): + # This machine should match the small and medium flavors + results = matcher.match(machine) + assert len(results) == 2 + assert results[0].name == "small" + assert results[1].name == "medium" + +def test_match_no_flavor(matcher): + # A machine that does not meet any flavor specs + machine = Machine(memory_mb=2048, cpu="x86", disk_gb=10) + results = matcher.match(machine) + assert len(results) == 0 + +def test_pick_best_flavor2(matcher, machine): + # This machine should pick the medium flavor as the best best_flavor = matcher.pick_best_flavor(machine) assert best_flavor is not None assert best_flavor.name == "medium" - -def test_no_matching_flavor(flavors): - machine = Machine(memory_mb=51200, cpu="Non-Existent CPU Model", disk_gb=250) - matcher = Matcher(flavors) +def test_pick_best_flavor_no_match(matcher): + # A machine that does not meet any flavor specs + machine = Machine(memory_mb=1024, cpu="ARM", disk_gb=10) best_flavor = matcher.pick_best_flavor(machine) assert best_flavor is None - - -def test_multiple_flavors_available(flavors): - machine = Machine(memory_mb=204800, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=2000) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 2 # small and medium should be available - best_flavor = matcher.pick_best_flavor(machine) - assert best_flavor.name == "medium" # medium has more memory, so it should be selected - - -# Edge cases -def test_memory_slightly_less(flavors): - # Machine with slightly less memory than required by the smallest flavor - machine = Machine(memory_mb=102300, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 # Should not match because memory is slightly less - - -def test_disk_slightly_less(flavors): - # Machine with slightly less disk space than required by the smallest flavor - machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=499) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 # Should not match because disk space is slightly less - - -def test_memory_exact_disk_slightly_more(flavors): - # Machine with exact memory but slightly more disk space than required - machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=501) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 1 - assert matched_flavors[0].name == "small" # Should match but with a lower score due to extra disk space - - -def test_disk_exact_memory_slightly_more(flavors): - # Machine with exact disk space but slightly more memory than required - machine = Machine(memory_mb=102500, cpu="AMD EPYC 9254 245-Core Processor", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 1 - assert matched_flavors[0].name == "small" # Should match but with a lower score due to extra memory - - -def test_cpu_model_not_exact_but_memory_and_disk_match(flavors): - # Machine with exact memory and disk space but CPU model is close but not exact - machine = Machine(memory_mb=102400, cpu="AMD EPYC 9254 245-Core Processor v2", disk_gb=500) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 # Should not match because CPU model is not exactly listed - - -def test_large_flavor_memory_slightly_less_disk_exact(flavors): - # Machine with slightly less memory than required for the medium flavor, exact disk space - machine = Machine(memory_mb=204600, cpu="Intel 80386DX", disk_gb=1800) - matcher = Matcher(flavors) - matched_flavors = matcher.match(machine) - assert len(matched_flavors) == 0 # Should not match because memory is slightly less than required