holmesgpt 0.11.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of holmesgpt might be problematic. Click here for more details.
- holmes/.git_archival.json +7 -0
- holmes/__init__.py +76 -0
- holmes/__init__.py.bak +76 -0
- holmes/clients/robusta_client.py +24 -0
- holmes/common/env_vars.py +47 -0
- holmes/config.py +526 -0
- holmes/core/__init__.py +0 -0
- holmes/core/conversations.py +578 -0
- holmes/core/investigation.py +152 -0
- holmes/core/investigation_structured_output.py +264 -0
- holmes/core/issue.py +54 -0
- holmes/core/llm.py +250 -0
- holmes/core/models.py +157 -0
- holmes/core/openai_formatting.py +51 -0
- holmes/core/performance_timing.py +72 -0
- holmes/core/prompt.py +42 -0
- holmes/core/resource_instruction.py +17 -0
- holmes/core/runbooks.py +26 -0
- holmes/core/safeguards.py +120 -0
- holmes/core/supabase_dal.py +540 -0
- holmes/core/tool_calling_llm.py +798 -0
- holmes/core/tools.py +566 -0
- holmes/core/tools_utils/__init__.py +0 -0
- holmes/core/tools_utils/tool_executor.py +65 -0
- holmes/core/tools_utils/toolset_utils.py +52 -0
- holmes/core/toolset_manager.py +418 -0
- holmes/interactive.py +229 -0
- holmes/main.py +1041 -0
- holmes/plugins/__init__.py +0 -0
- holmes/plugins/destinations/__init__.py +6 -0
- holmes/plugins/destinations/slack/__init__.py +2 -0
- holmes/plugins/destinations/slack/plugin.py +163 -0
- holmes/plugins/interfaces.py +32 -0
- holmes/plugins/prompts/__init__.py +48 -0
- holmes/plugins/prompts/_current_date_time.jinja2 +1 -0
- holmes/plugins/prompts/_default_log_prompt.jinja2 +11 -0
- holmes/plugins/prompts/_fetch_logs.jinja2 +36 -0
- holmes/plugins/prompts/_general_instructions.jinja2 +86 -0
- holmes/plugins/prompts/_global_instructions.jinja2 +12 -0
- holmes/plugins/prompts/_runbook_instructions.jinja2 +13 -0
- holmes/plugins/prompts/_toolsets_instructions.jinja2 +56 -0
- holmes/plugins/prompts/generic_ask.jinja2 +36 -0
- holmes/plugins/prompts/generic_ask_conversation.jinja2 +32 -0
- holmes/plugins/prompts/generic_ask_for_issue_conversation.jinja2 +50 -0
- holmes/plugins/prompts/generic_investigation.jinja2 +42 -0
- holmes/plugins/prompts/generic_post_processing.jinja2 +13 -0
- holmes/plugins/prompts/generic_ticket.jinja2 +12 -0
- holmes/plugins/prompts/investigation_output_format.jinja2 +32 -0
- holmes/plugins/prompts/kubernetes_workload_ask.jinja2 +84 -0
- holmes/plugins/prompts/kubernetes_workload_chat.jinja2 +39 -0
- holmes/plugins/runbooks/README.md +22 -0
- holmes/plugins/runbooks/__init__.py +100 -0
- holmes/plugins/runbooks/catalog.json +14 -0
- holmes/plugins/runbooks/jira.yaml +12 -0
- holmes/plugins/runbooks/kube-prometheus-stack.yaml +10 -0
- holmes/plugins/runbooks/networking/dns_troubleshooting_instructions.md +66 -0
- holmes/plugins/runbooks/upgrade/upgrade_troubleshooting_instructions.md +44 -0
- holmes/plugins/sources/github/__init__.py +77 -0
- holmes/plugins/sources/jira/__init__.py +123 -0
- holmes/plugins/sources/opsgenie/__init__.py +93 -0
- holmes/plugins/sources/pagerduty/__init__.py +147 -0
- holmes/plugins/sources/prometheus/__init__.py +0 -0
- holmes/plugins/sources/prometheus/models.py +104 -0
- holmes/plugins/sources/prometheus/plugin.py +154 -0
- holmes/plugins/toolsets/__init__.py +171 -0
- holmes/plugins/toolsets/aks-node-health.yaml +65 -0
- holmes/plugins/toolsets/aks.yaml +86 -0
- holmes/plugins/toolsets/argocd.yaml +70 -0
- holmes/plugins/toolsets/atlas_mongodb/instructions.jinja2 +8 -0
- holmes/plugins/toolsets/atlas_mongodb/mongodb_atlas.py +307 -0
- holmes/plugins/toolsets/aws.yaml +76 -0
- holmes/plugins/toolsets/azure_sql/__init__.py +0 -0
- holmes/plugins/toolsets/azure_sql/apis/alert_monitoring_api.py +600 -0
- holmes/plugins/toolsets/azure_sql/apis/azure_sql_api.py +309 -0
- holmes/plugins/toolsets/azure_sql/apis/connection_failure_api.py +445 -0
- holmes/plugins/toolsets/azure_sql/apis/connection_monitoring_api.py +251 -0
- holmes/plugins/toolsets/azure_sql/apis/storage_analysis_api.py +317 -0
- holmes/plugins/toolsets/azure_sql/azure_base_toolset.py +55 -0
- holmes/plugins/toolsets/azure_sql/azure_sql_instructions.jinja2 +137 -0
- holmes/plugins/toolsets/azure_sql/azure_sql_toolset.py +183 -0
- holmes/plugins/toolsets/azure_sql/install.md +66 -0
- holmes/plugins/toolsets/azure_sql/tools/__init__.py +1 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_connection_failures.py +324 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_connections.py +243 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_health_status.py +205 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_performance.py +249 -0
- holmes/plugins/toolsets/azure_sql/tools/analyze_database_storage.py +373 -0
- holmes/plugins/toolsets/azure_sql/tools/get_active_alerts.py +237 -0
- holmes/plugins/toolsets/azure_sql/tools/get_slow_queries.py +172 -0
- holmes/plugins/toolsets/azure_sql/tools/get_top_cpu_queries.py +170 -0
- holmes/plugins/toolsets/azure_sql/tools/get_top_data_io_queries.py +188 -0
- holmes/plugins/toolsets/azure_sql/tools/get_top_log_io_queries.py +180 -0
- holmes/plugins/toolsets/azure_sql/utils.py +83 -0
- holmes/plugins/toolsets/bash/__init__.py +0 -0
- holmes/plugins/toolsets/bash/bash_instructions.jinja2 +14 -0
- holmes/plugins/toolsets/bash/bash_toolset.py +208 -0
- holmes/plugins/toolsets/bash/common/bash.py +52 -0
- holmes/plugins/toolsets/bash/common/config.py +14 -0
- holmes/plugins/toolsets/bash/common/stringify.py +25 -0
- holmes/plugins/toolsets/bash/common/validators.py +24 -0
- holmes/plugins/toolsets/bash/grep/__init__.py +52 -0
- holmes/plugins/toolsets/bash/kubectl/__init__.py +100 -0
- holmes/plugins/toolsets/bash/kubectl/constants.py +96 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_describe.py +66 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_events.py +88 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_get.py +108 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_logs.py +20 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_run.py +46 -0
- holmes/plugins/toolsets/bash/kubectl/kubectl_top.py +81 -0
- holmes/plugins/toolsets/bash/parse_command.py +103 -0
- holmes/plugins/toolsets/confluence.yaml +19 -0
- holmes/plugins/toolsets/consts.py +5 -0
- holmes/plugins/toolsets/coralogix/api.py +158 -0
- holmes/plugins/toolsets/coralogix/toolset_coralogix_logs.py +103 -0
- holmes/plugins/toolsets/coralogix/utils.py +181 -0
- holmes/plugins/toolsets/datadog.py +153 -0
- holmes/plugins/toolsets/docker.yaml +46 -0
- holmes/plugins/toolsets/git.py +756 -0
- holmes/plugins/toolsets/grafana/__init__.py +0 -0
- holmes/plugins/toolsets/grafana/base_grafana_toolset.py +54 -0
- holmes/plugins/toolsets/grafana/common.py +68 -0
- holmes/plugins/toolsets/grafana/grafana_api.py +31 -0
- holmes/plugins/toolsets/grafana/loki_api.py +89 -0
- holmes/plugins/toolsets/grafana/tempo_api.py +124 -0
- holmes/plugins/toolsets/grafana/toolset_grafana.py +102 -0
- holmes/plugins/toolsets/grafana/toolset_grafana_loki.py +102 -0
- holmes/plugins/toolsets/grafana/toolset_grafana_tempo.jinja2 +10 -0
- holmes/plugins/toolsets/grafana/toolset_grafana_tempo.py +299 -0
- holmes/plugins/toolsets/grafana/trace_parser.py +195 -0
- holmes/plugins/toolsets/helm.yaml +42 -0
- holmes/plugins/toolsets/internet/internet.py +275 -0
- holmes/plugins/toolsets/internet/notion.py +137 -0
- holmes/plugins/toolsets/kafka.py +638 -0
- holmes/plugins/toolsets/kubernetes.yaml +255 -0
- holmes/plugins/toolsets/kubernetes_logs.py +426 -0
- holmes/plugins/toolsets/kubernetes_logs.yaml +42 -0
- holmes/plugins/toolsets/logging_utils/__init__.py +0 -0
- holmes/plugins/toolsets/logging_utils/logging_api.py +217 -0
- holmes/plugins/toolsets/logging_utils/types.py +0 -0
- holmes/plugins/toolsets/mcp/toolset_mcp.py +135 -0
- holmes/plugins/toolsets/newrelic.py +222 -0
- holmes/plugins/toolsets/opensearch/__init__.py +0 -0
- holmes/plugins/toolsets/opensearch/opensearch.py +245 -0
- holmes/plugins/toolsets/opensearch/opensearch_logs.py +151 -0
- holmes/plugins/toolsets/opensearch/opensearch_traces.py +211 -0
- holmes/plugins/toolsets/opensearch/opensearch_traces_instructions.jinja2 +12 -0
- holmes/plugins/toolsets/opensearch/opensearch_utils.py +166 -0
- holmes/plugins/toolsets/prometheus/prometheus.py +818 -0
- holmes/plugins/toolsets/prometheus/prometheus_instructions.jinja2 +38 -0
- holmes/plugins/toolsets/rabbitmq/api.py +398 -0
- holmes/plugins/toolsets/rabbitmq/rabbitmq_instructions.jinja2 +37 -0
- holmes/plugins/toolsets/rabbitmq/toolset_rabbitmq.py +222 -0
- holmes/plugins/toolsets/robusta/__init__.py +0 -0
- holmes/plugins/toolsets/robusta/robusta.py +235 -0
- holmes/plugins/toolsets/robusta/robusta_instructions.jinja2 +24 -0
- holmes/plugins/toolsets/runbook/__init__.py +0 -0
- holmes/plugins/toolsets/runbook/runbook_fetcher.py +78 -0
- holmes/plugins/toolsets/service_discovery.py +92 -0
- holmes/plugins/toolsets/servicenow/install.md +37 -0
- holmes/plugins/toolsets/servicenow/instructions.jinja2 +3 -0
- holmes/plugins/toolsets/servicenow/servicenow.py +198 -0
- holmes/plugins/toolsets/slab.yaml +20 -0
- holmes/plugins/toolsets/utils.py +137 -0
- holmes/plugins/utils.py +14 -0
- holmes/utils/__init__.py +0 -0
- holmes/utils/cache.py +84 -0
- holmes/utils/cert_utils.py +40 -0
- holmes/utils/default_toolset_installation_guide.jinja2 +44 -0
- holmes/utils/definitions.py +13 -0
- holmes/utils/env.py +53 -0
- holmes/utils/file_utils.py +56 -0
- holmes/utils/global_instructions.py +20 -0
- holmes/utils/holmes_status.py +22 -0
- holmes/utils/holmes_sync_toolsets.py +80 -0
- holmes/utils/markdown_utils.py +55 -0
- holmes/utils/pydantic_utils.py +54 -0
- holmes/utils/robusta.py +10 -0
- holmes/utils/tags.py +97 -0
- holmesgpt-0.11.5.dist-info/LICENSE.txt +21 -0
- holmesgpt-0.11.5.dist-info/METADATA +400 -0
- holmesgpt-0.11.5.dist-info/RECORD +183 -0
- holmesgpt-0.11.5.dist-info/WHEEL +4 -0
- holmesgpt-0.11.5.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, List, Tuple
|
|
3
|
+
|
|
4
|
+
from holmes.core.tools import StructuredToolResult, ToolParameter, ToolResultStatus
|
|
5
|
+
from holmes.plugins.toolsets.azure_sql.azure_base_toolset import (
|
|
6
|
+
BaseAzureSQLTool,
|
|
7
|
+
BaseAzureSQLToolset,
|
|
8
|
+
AzureSQLDatabaseConfig,
|
|
9
|
+
)
|
|
10
|
+
from holmes.plugins.toolsets.azure_sql.apis.azure_sql_api import AzureSQLAPIClient
|
|
11
|
+
from holmes.plugins.toolsets.azure_sql.utils import format_timing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GetTopLogIOQueries(BaseAzureSQLTool):
|
|
15
|
+
def __init__(self, toolset: "BaseAzureSQLToolset"):
|
|
16
|
+
super().__init__(
|
|
17
|
+
name="get_top_log_io_queries",
|
|
18
|
+
description="Identifies queries consuming the most transaction log I/O from Query Store. Use this to find queries causing transaction log performance issues and write-heavy workload problems.",
|
|
19
|
+
parameters={
|
|
20
|
+
"top_count": ToolParameter(
|
|
21
|
+
description="Number of top queries to return. Use 15 for detailed analysis, 5-10 for quick overview (default: 15)",
|
|
22
|
+
type="integer",
|
|
23
|
+
required=False,
|
|
24
|
+
),
|
|
25
|
+
"hours_back": ToolParameter(
|
|
26
|
+
description="Time window for analysis in hours. Use 2 for recent issues, 24+ for trend analysis (default: 2)",
|
|
27
|
+
type="integer",
|
|
28
|
+
required=False,
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
toolset=toolset,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _format_log_io_queries_report(
|
|
35
|
+
self,
|
|
36
|
+
queries: List[Dict],
|
|
37
|
+
db_config: AzureSQLDatabaseConfig,
|
|
38
|
+
top_count: int,
|
|
39
|
+
hours_back: int,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Format the log I/O queries data into a readable report."""
|
|
42
|
+
report_sections = []
|
|
43
|
+
|
|
44
|
+
# Header
|
|
45
|
+
report_sections.append("# Top Log I/O Consuming Queries Report")
|
|
46
|
+
report_sections.append(f"**Database:** {db_config.database_name}")
|
|
47
|
+
report_sections.append(f"**Server:** {db_config.server_name}")
|
|
48
|
+
report_sections.append(f"**Analysis Period:** Last {hours_back} hours")
|
|
49
|
+
report_sections.append(f"**Top Queries:** {top_count}")
|
|
50
|
+
report_sections.append("")
|
|
51
|
+
|
|
52
|
+
if not queries:
|
|
53
|
+
report_sections.append("No queries found for the specified time period.")
|
|
54
|
+
return "\n".join(report_sections)
|
|
55
|
+
|
|
56
|
+
# Summary
|
|
57
|
+
total_log_writes = sum(float(q.get("total_log_bytes_used", 0)) for q in queries)
|
|
58
|
+
total_executions = sum(int(q.get("execution_count", 0)) for q in queries)
|
|
59
|
+
|
|
60
|
+
report_sections.append("## Summary")
|
|
61
|
+
report_sections.append(f"- **Total Queries Analyzed:** {len(queries)}")
|
|
62
|
+
report_sections.append(
|
|
63
|
+
f"- **Total Log Bytes Used:** {total_log_writes:,.0f} bytes"
|
|
64
|
+
)
|
|
65
|
+
report_sections.append(f"- **Total Executions:** {total_executions:,}")
|
|
66
|
+
report_sections.append("")
|
|
67
|
+
|
|
68
|
+
# Query Details
|
|
69
|
+
report_sections.append("## Query Details")
|
|
70
|
+
|
|
71
|
+
for i, query in enumerate(queries[:top_count], 1):
|
|
72
|
+
avg_log_bytes = float(query.get("avg_log_bytes_used", 0))
|
|
73
|
+
execution_count = int(query.get("execution_count", 0))
|
|
74
|
+
total_log_bytes = float(query.get("total_log_bytes_used", 0))
|
|
75
|
+
max_log_bytes = float(query.get("max_log_bytes_used", 0))
|
|
76
|
+
avg_cpu = float(query.get("avg_cpu_time", 0))
|
|
77
|
+
avg_duration = float(query.get("avg_duration", 0))
|
|
78
|
+
query_text = query.get("query_sql_text", "N/A")
|
|
79
|
+
last_execution = query.get("last_execution_time", "N/A")
|
|
80
|
+
|
|
81
|
+
# Truncate long queries
|
|
82
|
+
if len(query_text) > 200:
|
|
83
|
+
query_text = query_text[:200] + "..."
|
|
84
|
+
|
|
85
|
+
report_sections.append(f"### Query #{i}")
|
|
86
|
+
report_sections.append(
|
|
87
|
+
f"- **Average Log Bytes Used:** {avg_log_bytes:,.0f} bytes"
|
|
88
|
+
)
|
|
89
|
+
report_sections.append(
|
|
90
|
+
f"- **Total Log Bytes Used:** {total_log_bytes:,.0f} bytes"
|
|
91
|
+
)
|
|
92
|
+
report_sections.append(
|
|
93
|
+
f"- **Max Log Bytes Used:** {max_log_bytes:,.0f} bytes"
|
|
94
|
+
)
|
|
95
|
+
report_sections.append(f"- **Execution Count:** {execution_count:,}")
|
|
96
|
+
report_sections.append(f"- **Average CPU Time:** {format_timing(avg_cpu)}")
|
|
97
|
+
report_sections.append(
|
|
98
|
+
f"- **Average Duration:** {format_timing(avg_duration)}"
|
|
99
|
+
)
|
|
100
|
+
report_sections.append(f"- **Last Execution:** {last_execution}")
|
|
101
|
+
report_sections.append("- **Query Text:**")
|
|
102
|
+
report_sections.append("```sql")
|
|
103
|
+
report_sections.append(query_text)
|
|
104
|
+
report_sections.append("```")
|
|
105
|
+
report_sections.append("")
|
|
106
|
+
|
|
107
|
+
return "\n".join(report_sections)
|
|
108
|
+
|
|
109
|
+
def _invoke(self, params: Dict) -> StructuredToolResult:
|
|
110
|
+
try:
|
|
111
|
+
top_count = params.get("top_count", 15)
|
|
112
|
+
hours_back = params.get("hours_back", 2)
|
|
113
|
+
|
|
114
|
+
db_config = self.toolset.database_config()
|
|
115
|
+
client = self.toolset.api_client()
|
|
116
|
+
|
|
117
|
+
# Get top log I/O queries
|
|
118
|
+
queries = client.get_top_log_io_queries(
|
|
119
|
+
db_config.subscription_id,
|
|
120
|
+
db_config.resource_group,
|
|
121
|
+
db_config.server_name,
|
|
122
|
+
db_config.database_name,
|
|
123
|
+
top_count,
|
|
124
|
+
hours_back,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Format the report
|
|
128
|
+
report_text = self._format_log_io_queries_report(
|
|
129
|
+
queries, db_config, top_count, hours_back
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return StructuredToolResult(
|
|
133
|
+
status=ToolResultStatus.SUCCESS,
|
|
134
|
+
data=report_text,
|
|
135
|
+
params=params,
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
error_msg = f"Failed to get top log I/O queries: {str(e)}"
|
|
139
|
+
logging.error(error_msg)
|
|
140
|
+
return StructuredToolResult(
|
|
141
|
+
status=ToolResultStatus.ERROR,
|
|
142
|
+
error=error_msg,
|
|
143
|
+
params=params,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def get_parameterized_one_liner(self, params: Dict) -> str:
|
|
147
|
+
db_config = self.toolset.database_config()
|
|
148
|
+
return f"Fetch top log I/O consuming queries for database {db_config.server_name}/{db_config.database_name}"
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def validate_config(
|
|
152
|
+
api_client: AzureSQLAPIClient, database_config: AzureSQLDatabaseConfig
|
|
153
|
+
) -> Tuple[bool, str]:
|
|
154
|
+
errors = []
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Test direct database connection for Query Store access
|
|
158
|
+
test_query = (
|
|
159
|
+
"SELECT TOP 1 query_id FROM sys.query_store_query WHERE query_id > 0"
|
|
160
|
+
)
|
|
161
|
+
api_client.execute_query(
|
|
162
|
+
database_config.server_name, database_config.database_name, test_query
|
|
163
|
+
)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
error_msg = str(e)
|
|
166
|
+
if (
|
|
167
|
+
"login failed" in error_msg.lower()
|
|
168
|
+
or "authentication" in error_msg.lower()
|
|
169
|
+
):
|
|
170
|
+
errors.append(f"Database authentication failed: {error_msg}")
|
|
171
|
+
elif "permission" in error_msg.lower() or "denied" in error_msg.lower():
|
|
172
|
+
errors.append(f"Query Store access denied: {error_msg}")
|
|
173
|
+
elif "query store" in error_msg.lower():
|
|
174
|
+
errors.append(f"Query Store not available or disabled: {error_msg}")
|
|
175
|
+
else:
|
|
176
|
+
errors.append(f"Database connection failed: {error_msg}")
|
|
177
|
+
|
|
178
|
+
if errors:
|
|
179
|
+
return False, "\n".join(errors)
|
|
180
|
+
return True, ""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utility functions for Azure SQL toolset.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_timing(microseconds: float) -> str:
|
|
7
|
+
"""Format timing values with appropriate units.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
microseconds: Time value in microseconds
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Formatted string with appropriate units (s, ms, or μs)
|
|
14
|
+
"""
|
|
15
|
+
if microseconds >= 1_000_000: # >= 1 second
|
|
16
|
+
return f"{microseconds / 1_000_000:.2f} s"
|
|
17
|
+
elif microseconds >= 1_000: # >= 1 millisecond
|
|
18
|
+
return f"{microseconds / 1_000:.2f} ms"
|
|
19
|
+
else: # < 1 millisecond
|
|
20
|
+
return f"{microseconds:.0f} μs"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_size_bytes(bytes_value: float) -> str:
|
|
24
|
+
"""Format byte values with appropriate units.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
bytes_value: Size value in bytes
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Formatted string with appropriate units (GB, MB, KB, or B)
|
|
31
|
+
"""
|
|
32
|
+
if bytes_value >= 1_073_741_824: # >= 1 GB
|
|
33
|
+
return f"{bytes_value / 1_073_741_824:.2f} GB"
|
|
34
|
+
elif bytes_value >= 1_048_576: # >= 1 MB
|
|
35
|
+
return f"{bytes_value / 1_048_576:.2f} MB"
|
|
36
|
+
elif bytes_value >= 1_024: # >= 1 KB
|
|
37
|
+
return f"{bytes_value / 1_024:.2f} KB"
|
|
38
|
+
else: # < 1 KB
|
|
39
|
+
return f"{bytes_value:.0f} B"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_percentage(value: float, decimal_places: int = 2) -> str:
|
|
43
|
+
"""Format percentage values.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
value: Percentage value (0-100)
|
|
47
|
+
decimal_places: Number of decimal places to show
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Formatted percentage string
|
|
51
|
+
"""
|
|
52
|
+
return f"{value:.{decimal_places}f}%"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def safe_divide(numerator: float, denominator: float, default: float = 0.0) -> float:
|
|
56
|
+
"""Safely divide two numbers, handling division by zero.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
numerator: The dividend
|
|
60
|
+
denominator: The divisor
|
|
61
|
+
default: Value to return if denominator is zero
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Division result or default if denominator is zero
|
|
65
|
+
"""
|
|
66
|
+
if denominator == 0 or denominator is None:
|
|
67
|
+
return default
|
|
68
|
+
return numerator / denominator
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def truncate_text(text: str, max_length: int = 100) -> str:
|
|
72
|
+
"""Truncate text to a maximum length with ellipsis.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
text: Text to truncate
|
|
76
|
+
max_length: Maximum length including ellipsis
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Truncated text with ellipsis if needed
|
|
80
|
+
"""
|
|
81
|
+
if not text or len(text) <= max_length:
|
|
82
|
+
return text
|
|
83
|
+
return text[: max_length - 3] + "..."
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Bash commands
|
|
2
|
+
|
|
3
|
+
The tool `run_bash_command` allows you to run many kubectl commands:
|
|
4
|
+
|
|
5
|
+
- `kubectl get ...`
|
|
6
|
+
- `kubectl describe ...`
|
|
7
|
+
- `kubectl events ...`
|
|
8
|
+
- `kubectl top ...`
|
|
9
|
+
|
|
10
|
+
It is also possible to combine `kubectl` with `grep`. For example:
|
|
11
|
+
- `kubectl get pods | grep holmes`
|
|
12
|
+
|
|
13
|
+
The tool `kubectl_run_image` will run an image:
|
|
14
|
+
- `kubectl run <name> --image=<image> --rm --attach --restart=Never --i --tty -- <command>`
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import string
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from holmes.core.tools import (
|
|
11
|
+
CallablePrerequisite,
|
|
12
|
+
StructuredToolResult,
|
|
13
|
+
Tool,
|
|
14
|
+
ToolParameter,
|
|
15
|
+
ToolResultStatus,
|
|
16
|
+
Toolset,
|
|
17
|
+
ToolsetTag,
|
|
18
|
+
)
|
|
19
|
+
from holmes.plugins.toolsets.bash.common.bash import execute_bash_command
|
|
20
|
+
from holmes.plugins.toolsets.bash.common.config import BashExecutorConfig
|
|
21
|
+
from holmes.plugins.toolsets.bash.kubectl.constants import SAFE_NAMESPACE_PATTERN
|
|
22
|
+
from holmes.plugins.toolsets.bash.kubectl.kubectl_run import validate_image_and_commands
|
|
23
|
+
from holmes.plugins.toolsets.bash.parse_command import make_command_safe
|
|
24
|
+
from holmes.plugins.toolsets.utils import get_param_or_raise
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseBashExecutorToolset(Toolset):
|
|
28
|
+
config: Optional[BashExecutorConfig] = None
|
|
29
|
+
|
|
30
|
+
def get_example_config(self):
|
|
31
|
+
example_config = BashExecutorConfig()
|
|
32
|
+
return example_config.model_dump()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseBashTool(Tool):
|
|
36
|
+
toolset: BaseBashExecutorToolset
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KubectlRunImageCommand(BaseBashTool):
|
|
40
|
+
def __init__(self, toolset: BaseBashExecutorToolset):
|
|
41
|
+
super().__init__(
|
|
42
|
+
name="kubectl_run_image",
|
|
43
|
+
description=(
|
|
44
|
+
"Executes `kubectl run <name> --image=<image> ... -- <command>` return the result"
|
|
45
|
+
),
|
|
46
|
+
parameters={
|
|
47
|
+
"image": ToolParameter(
|
|
48
|
+
description="The image to run",
|
|
49
|
+
type="string",
|
|
50
|
+
required=True,
|
|
51
|
+
),
|
|
52
|
+
"command": ToolParameter(
|
|
53
|
+
description="The command to execute on the deployed pod",
|
|
54
|
+
type="string",
|
|
55
|
+
required=True,
|
|
56
|
+
),
|
|
57
|
+
"namespace": ToolParameter(
|
|
58
|
+
description="The namespace in which to deploy the temporary pod",
|
|
59
|
+
type="string",
|
|
60
|
+
required=False,
|
|
61
|
+
),
|
|
62
|
+
"timeout": ToolParameter(
|
|
63
|
+
description=(
|
|
64
|
+
"Optional timeout in seconds for the command execution. "
|
|
65
|
+
"Defaults to 60s."
|
|
66
|
+
),
|
|
67
|
+
type="integer",
|
|
68
|
+
required=False,
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
toolset=toolset,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _build_kubectl_command(self, params: dict, pod_name: str) -> str:
|
|
75
|
+
namespace = params.get("namespace", "default")
|
|
76
|
+
image = get_param_or_raise(params, "image")
|
|
77
|
+
command_str = get_param_or_raise(params, "command")
|
|
78
|
+
return f"kubectl run {pod_name} --image={image} --namespace={namespace} --rm --attach --restart=Never -i -- {command_str}"
|
|
79
|
+
|
|
80
|
+
def _invoke(self, params: Dict[str, Any]) -> StructuredToolResult:
|
|
81
|
+
timeout = params.get("timeout", 60)
|
|
82
|
+
|
|
83
|
+
image = get_param_or_raise(params, "image")
|
|
84
|
+
command_str = get_param_or_raise(params, "command")
|
|
85
|
+
|
|
86
|
+
namespace = params.get("namespace")
|
|
87
|
+
|
|
88
|
+
if namespace and not re.match(SAFE_NAMESPACE_PATTERN, namespace):
|
|
89
|
+
return StructuredToolResult(
|
|
90
|
+
status=ToolResultStatus.ERROR,
|
|
91
|
+
error=f"Error: The namespace is invalid. Valid namespaces must match the following regexp: {SAFE_NAMESPACE_PATTERN}",
|
|
92
|
+
params=params,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
validate_image_and_commands(
|
|
96
|
+
image=image, container_command=command_str, config=self.toolset.config
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
pod_name = (
|
|
100
|
+
"holmesgpt-debug-pod-"
|
|
101
|
+
+ "".join(random.choices(string.ascii_letters, k=8)).lower()
|
|
102
|
+
)
|
|
103
|
+
full_kubectl_command = self._build_kubectl_command(params, pod_name)
|
|
104
|
+
return execute_bash_command(
|
|
105
|
+
cmd=full_kubectl_command, timeout=timeout, params=params
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def get_parameterized_one_liner(self, params: Dict[str, Any]) -> str:
|
|
109
|
+
return self._build_kubectl_command(params, "<pod_name>")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class RunBashCommand(BaseBashTool):
|
|
113
|
+
def __init__(self, toolset: BaseBashExecutorToolset):
|
|
114
|
+
super().__init__(
|
|
115
|
+
name="run_bash_command",
|
|
116
|
+
description=(
|
|
117
|
+
"Executes a given bash command and returns its standard output, "
|
|
118
|
+
"standard error, and exit code."
|
|
119
|
+
"The command is executed via 'bash -c \"<command>\"'."
|
|
120
|
+
"Only some commands are allowed."
|
|
121
|
+
),
|
|
122
|
+
parameters={
|
|
123
|
+
"command": ToolParameter(
|
|
124
|
+
description="The bash command string to execute.",
|
|
125
|
+
type="string",
|
|
126
|
+
required=True,
|
|
127
|
+
),
|
|
128
|
+
"timeout": ToolParameter(
|
|
129
|
+
description=(
|
|
130
|
+
"Optional timeout in seconds for the command execution. "
|
|
131
|
+
"Defaults to 60s."
|
|
132
|
+
),
|
|
133
|
+
type="integer",
|
|
134
|
+
required=False,
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
toolset=toolset,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _invoke(self, params: Dict[str, Any]) -> StructuredToolResult:
|
|
141
|
+
command_str = params.get("command")
|
|
142
|
+
timeout = params.get("timeout", 60)
|
|
143
|
+
|
|
144
|
+
if not command_str:
|
|
145
|
+
return StructuredToolResult(
|
|
146
|
+
status=ToolResultStatus.ERROR,
|
|
147
|
+
error="The 'command' parameter is required and was not provided.",
|
|
148
|
+
params=params,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if not isinstance(command_str, str):
|
|
152
|
+
return StructuredToolResult(
|
|
153
|
+
status=ToolResultStatus.ERROR,
|
|
154
|
+
error=f"The 'command' parameter must be a string, got {type(command_str).__name__}.",
|
|
155
|
+
params=params,
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
safe_command_str = make_command_safe(command_str, self.toolset.config)
|
|
159
|
+
return execute_bash_command(
|
|
160
|
+
cmd=safe_command_str, timeout=timeout, params=params
|
|
161
|
+
)
|
|
162
|
+
except (argparse.ArgumentError, ValueError) as e:
|
|
163
|
+
logging.info(f"Refusing LLM tool call {command_str}", exc_info=True)
|
|
164
|
+
return StructuredToolResult(
|
|
165
|
+
status=ToolResultStatus.ERROR,
|
|
166
|
+
error=f"Refusing to execute bash command. Only some commands are supported and this is likely because requested command is unsupported. Error: {str(e)}",
|
|
167
|
+
params=params,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def get_parameterized_one_liner(self, params: Dict[str, Any]) -> str:
|
|
171
|
+
command = params.get("command", "N/A")
|
|
172
|
+
display_command = command[:200] + "..." if len(command) > 200 else command
|
|
173
|
+
return display_command
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class BashExecutorToolset(BaseBashExecutorToolset):
|
|
177
|
+
def __init__(self):
|
|
178
|
+
super().__init__(
|
|
179
|
+
name="bash",
|
|
180
|
+
enabled=False,
|
|
181
|
+
description=(
|
|
182
|
+
"Toolset for executing arbitrary bash commands on the system where Holmes is running. "
|
|
183
|
+
"WARNING: This toolset provides powerful capabilities and should be "
|
|
184
|
+
"enabled and used with extreme caution due to significant security risks. "
|
|
185
|
+
"Ensure that only trusted users have access to this tool."
|
|
186
|
+
),
|
|
187
|
+
docs_url="", # TODO: Add relevant documentation URL
|
|
188
|
+
icon_url="https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Bash_Logo_Colored.svg/120px-Bash_Logo_Colored.svg.png", # Example Bash icon
|
|
189
|
+
prerequisites=[CallablePrerequisite(callable=self.prerequisites_callable)],
|
|
190
|
+
tools=[RunBashCommand(self), KubectlRunImageCommand(self)],
|
|
191
|
+
tags=[ToolsetTag.CORE],
|
|
192
|
+
is_default=False,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
self._reload_llm_instructions()
|
|
196
|
+
|
|
197
|
+
def _reload_llm_instructions(self):
|
|
198
|
+
template_file_path = os.path.abspath(
|
|
199
|
+
os.path.join(os.path.dirname(__file__), "bash_instructions.jinja2")
|
|
200
|
+
)
|
|
201
|
+
self._load_llm_instructions(jinja_template=f"file://{template_file_path}")
|
|
202
|
+
|
|
203
|
+
def prerequisites_callable(self, config: dict[str, Any]) -> tuple[bool, str]:
|
|
204
|
+
if config:
|
|
205
|
+
self.config = BashExecutorConfig(**config)
|
|
206
|
+
else:
|
|
207
|
+
self.config = BashExecutorConfig()
|
|
208
|
+
return True, ""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from holmes.core.tools import StructuredToolResult, ToolResultStatus
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def execute_bash_command(cmd: str, timeout: int, params: dict) -> StructuredToolResult:
|
|
6
|
+
try:
|
|
7
|
+
process = subprocess.run(
|
|
8
|
+
cmd,
|
|
9
|
+
shell=True,
|
|
10
|
+
executable="/bin/bash",
|
|
11
|
+
stdout=subprocess.PIPE,
|
|
12
|
+
stderr=subprocess.STDOUT,
|
|
13
|
+
text=True,
|
|
14
|
+
timeout=timeout,
|
|
15
|
+
check=False,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
stdout = process.stdout.strip() if process.stdout else ""
|
|
19
|
+
result_data = f"{cmd}\n" f"{stdout}"
|
|
20
|
+
|
|
21
|
+
status = ToolResultStatus.ERROR
|
|
22
|
+
if process.returncode == 0 and stdout:
|
|
23
|
+
status = ToolResultStatus.SUCCESS
|
|
24
|
+
elif not stdout:
|
|
25
|
+
status = ToolResultStatus.NO_DATA
|
|
26
|
+
|
|
27
|
+
return StructuredToolResult(
|
|
28
|
+
status=status,
|
|
29
|
+
data=result_data,
|
|
30
|
+
params=params,
|
|
31
|
+
invocation=cmd,
|
|
32
|
+
return_code=process.returncode,
|
|
33
|
+
)
|
|
34
|
+
except subprocess.TimeoutExpired:
|
|
35
|
+
return StructuredToolResult(
|
|
36
|
+
status=ToolResultStatus.ERROR,
|
|
37
|
+
error=f"Error: Command '{cmd}' timed out after {timeout} seconds.",
|
|
38
|
+
params=params,
|
|
39
|
+
)
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
# This might occur if /bin/bash is not found, or if shell=False and command is not found
|
|
42
|
+
return StructuredToolResult(
|
|
43
|
+
status=ToolResultStatus.ERROR,
|
|
44
|
+
error="Error: Bash executable or command not found. Ensure bash is installed and the command is valid.",
|
|
45
|
+
params=params,
|
|
46
|
+
)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return StructuredToolResult(
|
|
49
|
+
status=ToolResultStatus.ERROR,
|
|
50
|
+
error=f"Error executing command '{cmd}': {str(e)}",
|
|
51
|
+
params=params,
|
|
52
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class KubectlImageConfig(BaseModel):
|
|
5
|
+
image: str
|
|
6
|
+
allowed_commands: list[str]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KubectlConfig(BaseModel):
|
|
10
|
+
allowed_images: list[KubectlImageConfig] = []
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BashExecutorConfig(BaseModel):
|
|
14
|
+
kubectl: KubectlConfig = KubectlConfig()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
|
|
3
|
+
SAFE_SHELL_CHARS = frozenset(".-_=/,:")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def escape_shell_args(args: list[str]) -> list[str]:
|
|
7
|
+
"""
|
|
8
|
+
Escape shell arguments to prevent injection.
|
|
9
|
+
Uses shlex.quote for safe shell argument quoting.
|
|
10
|
+
"""
|
|
11
|
+
escaped_args = []
|
|
12
|
+
|
|
13
|
+
for arg in args:
|
|
14
|
+
# If argument is safe (contains only alphanumeric, hyphens, dots, underscores, equals, slash, comma, colon)
|
|
15
|
+
# no escaping needed
|
|
16
|
+
if arg and all(c.isalnum() or c in SAFE_SHELL_CHARS for c in arg):
|
|
17
|
+
escaped_args.append(arg)
|
|
18
|
+
# If argument starts with -- or - (flag), no escaping needed
|
|
19
|
+
elif arg.startswith("-"):
|
|
20
|
+
escaped_args.append(arg)
|
|
21
|
+
# For everything else, use shlex.quote for proper escaping
|
|
22
|
+
else:
|
|
23
|
+
escaped_args.append(shlex.quote(arg))
|
|
24
|
+
|
|
25
|
+
return escaped_args
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def regex_validator(field_name: str, pattern: Union[str, re.Pattern]):
|
|
7
|
+
def check_regex(arg_value):
|
|
8
|
+
if not re.match(pattern, arg_value):
|
|
9
|
+
raise argparse.ArgumentTypeError(f"invalid {field_name} value")
|
|
10
|
+
return arg_value
|
|
11
|
+
|
|
12
|
+
return check_regex
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def whitelist_validator(field_name: str, whitelisted_values: set[str]):
|
|
16
|
+
def validate_value(value: str) -> str:
|
|
17
|
+
if value not in whitelisted_values:
|
|
18
|
+
whitelisted_values_str = ", ".join(whitelisted_values)
|
|
19
|
+
raise argparse.ArgumentTypeError(
|
|
20
|
+
f"Invalid {field_name} format: '{value}'. Must be one of [{whitelisted_values_str}]"
|
|
21
|
+
)
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
return validate_value
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from holmes.plugins.toolsets.bash.common.stringify import escape_shell_args
|
|
6
|
+
|
|
7
|
+
MAX_GREP_SIZE = 100
|
|
8
|
+
SAFE_GREP_PATTERN = re.compile(r"^[a-zA-Z0-9\-_. :*()]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_grep_parser(main_parser: Any):
|
|
12
|
+
parser = main_parser.add_parser(
|
|
13
|
+
"grep",
|
|
14
|
+
help="Search text patterns in files or input",
|
|
15
|
+
exit_on_error=False,
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"keyword",
|
|
19
|
+
type=lambda x: validate_grep_keyword(x),
|
|
20
|
+
help="The pattern to search for",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-i", "--ignore-case", action="store_true", help="Ignore case distinctions"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_grep_keyword(value: str) -> str:
|
|
28
|
+
"""Validate the grep keyword parameter."""
|
|
29
|
+
|
|
30
|
+
if not value:
|
|
31
|
+
raise argparse.ArgumentTypeError("Grep keyword cannot be empty")
|
|
32
|
+
|
|
33
|
+
if not SAFE_GREP_PATTERN.match(value):
|
|
34
|
+
raise argparse.ArgumentTypeError(f"Unsafe grep keyword: {value}")
|
|
35
|
+
|
|
36
|
+
if len(value) > MAX_GREP_SIZE:
|
|
37
|
+
raise argparse.ArgumentTypeError(
|
|
38
|
+
f"Grep keyword too long. Max allowed size is {MAX_GREP_SIZE} but received {len(value)}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def stringify_grep_command(cmd: Any) -> str:
|
|
45
|
+
"""Stringify grep command."""
|
|
46
|
+
parts = ["grep"]
|
|
47
|
+
|
|
48
|
+
if cmd.ignore_case:
|
|
49
|
+
parts.append("-i")
|
|
50
|
+
|
|
51
|
+
parts.append(cmd.keyword)
|
|
52
|
+
return " ".join(escape_shell_args(parts))
|