Skip to content
Draft
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
110 changes: 110 additions & 0 deletions docs/development/stress-testing-oom-kill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Stress Testing Holmes with Intentional OOM Kills

> ⚠️ **Never enable this toolset in production.** It allocates ~30 GB of RAM on the Holmes host and is intended only for controlled, non-production stress tests.

This guide explains how to intentionally trigger OOM kills using the built-in OOM toolsets, how to enable them safely, and how to confirm they are available.

## Available Toolsets

Holmes ships with two disabled-by-default toolsets for inducing an OOM kill:

- **`oom_kill` (Python)** – `trigger_oom_kill` allocates ~30 GB and sleeps for a configurable duration.
- **`oom_kill_bash` (YAML/bash)** – `trigger_oom_kill_bash` does the same via a bash-executed Python snippet and applies `ulimit -v 2097152` (2 GiB virtual memory cap) before allocation to reduce blast radius.

Both toolsets require the environment variable `ALLOW_HOLMES_OOMKILL_TOOLSET` to pass prerequisites and must be explicitly enabled in configuration. They are **disabled by default** and will not be loaded unless you opt in.

## Enabling via Helm/ArgoCD (cluster install)

Use the correct values path for your deployment method.

=== "Robusta Helm Chart (Holmes as subchart)"

1. **Set the env guard** (required):
```bash
argocd app set <APP_NAME> \
--helm-set-string holmes.additionalEnvVars[0].name=ALLOW_HOLMES_OOMKILL_TOOLSET \
--helm-set-string holmes.additionalEnvVars[0].value=true
```

2. **Enable the toolsets**:
```bash
argocd app set <APP_NAME> \
--helm-set-string holmes.toolsets.oom_kill.enabled=true \
--helm-set-string holmes.toolsets.oom_kill_bash.enabled=true
```

3. **Sync to apply**:
```bash
argocd app sync <APP_NAME>
```

4. **Verify** (optional):
```bash
kubectl -n <holmes-namespace> exec -it <holmes-pod> -- \
cat /app/custom_toolset.yaml
# Expect oom_kill and oom_kill_bash present and enabled
```

=== "Holmes Helm Chart (direct)"

1. **Set the env guard** (required):
```bash
argocd app set <APP_NAME> \
--helm-set-string additionalEnvVars[0].name=ALLOW_HOLMES_OOMKILL_TOOLSET \
--helm-set-string additionalEnvVars[0].value=true
```

2. **Enable the toolsets**:
```bash
argocd app set <APP_NAME> \
--helm-set-string toolsets.oom_kill.enabled=true \
--helm-set-string toolsets.oom_kill_bash.enabled=true
```

3. **Sync to apply**:
```bash
argocd app sync <APP_NAME>
```

4. **Verify** (optional):
```bash
kubectl -n <holmes-namespace> exec -it <holmes-pod> -- \
cat /app/custom_toolset.yaml
# Expect oom_kill and oom_kill_bash present and enabled
```

## Enabling in Local CLI Mode

Add to your local config (e.g., `config.yaml`) and set the env guard before running the CLI:

```yaml
toolsets:
oom_kill:
enabled: true
oom_kill_bash:
enabled: true
```

Then run:
```bash
export ALLOW_HOLMES_OOMKILL_TOOLSET=true
holmes --config ./config.yaml ...
```

Because both toolsets are disabled by default and gated by `ALLOW_HOLMES_OOMKILL_TOOLSET`, they will **not** be auto-enabled in local mode unless you explicitly enable them and set the env variable.

## Using the Tools

- **Python toolset**: `trigger_oom_kill` (param: `hold_seconds`, default 300).
- **Bash toolset**: `trigger_oom_kill_bash` (param: `hold_seconds`, default 300).

