opspilot-ai 0.1.0__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.
- opspilot/__init__.py +0 -0
- opspilot/agents/fixer.py +46 -0
- opspilot/agents/planner.py +74 -0
- opspilot/agents/remediation.py +200 -0
- opspilot/agents/verifier.py +67 -0
- opspilot/cli.py +360 -0
- opspilot/config.py +22 -0
- opspilot/context/__init__.py +26 -0
- opspilot/context/deployment_history.py +347 -0
- opspilot/context/deps.py +14 -0
- opspilot/context/docker.py +17 -0
- opspilot/context/env.py +17 -0
- opspilot/context/logs.py +16 -0
- opspilot/context/production_logs.py +262 -0
- opspilot/context/project.py +19 -0
- opspilot/diffs/redis.py +23 -0
- opspilot/graph/engine.py +33 -0
- opspilot/graph/nodes.py +41 -0
- opspilot/memory.py +24 -0
- opspilot/memory_redis.py +322 -0
- opspilot/state.py +18 -0
- opspilot/tools/__init__.py +52 -0
- opspilot/tools/dep_tools.py +5 -0
- opspilot/tools/env_tools.py +5 -0
- opspilot/tools/log_tools.py +11 -0
- opspilot/tools/pattern_analysis.py +194 -0
- opspilot/utils/__init__.py +1 -0
- opspilot/utils/llm.py +23 -0
- opspilot/utils/llm_providers.py +499 -0
- opspilot_ai-0.1.0.dist-info/METADATA +408 -0
- opspilot_ai-0.1.0.dist-info/RECORD +35 -0
- opspilot_ai-0.1.0.dist-info/WHEEL +5 -0
- opspilot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- opspilot_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
- opspilot_ai-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Deployment and Git history correlation for incident analysis."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_recent_commits(repo_path: str, since_hours: int = 24, limit: int = 20) -> List[Dict]:
|
|
10
|
+
"""
|
|
11
|
+
Get recent Git commits with metadata.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
repo_path: Path to Git repository
|
|
15
|
+
since_hours: Get commits from last N hours (default: 24)
|
|
16
|
+
limit: Max number of commits to retrieve (default: 20)
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
List of commit dictionaries with hash, author, date, message
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
since_time = datetime.now() - timedelta(hours=since_hours)
|
|
23
|
+
since_str = since_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
24
|
+
|
|
25
|
+
# Git log format: hash|author|date|subject
|
|
26
|
+
cmd = [
|
|
27
|
+
"git", "-C", repo_path, "log",
|
|
28
|
+
f"--since={since_str}",
|
|
29
|
+
f"-{limit}",
|
|
30
|
+
"--pretty=format:%H|%an|%ai|%s"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
cmd,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=10
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if result.returncode != 0:
|
|
41
|
+
print(f"Git log failed: {result.stderr}")
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
commits = []
|
|
45
|
+
for line in result.stdout.strip().split("\n"):
|
|
46
|
+
if not line:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
parts = line.split("|", 3)
|
|
50
|
+
if len(parts) == 4:
|
|
51
|
+
commits.append({
|
|
52
|
+
"hash": parts[0][:8], # Short hash
|
|
53
|
+
"author": parts[1],
|
|
54
|
+
"date": parts[2],
|
|
55
|
+
"message": parts[3]
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return commits
|
|
59
|
+
|
|
60
|
+
except subprocess.TimeoutExpired:
|
|
61
|
+
print("Git log timed out")
|
|
62
|
+
return []
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
print("Git not found. Install from: https://git-scm.com/")
|
|
65
|
+
return []
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"Failed to get Git commits: {e}")
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_changed_files(repo_path: str, commit_hash: str) -> List[str]:
|
|
72
|
+
"""
|
|
73
|
+
Get list of files changed in a specific commit.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
repo_path: Path to Git repository
|
|
77
|
+
commit_hash: Commit hash
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of file paths changed in the commit
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
cmd = [
|
|
84
|
+
"git", "-C", repo_path, "diff-tree",
|
|
85
|
+
"--no-commit-id", "--name-only", "-r",
|
|
86
|
+
commit_hash
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
cmd,
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
timeout=10
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if result.returncode == 0:
|
|
97
|
+
return [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
|
98
|
+
else:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
except Exception:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def correlate_with_error_timeline(commits: List[Dict], error_first_seen: str) -> Dict:
|
|
106
|
+
"""
|
|
107
|
+
Correlate commits with error timeline.
|
|
108
|
+
|
|
109
|
+
Identifies commits that happened around the same time as errors appeared.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
commits: List of commit dictionaries
|
|
113
|
+
error_first_seen: Timestamp when error was first seen
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Analysis dictionary with suspicious commits
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Parse error timestamp (supports common formats)
|
|
120
|
+
error_time = None
|
|
121
|
+
for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"]:
|
|
122
|
+
try:
|
|
123
|
+
error_time = datetime.strptime(error_first_seen[:19], fmt)
|
|
124
|
+
break
|
|
125
|
+
except ValueError:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if not error_time:
|
|
129
|
+
return {"correlation": "unknown", "reason": "Could not parse error timestamp"}
|
|
130
|
+
|
|
131
|
+
# Find commits within ±2 hours of error
|
|
132
|
+
suspicious_commits = []
|
|
133
|
+
for commit in commits:
|
|
134
|
+
try:
|
|
135
|
+
commit_time = datetime.strptime(commit["date"][:19], "%Y-%m-%d %H:%M:%S")
|
|
136
|
+
time_diff = abs((commit_time - error_time).total_seconds() / 3600)
|
|
137
|
+
|
|
138
|
+
if time_diff <= 2:
|
|
139
|
+
suspicious_commits.append({
|
|
140
|
+
**commit,
|
|
141
|
+
"time_diff_hours": round(time_diff, 1)
|
|
142
|
+
})
|
|
143
|
+
except ValueError:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
if suspicious_commits:
|
|
147
|
+
return {
|
|
148
|
+
"correlation": "strong",
|
|
149
|
+
"suspicious_commits": suspicious_commits,
|
|
150
|
+
"count": len(suspicious_commits),
|
|
151
|
+
"reason": f"Found {len(suspicious_commits)} commits within 2 hours of error"
|
|
152
|
+
}
|
|
153
|
+
else:
|
|
154
|
+
return {
|
|
155
|
+
"correlation": "weak",
|
|
156
|
+
"reason": "No commits near error occurrence time"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return {"correlation": "error", "reason": str(e)}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def analyze_deployment_impact(repo_path: str, since_hours: int = 24) -> Dict:
|
|
164
|
+
"""
|
|
165
|
+
Analyze recent deployments and their potential impact.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
repo_path: Path to Git repository
|
|
169
|
+
since_hours: Analyze last N hours (default: 24)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Deployment analysis with commits, changed files, and risk assessment
|
|
173
|
+
"""
|
|
174
|
+
commits = get_recent_commits(repo_path, since_hours)
|
|
175
|
+
|
|
176
|
+
if not commits:
|
|
177
|
+
return {
|
|
178
|
+
"has_recent_changes": False,
|
|
179
|
+
"commits": [],
|
|
180
|
+
"risk_level": "LOW",
|
|
181
|
+
"reason": f"No commits in the last {since_hours} hours"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Categorize changed files
|
|
185
|
+
all_changed_files = []
|
|
186
|
+
high_risk_files = []
|
|
187
|
+
config_files = []
|
|
188
|
+
|
|
189
|
+
for commit in commits:
|
|
190
|
+
files = get_changed_files(repo_path, commit["hash"])
|
|
191
|
+
all_changed_files.extend(files)
|
|
192
|
+
|
|
193
|
+
# Identify high-risk changes
|
|
194
|
+
for file in files:
|
|
195
|
+
file_lower = file.lower()
|
|
196
|
+
|
|
197
|
+
# Config files
|
|
198
|
+
if any(x in file_lower for x in [".env", "config", "settings", "docker"]):
|
|
199
|
+
config_files.append({"file": file, "commit": commit["hash"]})
|
|
200
|
+
|
|
201
|
+
# Database migrations
|
|
202
|
+
if "migration" in file_lower or "schema" in file_lower:
|
|
203
|
+
high_risk_files.append({"file": file, "commit": commit["hash"], "reason": "Database change"})
|
|
204
|
+
|
|
205
|
+
# Critical services
|
|
206
|
+
if any(x in file_lower for x in ["auth", "payment", "api", "gateway"]):
|
|
207
|
+
high_risk_files.append({"file": file, "commit": commit["hash"], "reason": "Critical service"})
|
|
208
|
+
|
|
209
|
+
# Assess risk level
|
|
210
|
+
if high_risk_files:
|
|
211
|
+
risk_level = "HIGH"
|
|
212
|
+
reason = f"{len(high_risk_files)} high-risk files changed (migrations, auth, payments)"
|
|
213
|
+
elif config_files:
|
|
214
|
+
risk_level = "MEDIUM"
|
|
215
|
+
reason = f"{len(config_files)} configuration files changed"
|
|
216
|
+
elif len(commits) > 10:
|
|
217
|
+
risk_level = "MEDIUM"
|
|
218
|
+
reason = f"High deployment frequency ({len(commits)} commits)"
|
|
219
|
+
else:
|
|
220
|
+
risk_level = "LOW"
|
|
221
|
+
reason = f"{len(commits)} commits with standard changes"
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"has_recent_changes": True,
|
|
225
|
+
"commits": commits,
|
|
226
|
+
"total_commits": len(commits),
|
|
227
|
+
"changed_files_count": len(set(all_changed_files)),
|
|
228
|
+
"high_risk_files": high_risk_files,
|
|
229
|
+
"config_files": config_files,
|
|
230
|
+
"risk_level": risk_level,
|
|
231
|
+
"reason": reason
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_blame_for_error(repo_path: str, file_path: str, line_number: int) -> Optional[Dict]:
|
|
236
|
+
"""
|
|
237
|
+
Get Git blame information for a specific line.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
repo_path: Path to Git repository
|
|
241
|
+
file_path: File path relative to repo root
|
|
242
|
+
line_number: Line number (1-indexed)
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Blame info with commit hash, author, date
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
cmd = [
|
|
249
|
+
"git", "-C", repo_path, "blame",
|
|
250
|
+
"-L", f"{line_number},{line_number}",
|
|
251
|
+
"--porcelain",
|
|
252
|
+
file_path
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
result = subprocess.run(
|
|
256
|
+
cmd,
|
|
257
|
+
capture_output=True,
|
|
258
|
+
text=True,
|
|
259
|
+
timeout=10
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if result.returncode != 0:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
# Parse porcelain format
|
|
266
|
+
lines = result.stdout.split("\n")
|
|
267
|
+
if not lines:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
# First line: hash original_line final_line num_lines
|
|
271
|
+
parts = lines[0].split()
|
|
272
|
+
commit_hash = parts[0] if parts else "unknown"
|
|
273
|
+
|
|
274
|
+
# Extract author and date from subsequent lines
|
|
275
|
+
author = "unknown"
|
|
276
|
+
date = "unknown"
|
|
277
|
+
|
|
278
|
+
for line in lines[1:]:
|
|
279
|
+
if line.startswith("author "):
|
|
280
|
+
author = line[7:]
|
|
281
|
+
elif line.startswith("author-time "):
|
|
282
|
+
try:
|
|
283
|
+
timestamp = int(line[12:])
|
|
284
|
+
date = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
|
285
|
+
except ValueError:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
"commit": commit_hash[:8],
|
|
290
|
+
"author": author,
|
|
291
|
+
"date": date,
|
|
292
|
+
"file": file_path,
|
|
293
|
+
"line": line_number
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
except Exception:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def format_deployment_analysis(analysis: Dict) -> str:
|
|
301
|
+
"""
|
|
302
|
+
Format deployment analysis as human-readable text.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
analysis: Deployment analysis dictionary
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Formatted multi-line string
|
|
309
|
+
"""
|
|
310
|
+
if not analysis.get("has_recent_changes"):
|
|
311
|
+
return "\nNo recent deployments detected.\n"
|
|
312
|
+
|
|
313
|
+
output = []
|
|
314
|
+
|
|
315
|
+
output.append("\n" + "=" * 60)
|
|
316
|
+
output.append("RECENT DEPLOYMENT ANALYSIS")
|
|
317
|
+
output.append("=" * 60)
|
|
318
|
+
|
|
319
|
+
risk_level = analysis.get("risk_level", "UNKNOWN")
|
|
320
|
+
risk_color_map = {
|
|
321
|
+
"HIGH": "RED",
|
|
322
|
+
"MEDIUM": "YELLOW",
|
|
323
|
+
"LOW": "GREEN"
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
output.append(f"\nRisk Level: {risk_level}")
|
|
327
|
+
output.append(f"Reason: {analysis.get('reason', 'N/A')}")
|
|
328
|
+
output.append(f"\nTotal Commits: {analysis.get('total_commits', 0)}")
|
|
329
|
+
output.append(f"Files Changed: {analysis.get('changed_files_count', 0)}")
|
|
330
|
+
|
|
331
|
+
if analysis.get("high_risk_files"):
|
|
332
|
+
output.append(f"\nHigh-Risk Changes ({len(analysis['high_risk_files'])}):")
|
|
333
|
+
for item in analysis["high_risk_files"][:5]: # Show first 5
|
|
334
|
+
output.append(f" - {item['file']} ({item['reason']}) [{item['commit']}]")
|
|
335
|
+
|
|
336
|
+
if analysis.get("config_files"):
|
|
337
|
+
output.append(f"\nConfiguration Changes ({len(analysis['config_files'])}):")
|
|
338
|
+
for item in analysis["config_files"][:5]:
|
|
339
|
+
output.append(f" - {item['file']} [{item['commit']}]")
|
|
340
|
+
|
|
341
|
+
if analysis.get("commits"):
|
|
342
|
+
output.append(f"\nRecent Commits:")
|
|
343
|
+
for commit in analysis["commits"][:5]:
|
|
344
|
+
output.append(f" [{commit['hash']}] {commit['message']}")
|
|
345
|
+
output.append(f" by {commit['author']} at {commit['date']}")
|
|
346
|
+
|
|
347
|
+
return "\n".join(output)
|
opspilot/context/deps.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_dependencies(project_root: str) -> List[str]:
|
|
6
|
+
root = Path(project_root)
|
|
7
|
+
|
|
8
|
+
if (root / "requirements.txt").exists():
|
|
9
|
+
return (root / "requirements.txt").read_text().splitlines()
|
|
10
|
+
|
|
11
|
+
if (root / "package.json").exists():
|
|
12
|
+
return ["node_project"]
|
|
13
|
+
|
|
14
|
+
return []
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_docker_files(project_root: str) -> Dict[str, str]:
|
|
6
|
+
root = Path(project_root)
|
|
7
|
+
docker_data = {}
|
|
8
|
+
|
|
9
|
+
dockerfile = root / "Dockerfile"
|
|
10
|
+
if dockerfile.exists():
|
|
11
|
+
docker_data["Dockerfile"] = dockerfile.read_text()
|
|
12
|
+
|
|
13
|
+
compose = root / "docker-compose.yml"
|
|
14
|
+
if compose.exists():
|
|
15
|
+
docker_data["docker-compose.yml"] = compose.read_text()
|
|
16
|
+
|
|
17
|
+
return docker_data
|
opspilot/context/env.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_env(project_root: str) -> Dict[str, str]:
|
|
6
|
+
env_path = Path(project_root) / ".env"
|
|
7
|
+
env_vars = {}
|
|
8
|
+
|
|
9
|
+
if not env_path.exists():
|
|
10
|
+
return env_vars
|
|
11
|
+
|
|
12
|
+
for line in env_path.read_text().splitlines():
|
|
13
|
+
if line.strip() and not line.startswith("#") and "=" in line:
|
|
14
|
+
key, value = line.split("=", 1)
|
|
15
|
+
env_vars[key.strip()] = value.strip()
|
|
16
|
+
|
|
17
|
+
return env_vars
|
opspilot/context/logs.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_logs(project_root: str) -> Optional[str]:
|
|
6
|
+
logs_dir = Path(project_root) / "logs"
|
|
7
|
+
if not logs_dir.exists():
|
|
8
|
+
return None
|
|
9
|
+
|
|
10
|
+
log_files = list(logs_dir.glob("*.log"))
|
|
11
|
+
if not log_files:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
# Read most recent log file
|
|
15
|
+
latest_log = max(log_files, key=lambda f: f.stat().st_mtime)
|
|
16
|
+
return latest_log.read_text(errors="ignore")[-5000:]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Production log fetching from various sources."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fetch_logs_from_url(url: str, timeout: int = 30) -> Optional[str]:
|
|
10
|
+
"""
|
|
11
|
+
Fetch logs from HTTP/HTTPS URL.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
url: Log file URL
|
|
15
|
+
timeout: Request timeout in seconds
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Log content as string, or None if failed
|
|
19
|
+
"""
|
|
20
|
+
try:
|
|
21
|
+
response = requests.get(url, timeout=timeout)
|
|
22
|
+
response.raise_for_status()
|
|
23
|
+
return response.text
|
|
24
|
+
except requests.RequestException as e:
|
|
25
|
+
print(f"Failed to fetch logs from {url}: {e}")
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_logs_from_s3(bucket: str, key: str, profile: Optional[str] = None) -> Optional[str]:
|
|
30
|
+
"""
|
|
31
|
+
Fetch logs from S3 bucket using AWS CLI.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
bucket: S3 bucket name
|
|
35
|
+
key: Object key (file path in bucket)
|
|
36
|
+
profile: Optional AWS profile name
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Log content as string, or None if failed
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
cmd = ["aws", "s3", "cp", f"s3://{bucket}/{key}", "-"]
|
|
43
|
+
|
|
44
|
+
if profile:
|
|
45
|
+
cmd.extend(["--profile", profile])
|
|
46
|
+
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
cmd,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=60
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if result.returncode == 0:
|
|
55
|
+
return result.stdout
|
|
56
|
+
else:
|
|
57
|
+
print(f"Failed to fetch from S3: {result.stderr}")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
except subprocess.TimeoutExpired:
|
|
61
|
+
print(f"S3 fetch timed out for s3://{bucket}/{key}")
|
|
62
|
+
return None
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
print("AWS CLI not found. Install from: https://aws.amazon.com/cli/")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fetch_logs_from_file(path: str) -> Optional[str]:
|
|
69
|
+
"""
|
|
70
|
+
Read logs from local file.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: Local file path
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Log content as string, or None if failed
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
file_path = Path(path)
|
|
80
|
+
if not file_path.exists():
|
|
81
|
+
print(f"Log file not found: {path}")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
return file_path.read_text(encoding="utf-8", errors="ignore")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f"Failed to read log file {path}: {e}")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fetch_logs_from_kubectl(namespace: str, pod: str, container: Optional[str] = None,
|
|
91
|
+
tail: int = 1000) -> Optional[str]:
|
|
92
|
+
"""
|
|
93
|
+
Fetch logs from Kubernetes pod using kubectl.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
namespace: Kubernetes namespace
|
|
97
|
+
pod: Pod name
|
|
98
|
+
container: Optional container name (for multi-container pods)
|
|
99
|
+
tail: Number of lines to fetch (default: 1000)
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Log content as string, or None if failed
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
cmd = ["kubectl", "logs", pod, "-n", namespace, f"--tail={tail}"]
|
|
106
|
+
|
|
107
|
+
if container:
|
|
108
|
+
cmd.extend(["-c", container])
|
|
109
|
+
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
cmd,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
timeout=30
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if result.returncode == 0:
|
|
118
|
+
return result.stdout
|
|
119
|
+
else:
|
|
120
|
+
print(f"Failed to fetch kubectl logs: {result.stderr}")
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
except subprocess.TimeoutExpired:
|
|
124
|
+
print(f"kubectl logs fetch timed out for {namespace}/{pod}")
|
|
125
|
+
return None
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
print("kubectl not found. Install from: https://kubernetes.io/docs/tasks/tools/")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fetch_logs_from_cloudwatch(log_group: str, log_stream: str,
|
|
132
|
+
profile: Optional[str] = None,
|
|
133
|
+
limit: int = 1000) -> Optional[str]:
|
|
134
|
+
"""
|
|
135
|
+
Fetch logs from AWS CloudWatch using AWS CLI.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
log_group: CloudWatch log group name
|
|
139
|
+
log_stream: Log stream name
|
|
140
|
+
profile: Optional AWS profile name
|
|
141
|
+
limit: Max number of log events (default: 1000)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Log content as string, or None if failed
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
cmd = [
|
|
148
|
+
"aws", "logs", "get-log-events",
|
|
149
|
+
"--log-group-name", log_group,
|
|
150
|
+
"--log-stream-name", log_stream,
|
|
151
|
+
"--limit", str(limit),
|
|
152
|
+
"--output", "text",
|
|
153
|
+
"--query", "events[*].message"
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
if profile:
|
|
157
|
+
cmd.extend(["--profile", profile])
|
|
158
|
+
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
cmd,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
timeout=60
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if result.returncode == 0:
|
|
167
|
+
return result.stdout
|
|
168
|
+
else:
|
|
169
|
+
print(f"Failed to fetch CloudWatch logs: {result.stderr}")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
print(f"CloudWatch fetch timed out for {log_group}/{log_stream}")
|
|
174
|
+
return None
|
|
175
|
+
except FileNotFoundError:
|
|
176
|
+
print("AWS CLI not found. Install from: https://aws.amazon.com/cli/")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def auto_detect_and_fetch(source: str) -> Optional[str]:
|
|
181
|
+
"""
|
|
182
|
+
Auto-detect log source type and fetch accordingly.
|
|
183
|
+
|
|
184
|
+
Supports:
|
|
185
|
+
- HTTP/HTTPS URLs
|
|
186
|
+
- S3 paths (s3://bucket/key)
|
|
187
|
+
- Kubernetes (k8s://namespace/pod or k8s://namespace/pod/container)
|
|
188
|
+
- CloudWatch (cw://log-group/log-stream)
|
|
189
|
+
- Local files
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
source: Log source string
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Log content as string, or None if failed
|
|
196
|
+
"""
|
|
197
|
+
source = source.strip()
|
|
198
|
+
|
|
199
|
+
# HTTP/HTTPS URL
|
|
200
|
+
if source.startswith(("http://", "https://")):
|
|
201
|
+
return fetch_logs_from_url(source)
|
|
202
|
+
|
|
203
|
+
# S3 path
|
|
204
|
+
if source.startswith("s3://"):
|
|
205
|
+
# Parse s3://bucket/key
|
|
206
|
+
parts = source[5:].split("/", 1)
|
|
207
|
+
if len(parts) == 2:
|
|
208
|
+
return fetch_logs_from_s3(parts[0], parts[1])
|
|
209
|
+
else:
|
|
210
|
+
print(f"Invalid S3 path format: {source}")
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
# Kubernetes
|
|
214
|
+
if source.startswith("k8s://"):
|
|
215
|
+
# Parse k8s://namespace/pod or k8s://namespace/pod/container
|
|
216
|
+
parts = source[6:].split("/")
|
|
217
|
+
if len(parts) == 2:
|
|
218
|
+
return fetch_logs_from_kubectl(parts[0], parts[1])
|
|
219
|
+
elif len(parts) == 3:
|
|
220
|
+
return fetch_logs_from_kubectl(parts[0], parts[1], parts[2])
|
|
221
|
+
else:
|
|
222
|
+
print(f"Invalid k8s path format: {source}")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
# CloudWatch
|
|
226
|
+
if source.startswith("cw://"):
|
|
227
|
+
# Parse cw://log-group/log-stream
|
|
228
|
+
parts = source[5:].split("/", 1)
|
|
229
|
+
if len(parts) == 2:
|
|
230
|
+
return fetch_logs_from_cloudwatch(parts[0], parts[1])
|
|
231
|
+
else:
|
|
232
|
+
print(f"Invalid CloudWatch path format: {source}")
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# Local file (default)
|
|
236
|
+
return fetch_logs_from_file(source)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def fetch_multiple_sources(sources: List[str]) -> str:
|
|
240
|
+
"""
|
|
241
|
+
Fetch logs from multiple sources and concatenate.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
sources: List of log source strings
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Combined log content
|
|
248
|
+
"""
|
|
249
|
+
all_logs = []
|
|
250
|
+
|
|
251
|
+
for source in sources:
|
|
252
|
+
print(f"Fetching logs from: {source}")
|
|
253
|
+
logs = auto_detect_and_fetch(source)
|
|
254
|
+
if logs:
|
|
255
|
+
all_logs.append(f"\n{'='*60}\n")
|
|
256
|
+
all_logs.append(f"SOURCE: {source}\n")
|
|
257
|
+
all_logs.append(f"{'='*60}\n")
|
|
258
|
+
all_logs.append(logs)
|
|
259
|
+
else:
|
|
260
|
+
print(f"Warning: Could not fetch logs from {source}")
|
|
261
|
+
|
|
262
|
+
return "\n".join(all_logs)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List, Dict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def scan_project_tree(project_root: str, max_depth: int = 3) -> Dict[str, List[str]]:
|
|
6
|
+
root = Path(project_root)
|
|
7
|
+
structure = {}
|
|
8
|
+
|
|
9
|
+
for path in root.rglob("*"):
|
|
10
|
+
try:
|
|
11
|
+
depth = len(path.relative_to(root).parts)
|
|
12
|
+
except ValueError:
|
|
13
|
+
continue
|
|
14
|
+
|
|
15
|
+
if depth <= max_depth and path.is_file():
|
|
16
|
+
structure.setdefault(
|
|
17
|
+
str(path.parent.relative_to(root)), []).append(path.name)
|
|
18
|
+
|
|
19
|
+
return structure
|