Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ stricter subset of Keep a Changelog).
- buttercup-seed-gen to registry/ and example/
- `libCRS download-source fuzz-proj <dest>`: copies clean fuzz project
- `libCRS download-source target-source <dest>`: 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.
Expand Down
66 changes: 64 additions & 2 deletions oss_crs/src/crs_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +20,7 @@
build_snapshot_tag,
preserved_builder_image_name,
rm_with_docker,
get_host_memory,
log_success,
log_warning,
log_dim,
Expand All @@ -29,6 +30,8 @@
check_cgroup_parent_available,
create_run_cgroups,
cleanup_cgroup,
parse_cpuset,
parse_memory_to_bytes,
)

import docker
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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():
Expand Down
20 changes: 20 additions & 0 deletions oss_crs/src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import string
import time
import re
import os
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -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
# =============================================================================
Expand Down
174 changes: 174 additions & 0 deletions oss_crs/tests/unit/test_validate_machine_resources.py
Original file line number Diff line number Diff line change
@@ -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)