Example invocation (conceptual):
```
trigger_oom_kill: allocate ~30GB and sleep for 120s
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Safety Considerations

- Keep this toolset out of production environments.
- Ensure hosts have proper isolation; the process is expected to be OOM-killed.
- Consider running in a dedicated test cluster or namespace.
16 changes: 10 additions & 6 deletions holmes/core/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import shlex
import subprocess
import tempfile
import time
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum
Expand All @@ -30,21 +31,21 @@
PrivateAttr,
)
from rich.console import Console
from rich.table import Table

from holmes.core.llm import LLM
from holmes.core.openai_formatting import format_tool_to_open_ai_standard
from holmes.plugins.prompts import load_and_render_prompt
from holmes.core.transformers import (
registry,
TransformerError,
Transformer,
)
from holmes.plugins.prompts import load_and_render_prompt
from holmes.utils.config_utils import merge_transformers
from holmes.utils.memory_limit import get_ulimit_prefix, check_oom_and_append_hint

if TYPE_CHECKING:
from holmes.core.transformers import BaseTransformer
from holmes.utils.config_utils import merge_transformers
import time
from rich.table import Table

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -497,8 +498,9 @@ def __invoke_script(self, params) -> str:
def __execute_subprocess(self, cmd) -> Tuple[str, int]:
try:
logger.debug(f"Running `{cmd}`")
protected_cmd = get_ulimit_prefix() + cmd
result = subprocess.run(
cmd,
protected_cmd,
shell=True,
text=True,
check=False, # do not throw error, we just return the error code
Expand All @@ -507,7 +509,9 @@ def __execute_subprocess(self, cmd) -> Tuple[str, int]:
stderr=subprocess.STDOUT,
)

return result.stdout.strip(), result.returncode
output = result.stdout.strip()
output = check_oom_and_append_hint(output, result.returncode)
return output, result.returncode
except Exception as e:
logger.error(
f"An unexpected error occurred while running '{cmd}': {e}",
Expand Down
2 changes: 2 additions & 0 deletions holmes/plugins/toolsets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from holmes.plugins.toolsets.internet.notion import NotionToolset
from holmes.plugins.toolsets.kafka import KafkaToolset
from holmes.plugins.toolsets.kubernetes_logs import KubernetesLogsToolset
from holmes.plugins.toolsets.oom_kill import OOMKillToolset
from holmes.plugins.toolsets.mcp.toolset_mcp import RemoteMCPToolset
from holmes.plugins.toolsets.newrelic.newrelic import NewRelicToolset
from holmes.plugins.toolsets.opensearch.opensearch import OpenSearchToolset
Expand Down Expand Up @@ -96,6 +97,7 @@ def load_python_toolsets(
OpenSearchLogsToolset(),
OpenSearchTracesToolset(),
OpenSearchQueryAssistToolset(),
OOMKillToolset(),
CoralogixToolset(),
RabbitMQToolset(),
GitToolset(),
Expand Down
12 changes: 8 additions & 4 deletions holmes/plugins/toolsets/bash/common/bash.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import subprocess

from holmes.core.tools import StructuredToolResult, StructuredToolResultStatus
from holmes.utils.memory_limit import get_ulimit_prefix, check_oom_and_append_hint


def execute_bash_command(cmd: str, timeout: int, params: dict) -> StructuredToolResult:
try:
protected_cmd = get_ulimit_prefix() + cmd
process = subprocess.run(
cmd,
protected_cmd,
shell=True,
executable="/bin/bash",
stdout=subprocess.PIPE,
Expand All @@ -16,7 +19,8 @@ def execute_bash_command(cmd: str, timeout: int, params: dict) -> StructuredTool
)

stdout = process.stdout.strip() if process.stdout else ""
result_data = f"{cmd}\n" f"{stdout}"
stdout = check_oom_and_append_hint(stdout, process.returncode)
result_data = f"{cmd}\n{stdout}"

if process.returncode == 0:
status = (
Expand Down Expand Up @@ -44,10 +48,10 @@ def execute_bash_command(cmd: str, timeout: int, params: dict) -> StructuredTool
params=params,
)
except FileNotFoundError:
# This might occur if /bin/bash is not found, or if shell=False and command is not found
# This might occur if /bin/bash is not found, or command is not found
return StructuredToolResult(
status=StructuredToolResultStatus.ERROR,
error="Error: Bash executable or command not found. Ensure bash is installed and the command is valid.",
error="Error: Bash executable or command not found.",
params=params,
)
except Exception as e:
Expand Down
91 changes: 91 additions & 0 deletions holmes/plugins/toolsets/oom_kill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import time
from typing import Any, Dict

from holmes.core.tools import (
CallablePrerequisite,
StructuredToolResult,
StructuredToolResultStatus,
Tool,
ToolInvokeContext,
ToolParameter,
Toolset,
ToolsetTag,
ToolsetEnvironmentPrerequisite,
)


class TriggerOOMKill(Tool):
toolset: "OOMKillToolset"

def __init__(self, toolset: "OOMKillToolset"):
super().__init__(
name="trigger_oom_kill",
description=(
"Allocates approximately 30GB of memory on the Holmes host to provoke the "
"OOM killer. This is intended for stress testing only and will likely crash "
"the running process. No confirmation is required because this is meant for "
"automated stress scenarios."
),
parameters={
"hold_seconds": ToolParameter(
description=(
"How long to keep the memory allocated before exiting. Defaults to 300 seconds."
),
type="integer",
required=False,
),
},
toolset=toolset, # type: ignore[call-arg]
)

def _invoke(self, params: dict, context: ToolInvokeContext) -> StructuredToolResult:
hold_seconds = params.get("hold_seconds", 300)
if not isinstance(hold_seconds, int) or hold_seconds <= 0:
return StructuredToolResult(
status=StructuredToolResultStatus.ERROR,
error="hold_seconds must be a positive integer.",
params=params,
)

size_bytes = 30 * 1024 * 1024 * 1024
print(
f"Allocating {{size_bytes / 1024 / 1024 / 1024:.0f}} GB of memory to intentionally trigger OOM kill; sleeping for {hold_seconds}s"
)
data = bytearray(size_bytes) # type: ignore
time.sleep({hold_seconds})

Comment on lines +41 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical syntax errors and missing return statement.

The _invoke method has multiple critical issues that will cause runtime failures:

  1. Line 52: The f-string uses double braces {{size_bytes...}}, which escapes them and produces literal text "{size_bytes / 1024 / 1024 / 1024:.0f}" instead of interpolating the value.

  2. Line 55: time.sleep({hold_seconds}) creates a set and passes it to sleep, which expects a number. This will raise TypeError: 'set' object cannot be interpreted as an integer.

  3. Missing return: The method doesn't return a StructuredToolResult on the success path, violating the tool interface contract.

Additionally, as noted in past reviews, bytearray(size_bytes) on Linux with memory overcommitment may only reserve virtual memory without allocating physical pages. To reliably trigger OOM, pages must be written to.

🔎 Proposed fix for syntax errors, missing return, and reliable OOM triggering
     def _invoke(self, params: dict, context: ToolInvokeContext) -> StructuredToolResult:
         hold_seconds = params.get("hold_seconds", 300)
         if not isinstance(hold_seconds, int) or hold_seconds <= 0:
             return StructuredToolResult(
                 status=StructuredToolResultStatus.ERROR,
                 error="hold_seconds must be a positive integer.",
                 params=params,
             )
 
         size_bytes = 30 * 1024 * 1024 * 1024
         print(
-            f"Allocating {{size_bytes / 1024 / 1024 / 1024:.0f}} GB of memory to intentionally trigger OOM kill; sleeping for {hold_seconds}s"
+            f"Allocating {size_bytes / 1024 / 1024 / 1024:.0f} GB of memory to intentionally trigger OOM kill; sleeping for {hold_seconds}s"
         )
         data = bytearray(size_bytes)  # type: ignore
-        time.sleep({hold_seconds})
+        # Touch every page to force physical allocation
+        page_size = 4096
+        for i in range(0, size_bytes, page_size):
+            data[i] = 1
+        print("Memory allocation complete")
+        time.sleep(hold_seconds)
+        
+        return StructuredToolResult(
+            status=StructuredToolResultStatus.SUCCESS,
+            data="OOM kill triggered successfully",
+            params=params,
+        )
🧰 Tools
🪛 Ruff (0.14.10)

41-41: Unused method argument: context

(ARG002)


54-54: Local variable data is assigned to but never used

Remove assignment to unused variable data

(F841)

🤖 Prompt for AI Agents
In holmes/plugins/toolsets/oom_kill.py around lines 41 to 56, fix three issues:
replace the escaped f-string braces so the GB value is interpolated correctly,
change time.sleep({hold_seconds}) to pass the numeric hold_seconds value (no
braces), and ensure the method returns a StructuredToolResult on success;
additionally to reliably trigger OOM on Linux, touch/write to the allocated
memory pages (e.g., iterate writing to each page or use memoryview writes) so
physical pages are committed, and wrap allocation/sleep in try/except to return
a StructuredToolResult with ERROR on exceptions and a SUCCESS result on
completion.

def get_parameterized_one_liner(self, params: Dict[str, Any]) -> str:
hold_seconds = params.get("hold_seconds", 300)
return (
"python - <<'PY' ... # allocates ~30GB and sleeps for "
f"{hold_seconds}s to trigger OOM"
)


class OOMKillToolset(Toolset):
def __init__(self):
super().__init__(
name="oom_kill",
enabled=False,
description=(
"Dangerous toolset that intentionally exhausts memory on the Holmes host to trigger an OOM kill. "
"Use only in controlled stress tests."
),
docs_url=None,
icon_url=None,
prerequisites=[
ToolsetEnvironmentPrerequisite(env=["ALLOW_HOLMES_OOMKILL_TOOLSET"]),
CallablePrerequisite(callable=self.prerequisites_callable),
],
tools=[TriggerOOMKill(self)],
experimental=True,
tags=[ToolsetTag.CORE],
is_default=False,
)

def prerequisites_callable(self, config: dict[str, Any]) -> tuple[bool, str]:
# No special configuration is required for this toolset.
return True, ""

def get_example_config(self) -> Dict[str, Any]:
return {}
28 changes: 28 additions & 0 deletions holmes/plugins/toolsets/oom_kill.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
toolsets:
oom_kill_bash:
description: "DANGEROUS: intentionally allocate ~30GB on the Holmes host to trigger OOM kill for stress testing (no confirmation required)."
docs_url: ""
icon_url: ""
tags:
- core
prerequisites:
- env:
- ALLOW_HOLMES_OOMKILL_TOOLSET
additional_instructions: |
⚠️ This toolset is intentionally destructive. Only use in controlled environments.
tools:
- name: "trigger_oom_kill_bash"
description: "Allocate ~30GB of memory and hold it for a period to provoke the OOM killer. No confirmation required; intended for automated stress tests."
command: |
python - <<'PY'
import time

hold_seconds = int("{{ hold_seconds|default(300) }}")
if hold_seconds <= 0:
raise SystemExit("hold_seconds must be positive.")

size_bytes = 30 * 1024 * 1024 * 1024
print(f"Allocating {size_bytes / 1024 / 1024 / 1024:.0f} GB of memory to intentionally trigger OOM kill; sleeping for {hold_seconds}s")
data = bytearray(size_bytes)
time.sleep(hold_seconds)
Comment thread
aantn marked this conversation as resolved.
PY
Loading
Loading