dynamic-mcp 0.2.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.
- dynamic_mcp/bpftrace_executor.py +210 -0
- dynamic_mcp/config.py +106 -0
- dynamic_mcp/crash_discovery.py +134 -0
- dynamic_mcp/crash_session.py +209 -0
- dynamic_mcp/dynamic-mcp.service +55 -0
- dynamic_mcp/kernel_detection.py +196 -0
- dynamic_mcp/permission_manager.py +193 -0
- dynamic_mcp/server.py +951 -0
- dynamic_mcp/systemd_installer.py +238 -0
- dynamic_mcp/tunnel_manager.py +243 -0
- dynamic_mcp-0.2.0.data/scripts/setup.py +229 -0
- dynamic_mcp-0.2.0.dist-info/METADATA +387 -0
- dynamic_mcp-0.2.0.dist-info/RECORD +17 -0
- dynamic_mcp-0.2.0.dist-info/WHEEL +5 -0
- dynamic_mcp-0.2.0.dist-info/entry_points.txt +4 -0
- dynamic_mcp-0.2.0.dist-info/licenses/LICENSE +22 -0
- dynamic_mcp-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""BPFtrace script execution and management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BPFtraceExecutor:
|
|
15
|
+
"""Executes BPFtrace scripts with proper permission handling."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, timeout: int = 30):
|
|
18
|
+
"""Initialize BPFtrace executor.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
timeout: Default timeout for script execution in seconds
|
|
22
|
+
"""
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.bpftrace_path = self._find_bpftrace()
|
|
25
|
+
|
|
26
|
+
def _find_bpftrace(self) -> Optional[str]:
|
|
27
|
+
"""Find bpftrace binary in system PATH."""
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["which", "bpftrace"],
|
|
31
|
+
stdout=subprocess.PIPE,
|
|
32
|
+
stderr=subprocess.PIPE,
|
|
33
|
+
universal_newlines=True,
|
|
34
|
+
timeout=5
|
|
35
|
+
)
|
|
36
|
+
if result.returncode == 0:
|
|
37
|
+
path = result.stdout.strip()
|
|
38
|
+
logger.info(f"Found bpftrace at: {path}")
|
|
39
|
+
return path
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.warning(f"Could not find bpftrace: {e}")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def is_available(self) -> bool:
|
|
45
|
+
"""Check if bpftrace is available on the system."""
|
|
46
|
+
return self.bpftrace_path is not None
|
|
47
|
+
|
|
48
|
+
def get_version(self) -> Optional[str]:
|
|
49
|
+
"""Get bpftrace version."""
|
|
50
|
+
if not self.bpftrace_path:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
[self.bpftrace_path, "--version"],
|
|
55
|
+
stdout=subprocess.PIPE,
|
|
56
|
+
stderr=subprocess.PIPE,
|
|
57
|
+
universal_newlines=True,
|
|
58
|
+
timeout=5
|
|
59
|
+
)
|
|
60
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.warning(f"Could not get bpftrace version: {e}")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
async def execute_script(
|
|
66
|
+
self,
|
|
67
|
+
script: str,
|
|
68
|
+
timeout: Optional[int] = None,
|
|
69
|
+
use_sudo: bool = True
|
|
70
|
+
) -> Tuple[str, str, int]:
|
|
71
|
+
"""Execute a BPFtrace script.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
script: BPFtrace script content
|
|
75
|
+
timeout: Execution timeout in seconds (uses default if None)
|
|
76
|
+
use_sudo: Whether to use sudo for execution
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Tuple of (stdout, stderr, return_code)
|
|
80
|
+
"""
|
|
81
|
+
if not self.bpftrace_path:
|
|
82
|
+
return "", "BPFtrace not available on system", 1
|
|
83
|
+
|
|
84
|
+
timeout = timeout or self.timeout
|
|
85
|
+
|
|
86
|
+
# Write script to temporary file
|
|
87
|
+
try:
|
|
88
|
+
with tempfile.NamedTemporaryFile(
|
|
89
|
+
mode='w',
|
|
90
|
+
suffix='.bt',
|
|
91
|
+
delete=False
|
|
92
|
+
) as f:
|
|
93
|
+
f.write(script)
|
|
94
|
+
script_path = f.name
|
|
95
|
+
|
|
96
|
+
return await self._execute_script_file(
|
|
97
|
+
script_path,
|
|
98
|
+
timeout,
|
|
99
|
+
use_sudo
|
|
100
|
+
)
|
|
101
|
+
finally:
|
|
102
|
+
# Clean up temporary file
|
|
103
|
+
try:
|
|
104
|
+
os.unlink(script_path)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.warning(f"Could not delete temp script: {e}")
|
|
107
|
+
|
|
108
|
+
async def _execute_script_file(
|
|
109
|
+
self,
|
|
110
|
+
script_path: str,
|
|
111
|
+
timeout: int,
|
|
112
|
+
use_sudo: bool
|
|
113
|
+
) -> Tuple[str, str, int]:
|
|
114
|
+
"""Execute a BPFtrace script from file."""
|
|
115
|
+
cmd = [self.bpftrace_path, script_path]
|
|
116
|
+
|
|
117
|
+
if use_sudo:
|
|
118
|
+
cmd = ["sudo", "-n"] + cmd
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
process = await asyncio.create_subprocess_exec(
|
|
122
|
+
*cmd,
|
|
123
|
+
stdout=asyncio.subprocess.PIPE,
|
|
124
|
+
stderr=asyncio.subprocess.PIPE
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
stdout, stderr = await asyncio.wait_for(
|
|
129
|
+
process.communicate(),
|
|
130
|
+
timeout=timeout
|
|
131
|
+
)
|
|
132
|
+
return (
|
|
133
|
+
stdout.decode('utf-8', errors='ignore'),
|
|
134
|
+
stderr.decode('utf-8', errors='ignore'),
|
|
135
|
+
process.returncode
|
|
136
|
+
)
|
|
137
|
+
except asyncio.TimeoutError:
|
|
138
|
+
logger.info(f"BPFtrace script execution timeout after {timeout}s, terminating process")
|
|
139
|
+
|
|
140
|
+
# Try graceful termination first with SIGTERM
|
|
141
|
+
process.terminate()
|
|
142
|
+
logger.debug("Sent SIGTERM to process")
|
|
143
|
+
try:
|
|
144
|
+
# Wait for graceful termination with a short timeout
|
|
145
|
+
logger.debug("Waiting for graceful termination...")
|
|
146
|
+
await asyncio.wait_for(process.wait(), timeout=2)
|
|
147
|
+
logger.debug("Process terminated gracefully")
|
|
148
|
+
except asyncio.TimeoutError:
|
|
149
|
+
# If graceful termination fails, force kill
|
|
150
|
+
logger.warning("Graceful termination failed, force killing process")
|
|
151
|
+
process.kill()
|
|
152
|
+
logger.debug("Sent SIGKILL to process")
|
|
153
|
+
try:
|
|
154
|
+
logger.debug("Waiting for process to be killed...")
|
|
155
|
+
await asyncio.wait_for(process.wait(), timeout=1)
|
|
156
|
+
logger.debug("Process killed successfully")
|
|
157
|
+
except asyncio.TimeoutError:
|
|
158
|
+
logger.error("Process did not respond to SIGKILL")
|
|
159
|
+
|
|
160
|
+
# Capture any partial output that was produced before timeout
|
|
161
|
+
# Don't try to read from streams as they may block
|
|
162
|
+
stdout_text = ""
|
|
163
|
+
stderr_text = f"Script execution timed out after {timeout}s"
|
|
164
|
+
|
|
165
|
+
logger.debug(f"Returning timeout result: exit_code=124")
|
|
166
|
+
return stdout_text, stderr_text, 124
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Error executing BPFtrace script: {e}")
|
|
170
|
+
return "", str(e), 1
|
|
171
|
+
|
|
172
|
+
def validate_script(self, script: str) -> Tuple[bool, str]:
|
|
173
|
+
"""Validate BPFtrace script syntax.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
script: BPFtrace script content
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Tuple of (is_valid, error_message)
|
|
180
|
+
"""
|
|
181
|
+
if not self.bpftrace_path:
|
|
182
|
+
return False, "BPFtrace not available"
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
with tempfile.NamedTemporaryFile(
|
|
186
|
+
mode='w',
|
|
187
|
+
suffix='.bt',
|
|
188
|
+
delete=False
|
|
189
|
+
) as f:
|
|
190
|
+
f.write(script)
|
|
191
|
+
script_path = f.name
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
["sudo", "-n", self.bpftrace_path, "-c", script_path],
|
|
196
|
+
stdout=subprocess.PIPE,
|
|
197
|
+
stderr=subprocess.PIPE,
|
|
198
|
+
universal_newlines=True,
|
|
199
|
+
timeout=5
|
|
200
|
+
)
|
|
201
|
+
if result.returncode == 0:
|
|
202
|
+
return True, ""
|
|
203
|
+
else:
|
|
204
|
+
return False, result.stderr
|
|
205
|
+
finally:
|
|
206
|
+
os.unlink(script_path)
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
return False, str(e)
|
|
210
|
+
|
dynamic_mcp/config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Configuration for dynamic MCP server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Any
|
|
8
|
+
|
|
9
|
+
from dynamic_mcp.permission_manager import check_crash_dump_access, configure_crash_dump_permissions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Config:
|
|
13
|
+
"""Configuration class for crash MCP server."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.crash_dump_path = Path(os.getenv("CRASH_DUMP_PATH", "/var/crash"))
|
|
17
|
+
self.kernel_path = Path(os.getenv("KERNEL_PATH", "/boot"))
|
|
18
|
+
self.log_level = os.getenv("LOG_LEVEL", "INFO")
|
|
19
|
+
self.crash_timeout = int(os.getenv("CRASH_TIMEOUT", "360"))
|
|
20
|
+
self.max_crash_dumps = int(os.getenv("MAX_CRASH_DUMPS", "10"))
|
|
21
|
+
self.session_init_timeout = int(os.getenv("SESSION_INIT_TIMEOUT", "1024"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def setup_logging():
|
|
25
|
+
"""Setup logging configuration."""
|
|
26
|
+
logging.basicConfig(
|
|
27
|
+
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")),
|
|
28
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_system_requirements() -> Dict[str, Any]:
|
|
33
|
+
"""Check system requirements for crash analysis."""
|
|
34
|
+
requirements = {
|
|
35
|
+
"crash_utility": False,
|
|
36
|
+
"crash_dump_access": False,
|
|
37
|
+
"kernel_access": False,
|
|
38
|
+
"root_access": False,
|
|
39
|
+
"crash_dump_readable": False
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Check crash utility
|
|
43
|
+
try:
|
|
44
|
+
result = subprocess.run(["crash", "--version"], capture_output=True, text=True, timeout=10)
|
|
45
|
+
requirements["crash_utility"] = result.returncode == 0
|
|
46
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
# Check crash dump access
|
|
50
|
+
crash_path = Path("/var/crash")
|
|
51
|
+
requirements["crash_dump_access"] = crash_path.exists() and crash_path.is_dir()
|
|
52
|
+
|
|
53
|
+
# Check if crash dump directory is readable
|
|
54
|
+
requirements["crash_dump_readable"] = check_crash_dump_access(crash_path)
|
|
55
|
+
|
|
56
|
+
# Check kernel access
|
|
57
|
+
kernel_path = Path("/boot")
|
|
58
|
+
requirements["kernel_access"] = kernel_path.exists() and kernel_path.is_dir()
|
|
59
|
+
|
|
60
|
+
# Check root access
|
|
61
|
+
requirements["root_access"] = os.geteuid() == 0
|
|
62
|
+
|
|
63
|
+
return requirements
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_crash_utility() -> str:
|
|
67
|
+
"""Validate crash utility availability and return version."""
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(["crash", "--version"], capture_output=True, text=True, timeout=10)
|
|
70
|
+
if result.returncode == 0:
|
|
71
|
+
return result.stdout.strip()
|
|
72
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
73
|
+
pass
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_crash_dump_access(crash_path: Path = Path("/var/crash")) -> bool:
|
|
78
|
+
"""Ensure crash dump directory is readable at runtime.
|
|
79
|
+
|
|
80
|
+
Attempts to configure permissions if not already readable.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
crash_path: Path to crash dump directory
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if readable or successfully configured, False otherwise
|
|
87
|
+
"""
|
|
88
|
+
logger = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
# Check if already readable
|
|
91
|
+
if check_crash_dump_access(crash_path):
|
|
92
|
+
logger.debug(f"Crash dump directory already readable: {crash_path}")
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
logger.info(f"Crash dump directory not readable, attempting to configure permissions...")
|
|
96
|
+
|
|
97
|
+
# Try to configure permissions
|
|
98
|
+
success, message = configure_crash_dump_permissions(crash_path)
|
|
99
|
+
|
|
100
|
+
if success:
|
|
101
|
+
logger.info(f"Permission configuration: {message}")
|
|
102
|
+
# Verify it worked
|
|
103
|
+
return check_crash_dump_access(crash_path)
|
|
104
|
+
else:
|
|
105
|
+
logger.warning(f"Permission configuration failed: {message}")
|
|
106
|
+
return False
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Crash dump discovery functionality."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, NamedTuple, Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrashDump(NamedTuple):
|
|
14
|
+
"""Represents a crash dump file."""
|
|
15
|
+
name: str
|
|
16
|
+
path: Path
|
|
17
|
+
size: int
|
|
18
|
+
timestamp: datetime
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def mtime(self) -> datetime:
|
|
22
|
+
"""Get modification time (alias for timestamp)."""
|
|
23
|
+
return self.timestamp
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
"""Convert crash dump to dictionary."""
|
|
27
|
+
return {
|
|
28
|
+
"name": self.name,
|
|
29
|
+
"path": str(self.path),
|
|
30
|
+
"size": self.size,
|
|
31
|
+
"size_mb": round(self.size / (1024 * 1024), 2),
|
|
32
|
+
"timestamp": self.timestamp.isoformat(),
|
|
33
|
+
"mtime": self.mtime.isoformat(),
|
|
34
|
+
"readable": os.access(self.path, os.R_OK)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CrashDumpDiscovery:
|
|
39
|
+
"""Discovers crash dump files in the system."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, crash_dump_path: str):
|
|
42
|
+
self.crash_dump_path = Path(crash_dump_path)
|
|
43
|
+
self.dump_patterns = [
|
|
44
|
+
"vmcore*",
|
|
45
|
+
"core*",
|
|
46
|
+
"crash*",
|
|
47
|
+
"dump*"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
def find_crash_dumps(self, max_dumps: int = 10) -> List[CrashDump]:
|
|
51
|
+
"""Find crash dump files in the system."""
|
|
52
|
+
dumps = []
|
|
53
|
+
|
|
54
|
+
if not self.crash_dump_path.exists():
|
|
55
|
+
logger.warning(f"Crash dump path does not exist: {self.crash_dump_path}")
|
|
56
|
+
return dumps
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
# Search in main directory and subdirectories
|
|
60
|
+
for root, dirs, files in os.walk(self.crash_dump_path):
|
|
61
|
+
root_path = Path(root)
|
|
62
|
+
|
|
63
|
+
for file in files:
|
|
64
|
+
file_path = root_path / file
|
|
65
|
+
|
|
66
|
+
# Check if file matches dump patterns
|
|
67
|
+
if any(file_path.match(pattern) for pattern in self.dump_patterns):
|
|
68
|
+
try:
|
|
69
|
+
stat = file_path.stat()
|
|
70
|
+
dump = CrashDump(
|
|
71
|
+
name=file,
|
|
72
|
+
path=file_path,
|
|
73
|
+
size=stat.st_size,
|
|
74
|
+
timestamp=datetime.fromtimestamp(stat.st_mtime)
|
|
75
|
+
)
|
|
76
|
+
dumps.append(dump)
|
|
77
|
+
except (OSError, PermissionError) as e:
|
|
78
|
+
logger.warning(f"Cannot access dump file {file_path}: {e}")
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Limit search depth to avoid excessive recursion
|
|
82
|
+
if len(str(root_path).split(os.sep)) - len(str(self.crash_dump_path).split(os.sep)) > 2:
|
|
83
|
+
dirs.clear()
|
|
84
|
+
|
|
85
|
+
except PermissionError as e:
|
|
86
|
+
logger.error(f"Permission denied accessing crash dump directory: {e}")
|
|
87
|
+
|
|
88
|
+
# Sort by timestamp (newest first) and limit results
|
|
89
|
+
dumps.sort(key=lambda x: x.timestamp, reverse=True)
|
|
90
|
+
return dumps[:max_dumps]
|
|
91
|
+
|
|
92
|
+
def get_dump_info(self, dump: CrashDump) -> dict:
|
|
93
|
+
"""Get detailed information about a crash dump."""
|
|
94
|
+
return {
|
|
95
|
+
"name": dump.name,
|
|
96
|
+
"path": str(dump.path),
|
|
97
|
+
"size": dump.size,
|
|
98
|
+
"size_mb": round(dump.size / (1024 * 1024), 2),
|
|
99
|
+
"timestamp": dump.timestamp.isoformat(),
|
|
100
|
+
"readable": os.access(dump.path, os.R_OK)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def get_latest_crash_dump(self) -> Optional[CrashDump]:
|
|
104
|
+
"""Get the most recent crash dump."""
|
|
105
|
+
dumps = self.find_crash_dumps(max_dumps=1)
|
|
106
|
+
return dumps[0] if dumps else None
|
|
107
|
+
|
|
108
|
+
def get_crash_dump_by_name(self, name: str) -> Optional[CrashDump]:
|
|
109
|
+
"""Get a crash dump by name."""
|
|
110
|
+
dumps = self.find_crash_dumps()
|
|
111
|
+
for dump in dumps:
|
|
112
|
+
if dump.name == name:
|
|
113
|
+
return dump
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def is_valid_crash_dump(self, dump) -> bool:
|
|
117
|
+
"""Check if a file is a valid crash dump."""
|
|
118
|
+
try:
|
|
119
|
+
# Handle both string paths and CrashDump objects
|
|
120
|
+
if isinstance(dump, CrashDump):
|
|
121
|
+
path = dump.path
|
|
122
|
+
filename = dump.name
|
|
123
|
+
else:
|
|
124
|
+
path = Path(dump)
|
|
125
|
+
filename = path.name
|
|
126
|
+
|
|
127
|
+
if not path.exists() or not path.is_file():
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
# Check if filename matches dump patterns
|
|
131
|
+
return any(Path(filename).match(pattern) for pattern in self.dump_patterns)
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.warning(f"Error validating crash dump {getattr(dump, 'name', str(dump))}: {e}")
|
|
134
|
+
return False
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Crash session management."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pexpect
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrashSession:
|
|
14
|
+
"""Represents an active crash analysis session."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, dump_path: str, kernel_path: str):
|
|
17
|
+
self.dump_path = dump_path
|
|
18
|
+
self.kernel_path = kernel_path
|
|
19
|
+
self.process = None
|
|
20
|
+
self.session_id = f"crash_{int(time.time())}"
|
|
21
|
+
self.active = False
|
|
22
|
+
self.prompt_patterns = [
|
|
23
|
+
r'crash> ', # Standard prompt with space
|
|
24
|
+
r'crash>', # Prompt without space
|
|
25
|
+
r'crash>\s*', # Prompt with optional whitespace
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
def is_active(self) -> bool:
|
|
29
|
+
"""Check if the session is active."""
|
|
30
|
+
return self.active
|
|
31
|
+
|
|
32
|
+
def start(self, timeout: int = 180) -> bool:
|
|
33
|
+
"""Start the crash session.
|
|
34
|
+
|
|
35
|
+
Detects kernel file type and uses appropriate crash command format:
|
|
36
|
+
- For debug symbols (vmlinux): crash --no_scroll vmcore vmlinux
|
|
37
|
+
- For compressed kernels (vmlinuz): crash --no_scroll -f vmlinuz vmcore
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
# Detect kernel file type and build appropriate command
|
|
41
|
+
kernel_name = self.kernel_path.split('/')[-1]
|
|
42
|
+
is_debug_symbol = kernel_name == "vmlinux" or kernel_name.startswith("vmlinux-")
|
|
43
|
+
|
|
44
|
+
if is_debug_symbol:
|
|
45
|
+
# Debug symbols: crash --no_scroll vmcore vmlinux (no -f flag needed)
|
|
46
|
+
cmd_parts = ['crash', '--no_scroll', self.dump_path, self.kernel_path]
|
|
47
|
+
else:
|
|
48
|
+
# Compressed kernel: crash --no_scroll -f vmlinuz vmcore
|
|
49
|
+
cmd_parts = ['crash', '--no_scroll', '-f', self.kernel_path, self.dump_path]
|
|
50
|
+
|
|
51
|
+
cmd = ' '.join(cmd_parts)
|
|
52
|
+
|
|
53
|
+
logger.info(f"Starting crash process: {cmd}")
|
|
54
|
+
|
|
55
|
+
# Start crash process
|
|
56
|
+
self.process = pexpect.spawn(cmd, timeout=timeout)
|
|
57
|
+
|
|
58
|
+
# Wait for initial prompt
|
|
59
|
+
expect_list = self.prompt_patterns + ['crash: .*', pexpect.TIMEOUT, pexpect.EOF]
|
|
60
|
+
|
|
61
|
+
index = self.process.expect(expect_list, timeout=timeout)
|
|
62
|
+
|
|
63
|
+
if index < len(self.prompt_patterns):
|
|
64
|
+
logger.info(f"Crash session started successfully: {self.session_id}")
|
|
65
|
+
self.active = True
|
|
66
|
+
return True
|
|
67
|
+
elif index == len(self.prompt_patterns):
|
|
68
|
+
# Error pattern
|
|
69
|
+
error_msg = self.process.after.decode('utf-8', errors='ignore')
|
|
70
|
+
logger.error(f"Crash startup error: {error_msg}")
|
|
71
|
+
return False
|
|
72
|
+
elif index == len(self.prompt_patterns) + 1:
|
|
73
|
+
# Timeout
|
|
74
|
+
logger.error(f"Crash startup timed out after {timeout} seconds")
|
|
75
|
+
return False
|
|
76
|
+
else:
|
|
77
|
+
# EOF
|
|
78
|
+
logger.error("Crash process terminated during startup")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error(f"Failed to start crash session: {e}")
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def execute_command(self, command: str, timeout: int = 120) -> Tuple[str, str, int]:
|
|
86
|
+
"""Execute a command in the crash session."""
|
|
87
|
+
if not self.is_active() or not self.process:
|
|
88
|
+
return "", "Session not active", 1
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
logger.info(f"Executing crash command: {command}")
|
|
92
|
+
|
|
93
|
+
# Send command to crash process
|
|
94
|
+
self.process.sendline(command)
|
|
95
|
+
|
|
96
|
+
# Wait for prompt to return
|
|
97
|
+
expect_list = self.prompt_patterns + ['crash: .*', pexpect.TIMEOUT, pexpect.EOF]
|
|
98
|
+
|
|
99
|
+
index = self.process.expect(expect_list, timeout=timeout)
|
|
100
|
+
|
|
101
|
+
if index < len(self.prompt_patterns):
|
|
102
|
+
# Successfully got prompt back
|
|
103
|
+
output = self.process.before.decode('utf-8', errors='ignore')
|
|
104
|
+
# Clean up the output by removing the command echo
|
|
105
|
+
lines = output.split('\n')
|
|
106
|
+
if lines and lines[0].strip() == command.strip():
|
|
107
|
+
output = '\n'.join(lines[1:])
|
|
108
|
+
return output.strip(), "", 0
|
|
109
|
+
elif index == len(self.prompt_patterns):
|
|
110
|
+
# Error pattern matched
|
|
111
|
+
error_msg = self.process.after.decode('utf-8', errors='ignore')
|
|
112
|
+
return "", f"Crash error: {error_msg}", 1
|
|
113
|
+
elif index == len(self.prompt_patterns) + 1:
|
|
114
|
+
# Timeout
|
|
115
|
+
return "", f"Command '{command}' timed out after {timeout} seconds", 1
|
|
116
|
+
else:
|
|
117
|
+
# EOF - crash process died
|
|
118
|
+
self.active = False
|
|
119
|
+
return "", "Crash process terminated unexpectedly", 1
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Error executing command '{command}': {e}")
|
|
123
|
+
return "", str(e), 1
|
|
124
|
+
|
|
125
|
+
def close(self):
|
|
126
|
+
"""Close the crash session."""
|
|
127
|
+
if self.process:
|
|
128
|
+
try:
|
|
129
|
+
# Try to quit gracefully first
|
|
130
|
+
if self.active:
|
|
131
|
+
try:
|
|
132
|
+
self.process.sendline('quit')
|
|
133
|
+
self.process.expect(pexpect.EOF, timeout=5)
|
|
134
|
+
except:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Force close if still alive
|
|
138
|
+
if self.process.isalive():
|
|
139
|
+
self.process.terminate()
|
|
140
|
+
if self.process.isalive():
|
|
141
|
+
self.process.kill()
|
|
142
|
+
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Error closing session: {e}")
|
|
145
|
+
finally:
|
|
146
|
+
self.process = None
|
|
147
|
+
self.active = False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CrashSessionManager:
|
|
151
|
+
"""Manages crash analysis sessions."""
|
|
152
|
+
|
|
153
|
+
def __init__(self):
|
|
154
|
+
self.active_session: Optional[CrashSession] = None
|
|
155
|
+
|
|
156
|
+
def start_session(self, crash_dump, kernel_file, timeout: int = 180) -> bool:
|
|
157
|
+
"""Start a new crash analysis session."""
|
|
158
|
+
# Close existing session if any
|
|
159
|
+
if self.active_session:
|
|
160
|
+
self.close_session()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
logger.info(f"Starting crash session with dump: {crash_dump.name}, kernel: {kernel_file.name}")
|
|
164
|
+
|
|
165
|
+
# Create new session
|
|
166
|
+
session = CrashSession(str(crash_dump.path), str(kernel_file.path))
|
|
167
|
+
|
|
168
|
+
# Actually start the crash process
|
|
169
|
+
if session.start(timeout):
|
|
170
|
+
self.active_session = session
|
|
171
|
+
logger.info(f"Crash session started successfully: {session.session_id}")
|
|
172
|
+
return True
|
|
173
|
+
else:
|
|
174
|
+
logger.error("Failed to start crash process")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f"Failed to start crash session: {e}")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def execute_command(self, command: str, timeout: int = 120) -> Tuple[str, str, int]:
|
|
182
|
+
"""Execute a command in the active session."""
|
|
183
|
+
if not self.active_session:
|
|
184
|
+
return "", "No active crash session", 1
|
|
185
|
+
|
|
186
|
+
return self.active_session.execute_command(command, timeout)
|
|
187
|
+
|
|
188
|
+
def is_session_active(self) -> bool:
|
|
189
|
+
"""Check if there's an active session."""
|
|
190
|
+
return self.active_session is not None and self.active_session.is_active()
|
|
191
|
+
|
|
192
|
+
def get_session_info(self) -> dict:
|
|
193
|
+
"""Get information about the active session."""
|
|
194
|
+
if not self.active_session:
|
|
195
|
+
return {"active": False}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"active": True,
|
|
199
|
+
"session_id": self.active_session.session_id,
|
|
200
|
+
"dump_path": self.active_session.dump_path,
|
|
201
|
+
"kernel_path": self.active_session.kernel_path
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def close_session(self):
|
|
205
|
+
"""Close the active session."""
|
|
206
|
+
if self.active_session:
|
|
207
|
+
logger.info(f"Closing crash session: {self.active_session.session_id}")
|
|
208
|
+
self.active_session.close()
|
|
209
|
+
self.active_session = None
|