diff --git a/CHANGELOG.md b/CHANGELOG.md index c754ffd..9b1a549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ stricter subset of Keep a Changelog). - buttercup-seed-gen to registry/ and example/ - `libCRS download-source fuzz-proj `: copies clean fuzz project - `libCRS download-source target-source `: copies clean target source +- Warn user when compose file resource configs exceed machine resources (#49) ### Changed - Builder sidecar redesigned: framework-injected ephemeral containers replace CRS-declared long-running builders. Rebuilds launch a fresh container per patch from the preserved builder image. diff --git a/oss_crs/src/crs_compose.py b/oss_crs/src/crs_compose.py index d35fa33..0a7cd75 100644 --- a/oss_crs/src/crs_compose.py +++ b/oss_crs/src/crs_compose.py @@ -5,7 +5,7 @@ import os import hashlib from pathlib import Path -from typing import Optional +from typing import Callable, Optional from .config.crs_compose import CRSComposeConfig, CRSComposeEnv, RunEnv from .env_policy import OSS_FUZZ_TARGET_ENV, build_target_builder_env from .llm import LLM @@ -20,6 +20,7 @@ build_snapshot_tag, preserved_builder_image_name, rm_with_docker, + get_host_memory, log_success, log_warning, log_dim, @@ -29,6 +30,8 @@ check_cgroup_parent_available, create_run_cgroups, cleanup_cgroup, + parse_cpuset, + parse_memory_to_bytes, ) import docker @@ -62,6 +65,11 @@ def __init__( ) for name, crs_cfg in self.config.crs_entries.items() ] + + builder_count = sum(1 for crs in self.crs_list if crs.config.is_builder) + if builder_count > 1: + raise ValueError("At most one CRS entry with type 'builder' is allowed") + self.deadline: Optional[float] = None def _resolve_target_build_options( @@ -653,7 +661,12 @@ def build_target( return False resolved_source_path = source_dir - tasks = [] + tasks: list[tuple[str, Callable[[MultiTaskProgress], TaskResult]]] = [ + ( + "Validate machine resources", + lambda _: self._validate_machine_resources(), + ), + ] if bug_candidate is not None and bug_candidate_dir is not None: print( @@ -918,6 +931,51 @@ def _validate_required_inputs( return TaskResult(success=False, error="\n".join(errors)) return TaskResult(success=True) + def _validate_machine_resources(self) -> TaskResult: + """Validate that machine resources and resource config do not conflict.""" + # Get machine CPU count and memory (in Bytes) + machine_cpu_count = os.cpu_count() + machine_memory = get_host_memory() + + if machine_cpu_count is None or machine_memory is None: + log_warning( + "Could not determine machine resources. Skipping resource check." + ) + return TaskResult(success=True) + + # Collect entries for shared infra and all crs + entries = [("oss_crs_infra", self.config.oss_crs_infra)] + for name, crs_cfg in self.config.crs_entries.items(): + entries.append((name, crs_cfg)) + + total_memory_required = 0 + max_cpus_required = 0 + + for name, cfg in entries: + # Calculate max CPU needed + try: + cpus = parse_cpuset(cfg.cpuset) + max_cpus_required = max(max_cpus_required, max(cpus)) + except ValueError as e: + log_warning(f"Failed to validate cpuset for {name}: {e}") + + # Calculate max memory needed + try: + total_memory_required += parse_memory_to_bytes(cfg.memory) + except ValueError as e: + log_warning(f"Failed to parse memory for {name}: {e}") + + cpu_check = max_cpus_required < machine_cpu_count + memory_check = total_memory_required <= machine_memory + + if not cpu_check or not memory_check: + log_warning( + f"Machine does not have adequate resources. " + f"Only {machine_cpu_count} CPUs and {machine_memory // (1024**3)}G memory available. " + f"Please edit the compose file. " + ) + return TaskResult(success=True) + def __validate_before_run( self, target: Target, @@ -941,6 +999,10 @@ def __validate_before_run( bug_candidate_dir=bug_candidate_dir, ), ), + ( + "Validate machine resources", + lambda _: self._validate_machine_resources(), + ), ] if self.llm.exists(): diff --git a/oss_crs/src/utils.py b/oss_crs/src/utils.py index 35d4f6a..3ad2a32 100644 --- a/oss_crs/src/utils.py +++ b/oss_crs/src/utils.py @@ -5,6 +5,7 @@ import string import time import re +import os from pathlib import Path from typing import Optional @@ -187,6 +188,25 @@ def rm_with_docker(path: Path) -> None: raise RuntimeError(f"Error removing {path} with Docker: {e}") +def get_host_memory() -> int | None: + "Returns the machine's total memory in bytes." + try: + pagesize = os.sysconf("SC_PAGE_SIZE") + pages = os.sysconf("SC_PHYS_PAGES") + if pagesize > 0 and pages > 0: + return pagesize * pages + + except OSError: + pass + + meminfo = Path("/proc/meminfo") + if meminfo.exists(): + for line in meminfo.read_text().splitlines(): + if line.startswith("MemTotal:"): + return int(line.split()[1]) * 1024 + return None + + # ============================================================================= # Text Styling Helpers # ============================================================================= diff --git a/oss_crs/tests/unit/test_validate_machine_resources.py b/oss_crs/tests/unit/test_validate_machine_resources.py new file mode 100644 index 0000000..1a8ed11 --- /dev/null +++ b/oss_crs/tests/unit/test_validate_machine_resources.py @@ -0,0 +1,174 @@ +"""Unit tests for _validate_machine_resources step in CRSCompose.""" + +from typing import cast + +import pytest + +from oss_crs.src.config.crs_compose import CRSComposeConfig +from oss_crs.src.crs_compose import CRSCompose + +MOCK_CPU_COUNT = 12 +MOCK_MEMORY = 32 * (1024**3) + + +class _FakeResource: + def __init__(self, cpuset: str, memory: str): + self.cpuset = cpuset + self.memory = memory + + +class _FakeComposeConfig: + def __init__(self, infra: _FakeResource, crs_entries: dict): + self.oss_crs_infra = infra + self.crs_entries = crs_entries + + +def _make_compose( + infra_cpuset: str, + infra_memory: str = "4G", + crs_entries: dict | None = None, +) -> CRSCompose: + compose = object.__new__(CRSCompose) + compose.config = cast( + CRSComposeConfig, + _FakeComposeConfig( + _FakeResource(infra_cpuset, infra_memory), + crs_entries or {}, + ), + ) + return compose + + +@pytest.fixture +def patched_resources(monkeypatch): + """Patch machine resource detection with standard mock values. + + Returns a list that captures all log_warning calls. + """ + warnings = [] + monkeypatch.setattr("oss_crs.src.crs_compose.os.cpu_count", lambda: MOCK_CPU_COUNT) + monkeypatch.setattr("oss_crs.src.crs_compose.get_host_memory", lambda: MOCK_MEMORY) + monkeypatch.setattr( + "oss_crs.src.crs_compose.log_warning", lambda msg: warnings.append(msg) + ) + return warnings + + +## CPU and memory checks + + +def test_cpu_within_bounds_no_warning(patched_resources): + compose = _make_compose(f"0-{MOCK_CPU_COUNT - 2}") + compose._validate_machine_resources() + assert len(patched_resources) == 0 + + +def test_cpu_at_max_valid_id_no_warning(patched_resources): + compose = _make_compose(f"0-{MOCK_CPU_COUNT - 1}") + compose._validate_machine_resources() + assert len(patched_resources) == 0 + + +def test_cpu_equals_machine_count_warns(patched_resources): + compose = _make_compose(f"0-{MOCK_CPU_COUNT}") + compose._validate_machine_resources() + assert len(patched_resources) == 1 + assert "does not have adequate resources" in patched_resources[0] + + +def test_cpu_exceeds_machine_count_warns(patched_resources): + compose = _make_compose(f"0-{MOCK_CPU_COUNT + 1}") + compose._validate_machine_resources() + assert len(patched_resources) == 1 + assert "does not have adequate resources" in patched_resources[0] + + +def test_memory_at_exact_limit_no_warning(patched_resources): + exact_memory = f"{MOCK_MEMORY // (1024**3)}G" + compose = _make_compose("0-3", exact_memory) + compose._validate_machine_resources() + assert len(patched_resources) == 0 + + +def test_memory_over_limit_warns(patched_resources): + over_memory = f"{(MOCK_MEMORY // (1024**3)) + 1}G" + compose = _make_compose("0-3", over_memory) + compose._validate_machine_resources() + assert len(patched_resources) == 1 + assert "does not have adequate resources" in patched_resources[0] + + +def test_crs_entry_exceeds_cpu_count_warns(patched_resources): + compose = _make_compose( + "0-3", + crs_entries={"crs-a": _FakeResource(f"0-{MOCK_CPU_COUNT}", "4G")}, + ) + compose._validate_machine_resources() + assert len(patched_resources) == 1 + assert "does not have adequate resources" in patched_resources[0] + + +def test_combined_memory_across_entries_warns(patched_resources): + half_memory = f"{MOCK_MEMORY // (1024**3) // 2 + 1}G" + compose = _make_compose( + "0-3", + infra_memory=half_memory, + crs_entries={ + "crs-a": _FakeResource("0-3", half_memory), + }, + ) + compose._validate_machine_resources() + assert len(patched_resources) == 1 + assert "does not have adequate resources" in patched_resources[0] + + +def test_both_adequate_no_warning(patched_resources): + compose = _make_compose("0-3", "4G") + compose._validate_machine_resources() + assert len(patched_resources) == 0 + + +## undetectable machine resources checks + + +def test_undetectable_cpu_skips_check(monkeypatch): + warnings = [] + monkeypatch.setattr("oss_crs.src.crs_compose.os.cpu_count", lambda: None) + monkeypatch.setattr("oss_crs.src.crs_compose.get_host_memory", lambda: MOCK_MEMORY) + monkeypatch.setattr( + "oss_crs.src.crs_compose.log_warning", lambda msg: warnings.append(msg) + ) + + compose = _make_compose("0-3") + compose._validate_machine_resources() + assert len(warnings) == 1 + assert "Could not determine machine resources" in warnings[0] + + +def test_undetectable_memory_skips_check(monkeypatch): + warnings = [] + monkeypatch.setattr("oss_crs.src.crs_compose.os.cpu_count", lambda: MOCK_CPU_COUNT) + monkeypatch.setattr("oss_crs.src.crs_compose.get_host_memory", lambda: None) + monkeypatch.setattr( + "oss_crs.src.crs_compose.log_warning", lambda msg: warnings.append(msg) + ) + + compose = _make_compose("0-3") + compose._validate_machine_resources() + assert len(warnings) == 1 + assert "Could not determine machine resources" in warnings[0] + + +# parsing error checks + + +def test_invalid_cpuset_warns(patched_resources): + compose = _make_compose("not-a-cpuset", "4G") + compose._validate_machine_resources() + assert any("Failed to validate cpuset" in w for w in patched_resources) + + +def test_invalid_memory_string_warns(patched_resources): + compose = _make_compose("0-3", "notmemory") + compose._validate_machine_resources() + assert any("Failed to parse memory" in w for w in patched_resources)