@rishibhushan/jenkins-mcp-server 1.0.6 → 1.1.0
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.
- package/README.md +584 -18
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/jenkins_mcp_server/__init__.py +19 -11
- package/src/jenkins_mcp_server/cache.py +310 -0
- package/src/jenkins_mcp_server/config.py +54 -1
- package/src/jenkins_mcp_server/jenkins_client.py +69 -80
- package/src/jenkins_mcp_server/metrics.py +358 -0
- package/src/jenkins_mcp_server/server.py +1015 -108
|
@@ -14,19 +14,27 @@ from . import server
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def setup_logging(verbose: bool = False) -> None:
|
|
17
|
-
"""Configure logging
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
"""Configure structured logging"""
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
# Configure structlog
|
|
21
|
+
structlog.configure(
|
|
22
|
+
processors=[
|
|
23
|
+
structlog.stdlib.filter_by_level,
|
|
24
|
+
structlog.stdlib.add_logger_name,
|
|
25
|
+
structlog.stdlib.add_log_level,
|
|
26
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
27
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
28
|
+
structlog.processors.StackInfoRenderer(),
|
|
29
|
+
structlog.processors.format_exc_info,
|
|
30
|
+
structlog.processors.UnicodeDecoder(),
|
|
31
|
+
structlog.processors.JSONRenderer() if not verbose else structlog.dev.ConsoleRenderer()
|
|
32
|
+
],
|
|
33
|
+
context_class=dict,
|
|
34
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
35
|
+
cache_logger_on_first_use=True,
|
|
24
36
|
)
|
|
25
37
|
|
|
26
|
-
# Reduce noise from libraries
|
|
27
|
-
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
|
28
|
-
logging.getLogger('requests').setLevel(logging.WARNING)
|
|
29
|
-
|
|
30
38
|
|
|
31
39
|
def main():
|
|
32
40
|
"""
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Caching Module for Jenkins MCP Server
|
|
3
|
+
|
|
4
|
+
Provides TTL-based caching for frequently accessed Jenkins data
|
|
5
|
+
to reduce API calls and improve performance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
T = TypeVar('T')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CachedData:
|
|
22
|
+
"""Container for cached data with expiration"""
|
|
23
|
+
data: Any
|
|
24
|
+
cached_at: float
|
|
25
|
+
ttl_seconds: int
|
|
26
|
+
key: str
|
|
27
|
+
|
|
28
|
+
def is_expired(self) -> bool:
|
|
29
|
+
"""Check if cached data has expired"""
|
|
30
|
+
age = time.time() - self.cached_at
|
|
31
|
+
return age >= self.ttl_seconds
|
|
32
|
+
|
|
33
|
+
def age_seconds(self) -> float:
|
|
34
|
+
"""Get age of cached data in seconds"""
|
|
35
|
+
return time.time() - self.cached_at
|
|
36
|
+
|
|
37
|
+
def time_until_expiry(self) -> float:
|
|
38
|
+
"""Get seconds until expiry (negative if expired)"""
|
|
39
|
+
return self.ttl_seconds - self.age_seconds()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CacheManager:
|
|
43
|
+
"""
|
|
44
|
+
Thread-safe cache manager with TTL support.
|
|
45
|
+
|
|
46
|
+
Features:
|
|
47
|
+
- TTL-based expiration
|
|
48
|
+
- Thread-safe operations with async locks
|
|
49
|
+
- Cache statistics
|
|
50
|
+
- Automatic cleanup of expired entries
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
"""Initialize cache manager"""
|
|
55
|
+
self._cache: Dict[str, CachedData] = {}
|
|
56
|
+
self._lock = asyncio.Lock()
|
|
57
|
+
|
|
58
|
+
# Statistics
|
|
59
|
+
self._hits = 0
|
|
60
|
+
self._misses = 0
|
|
61
|
+
self._evictions = 0
|
|
62
|
+
|
|
63
|
+
logger.info("Cache manager initialized")
|
|
64
|
+
|
|
65
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
66
|
+
"""
|
|
67
|
+
Get cached data if available and not expired.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
key: Cache key
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Cached data if available and valid, None otherwise
|
|
74
|
+
"""
|
|
75
|
+
async with self._lock:
|
|
76
|
+
if key not in self._cache:
|
|
77
|
+
self._misses += 1
|
|
78
|
+
logger.debug(f"Cache miss: {key}")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
cached = self._cache[key]
|
|
82
|
+
|
|
83
|
+
if cached.is_expired():
|
|
84
|
+
# Remove expired entry
|
|
85
|
+
del self._cache[key]
|
|
86
|
+
self._evictions += 1
|
|
87
|
+
self._misses += 1
|
|
88
|
+
logger.debug(f"Cache expired: {key} (age: {cached.age_seconds():.1f}s)")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
self._hits += 1
|
|
92
|
+
logger.debug(f"Cache hit: {key} (age: {cached.age_seconds():.1f}s, ttl: {cached.ttl_seconds}s)")
|
|
93
|
+
return cached.data
|
|
94
|
+
|
|
95
|
+
async def set(self, key: str, data: Any, ttl_seconds: int = 30) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Store data in cache with TTL.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
key: Cache key
|
|
101
|
+
data: Data to cache
|
|
102
|
+
ttl_seconds: Time-to-live in seconds
|
|
103
|
+
"""
|
|
104
|
+
async with self._lock:
|
|
105
|
+
self._cache[key] = CachedData(
|
|
106
|
+
data=data,
|
|
107
|
+
cached_at=time.time(),
|
|
108
|
+
ttl_seconds=ttl_seconds,
|
|
109
|
+
key=key
|
|
110
|
+
)
|
|
111
|
+
logger.debug(f"Cached: {key} (ttl: {ttl_seconds}s)")
|
|
112
|
+
|
|
113
|
+
async def invalidate(self, key: str) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Invalidate (remove) a specific cache entry.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
key: Cache key to invalidate
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if key was in cache, False otherwise
|
|
122
|
+
"""
|
|
123
|
+
async with self._lock:
|
|
124
|
+
if key in self._cache:
|
|
125
|
+
del self._cache[key]
|
|
126
|
+
self._evictions += 1
|
|
127
|
+
logger.debug(f"Invalidated: {key}")
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
async def invalidate_pattern(self, pattern: str) -> int:
|
|
132
|
+
"""
|
|
133
|
+
Invalidate all cache entries matching a pattern.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
pattern: String pattern to match (substring match)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Number of entries invalidated
|
|
140
|
+
"""
|
|
141
|
+
async with self._lock:
|
|
142
|
+
keys_to_remove = [
|
|
143
|
+
key for key in self._cache.keys()
|
|
144
|
+
if pattern in key
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
for key in keys_to_remove:
|
|
148
|
+
del self._cache[key]
|
|
149
|
+
self._evictions += 1
|
|
150
|
+
|
|
151
|
+
if keys_to_remove:
|
|
152
|
+
logger.debug(f"Invalidated {len(keys_to_remove)} entries matching '{pattern}'")
|
|
153
|
+
|
|
154
|
+
return len(keys_to_remove)
|
|
155
|
+
|
|
156
|
+
async def clear(self) -> int:
|
|
157
|
+
"""
|
|
158
|
+
Clear all cached data.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Number of entries cleared
|
|
162
|
+
"""
|
|
163
|
+
async with self._lock:
|
|
164
|
+
count = len(self._cache)
|
|
165
|
+
self._cache.clear()
|
|
166
|
+
self._evictions += count
|
|
167
|
+
logger.info(f"Cache cleared: {count} entries removed")
|
|
168
|
+
return count
|
|
169
|
+
|
|
170
|
+
async def cleanup_expired(self) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Remove all expired entries from cache.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Number of expired entries removed
|
|
176
|
+
"""
|
|
177
|
+
async with self._lock:
|
|
178
|
+
expired_keys = [
|
|
179
|
+
key for key, cached in self._cache.items()
|
|
180
|
+
if cached.is_expired()
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
for key in expired_keys:
|
|
184
|
+
del self._cache[key]
|
|
185
|
+
self._evictions += 1
|
|
186
|
+
|
|
187
|
+
if expired_keys:
|
|
188
|
+
logger.debug(f"Cleaned up {len(expired_keys)} expired entries")
|
|
189
|
+
|
|
190
|
+
return len(expired_keys)
|
|
191
|
+
|
|
192
|
+
async def get_or_fetch(
|
|
193
|
+
self,
|
|
194
|
+
key: str,
|
|
195
|
+
fetch_func: Callable[[], Any],
|
|
196
|
+
ttl_seconds: int = 30
|
|
197
|
+
) -> Any:
|
|
198
|
+
"""
|
|
199
|
+
Get cached data or fetch and cache if not available.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
key: Cache key
|
|
203
|
+
fetch_func: Function to call to fetch data if not cached
|
|
204
|
+
ttl_seconds: TTL for newly cached data
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Cached or freshly fetched data
|
|
208
|
+
"""
|
|
209
|
+
# Try cache first
|
|
210
|
+
cached_data = await self.get(key)
|
|
211
|
+
if cached_data is not None:
|
|
212
|
+
return cached_data
|
|
213
|
+
|
|
214
|
+
# Fetch and cache
|
|
215
|
+
data = fetch_func()
|
|
216
|
+
await self.set(key, data, ttl_seconds)
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
220
|
+
"""
|
|
221
|
+
Get cache statistics.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Dictionary with cache statistics
|
|
225
|
+
"""
|
|
226
|
+
total_requests = self._hits + self._misses
|
|
227
|
+
hit_rate = (self._hits / total_requests * 100) if total_requests > 0 else 0
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"size": len(self._cache),
|
|
231
|
+
"hits": self._hits,
|
|
232
|
+
"misses": self._misses,
|
|
233
|
+
"evictions": self._evictions,
|
|
234
|
+
"total_requests": total_requests,
|
|
235
|
+
"hit_rate_percent": round(hit_rate, 2)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def reset_stats(self) -> None:
|
|
239
|
+
"""Reset cache statistics"""
|
|
240
|
+
self._hits = 0
|
|
241
|
+
self._misses = 0
|
|
242
|
+
self._evictions = 0
|
|
243
|
+
logger.debug("Cache statistics reset")
|
|
244
|
+
|
|
245
|
+
async def get_all_keys(self) -> list[str]:
|
|
246
|
+
"""Get all cache keys"""
|
|
247
|
+
async with self._lock:
|
|
248
|
+
return list(self._cache.keys())
|
|
249
|
+
|
|
250
|
+
async def get_cache_info(self) -> Dict[str, Any]:
|
|
251
|
+
"""
|
|
252
|
+
Get detailed cache information.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Dictionary with cache details including per-entry info
|
|
256
|
+
"""
|
|
257
|
+
async with self._lock:
|
|
258
|
+
entries = []
|
|
259
|
+
for key, cached in self._cache.items():
|
|
260
|
+
entries.append({
|
|
261
|
+
"key": key,
|
|
262
|
+
"age_seconds": round(cached.age_seconds(), 2),
|
|
263
|
+
"ttl_seconds": cached.ttl_seconds,
|
|
264
|
+
"expires_in_seconds": round(cached.time_until_expiry(), 2),
|
|
265
|
+
"is_expired": cached.is_expired(),
|
|
266
|
+
"cached_at": datetime.fromtimestamp(cached.cached_at).isoformat()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"stats": self.get_stats(),
|
|
271
|
+
"entries": entries
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Global cache manager instance
|
|
276
|
+
_cache_manager: Optional[CacheManager] = None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_cache_manager() -> CacheManager:
|
|
280
|
+
"""Get or create the global cache manager instance"""
|
|
281
|
+
global _cache_manager
|
|
282
|
+
if _cache_manager is None:
|
|
283
|
+
_cache_manager = CacheManager()
|
|
284
|
+
return _cache_manager
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# Convenience functions
|
|
288
|
+
async def get_cached(key: str) -> Optional[Any]:
|
|
289
|
+
"""Get cached data"""
|
|
290
|
+
return await get_cache_manager().get(key)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def set_cached(key: str, data: Any, ttl_seconds: int = 30) -> None:
|
|
294
|
+
"""Set cached data"""
|
|
295
|
+
await get_cache_manager().set(key, data, ttl_seconds)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def invalidate_cache(key: str) -> bool:
|
|
299
|
+
"""Invalidate cache entry"""
|
|
300
|
+
return await get_cache_manager().invalidate(key)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def clear_cache() -> int:
|
|
304
|
+
"""Clear all cache"""
|
|
305
|
+
return await get_cache_manager().clear()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def get_cache_stats() -> Dict[str, Any]:
|
|
309
|
+
"""Get cache statistics"""
|
|
310
|
+
return get_cache_manager().get_stats()
|
|
@@ -5,6 +5,8 @@ Handles loading Jenkins connection settings from multiple sources:
|
|
|
5
5
|
1. VS Code settings.json (highest priority)
|
|
6
6
|
2. Environment variables / .env file
|
|
7
7
|
3. Direct instantiation with parameters
|
|
8
|
+
|
|
9
|
+
Enhanced with timeout configuration support.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
import json
|
|
@@ -50,6 +52,50 @@ class JenkinsSettings(BaseSettings):
|
|
|
50
52
|
description="Jenkins API token (preferred over password)"
|
|
51
53
|
)
|
|
52
54
|
|
|
55
|
+
# Timeout settings (High Priority Issue #4)
|
|
56
|
+
timeout: int = Field(
|
|
57
|
+
default=30,
|
|
58
|
+
description="Default timeout for Jenkins API calls in seconds",
|
|
59
|
+
ge=5,
|
|
60
|
+
le=300
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
connect_timeout: int = Field(
|
|
64
|
+
default=10,
|
|
65
|
+
description="Connection timeout in seconds",
|
|
66
|
+
ge=2,
|
|
67
|
+
le=60
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
read_timeout: int = Field(
|
|
71
|
+
default=30,
|
|
72
|
+
description="Read timeout for API responses in seconds",
|
|
73
|
+
ge=5,
|
|
74
|
+
le=300
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Retry settings
|
|
78
|
+
max_retries: int = Field(
|
|
79
|
+
default=3,
|
|
80
|
+
description="Maximum number of retry attempts for failed requests",
|
|
81
|
+
ge=0,
|
|
82
|
+
le=10
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Console output settings (High Priority Issue #5)
|
|
86
|
+
console_max_lines: int = Field(
|
|
87
|
+
default=1000,
|
|
88
|
+
description="Default maximum lines to return from console output",
|
|
89
|
+
ge=10,
|
|
90
|
+
le=50000
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# SSL verification
|
|
94
|
+
verify_ssl: bool = Field(
|
|
95
|
+
default=True,
|
|
96
|
+
description="Whether to verify SSL certificates"
|
|
97
|
+
)
|
|
98
|
+
|
|
53
99
|
model_config = SettingsConfigDict(
|
|
54
100
|
env_prefix="JENKINS_",
|
|
55
101
|
env_file_encoding="utf-8",
|
|
@@ -92,6 +138,12 @@ class JenkinsSettings(BaseSettings):
|
|
|
92
138
|
logger.info("Jenkins Configuration:")
|
|
93
139
|
logger.info(f" URL: {self.url or 'Not configured'}")
|
|
94
140
|
logger.info(f" Username: {self.username or 'Not configured'}")
|
|
141
|
+
logger.info(f" Timeout: {self.timeout}s")
|
|
142
|
+
logger.info(f" Connect Timeout: {self.connect_timeout}s")
|
|
143
|
+
logger.info(f" Read Timeout: {self.read_timeout}s")
|
|
144
|
+
logger.info(f" Max Retries: {self.max_retries}")
|
|
145
|
+
logger.info(f" Console Max Lines: {self.console_max_lines}")
|
|
146
|
+
logger.info(f" Verify SSL: {self.verify_ssl}")
|
|
95
147
|
|
|
96
148
|
if hide_sensitive:
|
|
97
149
|
logger.info(f" Authentication: {self.auth_method}")
|
|
@@ -244,7 +296,8 @@ def load_settings(
|
|
|
244
296
|
print(f"=== VS Code settings loaded: {vscode_settings is not None} ===", file=sys.stderr, flush=True)
|
|
245
297
|
if vscode_settings:
|
|
246
298
|
# Merge VS Code settings into our settings object
|
|
247
|
-
for key in ['url', 'username', 'password', 'token'
|
|
299
|
+
for key in ['url', 'username', 'password', 'token', 'timeout', 'connect_timeout',
|
|
300
|
+
'read_timeout', 'max_retries', 'console_max_lines', 'verify_ssl']:
|
|
248
301
|
vscode_value = vscode_settings.get(key)
|
|
249
302
|
if vscode_value is not None:
|
|
250
303
|
setattr(settings, key, vscode_value)
|
|
@@ -3,6 +3,8 @@ Jenkins MCP Server Client Module
|
|
|
3
3
|
|
|
4
4
|
Provides a clean interface to Jenkins API operations with automatic fallback
|
|
5
5
|
between python-jenkins library and direct REST API calls.
|
|
6
|
+
|
|
7
|
+
Enhanced with configurable timeout support.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
import logging
|
|
@@ -34,7 +36,8 @@ class JenkinsClient:
|
|
|
34
36
|
Client for interacting with Jenkins API.
|
|
35
37
|
|
|
36
38
|
Supports both python-jenkins library and direct REST API calls
|
|
37
|
-
with automatic fallback for reliability.
|
|
39
|
+
with automatic fallback for reliability. Enhanced with configurable
|
|
40
|
+
timeout support.
|
|
38
41
|
"""
|
|
39
42
|
|
|
40
43
|
def __init__(self, settings: Optional[JenkinsSettings] = None):
|
|
@@ -60,6 +63,12 @@ class JenkinsClient:
|
|
|
60
63
|
username, auth_value = self.settings.get_credentials()
|
|
61
64
|
self.auth = HTTPBasicAuth(username, auth_value)
|
|
62
65
|
|
|
66
|
+
# Store timeout settings (High Priority Issue #4)
|
|
67
|
+
self.timeout = self.settings.timeout
|
|
68
|
+
self.connect_timeout = self.settings.connect_timeout
|
|
69
|
+
self.read_timeout = self.settings.read_timeout
|
|
70
|
+
self.verify_ssl = self.settings.verify_ssl
|
|
71
|
+
|
|
63
72
|
# Cache for python-jenkins server instance
|
|
64
73
|
self._server: Optional[jenkins.Jenkins] = None
|
|
65
74
|
|
|
@@ -67,14 +76,14 @@ class JenkinsClient:
|
|
|
67
76
|
self._test_connection()
|
|
68
77
|
|
|
69
78
|
def _test_connection(self) -> None:
|
|
70
|
-
"""Test connection to Jenkins server (with
|
|
79
|
+
"""Test connection to Jenkins server (with configurable timeout)"""
|
|
71
80
|
try:
|
|
72
|
-
# Quick connection test with
|
|
81
|
+
# Quick connection test with configured timeout for MCP compatibility
|
|
73
82
|
response = requests.get(
|
|
74
83
|
f"{self.base_url}/api/json",
|
|
75
84
|
auth=self.auth,
|
|
76
|
-
verify=False,
|
|
77
|
-
timeout=
|
|
85
|
+
verify=self.verify_ssl if self.verify_ssl else False,
|
|
86
|
+
timeout=self.connect_timeout # Use configured connect timeout
|
|
78
87
|
)
|
|
79
88
|
response.raise_for_status()
|
|
80
89
|
|
|
@@ -83,7 +92,7 @@ class JenkinsClient:
|
|
|
83
92
|
logger.debug(f"Jenkins version: {data.get('_class', 'unknown')}")
|
|
84
93
|
|
|
85
94
|
except requests.Timeout:
|
|
86
|
-
logger.warning(f"Connection to Jenkins timed out (server may be slow)")
|
|
95
|
+
logger.warning(f"Connection to Jenkins timed out after {self.connect_timeout}s (server may be slow)")
|
|
87
96
|
# Don't fail - let actual operations fail if there's a real problem
|
|
88
97
|
except requests.RequestException as e:
|
|
89
98
|
logger.warning(f"Could not verify Jenkins connection: {e}")
|
|
@@ -91,19 +100,20 @@ class JenkinsClient:
|
|
|
91
100
|
|
|
92
101
|
@property
|
|
93
102
|
def server(self) -> jenkins.Jenkins:
|
|
94
|
-
"""Get or create python-jenkins server instance (lazy initialization)"""
|
|
103
|
+
"""Get or create python-jenkins server instance (lazy initialization with timeout)"""
|
|
95
104
|
if self._server is None:
|
|
96
105
|
username, password = self.settings.get_credentials()
|
|
97
106
|
self._server = jenkins.Jenkins(
|
|
98
107
|
self.base_url,
|
|
99
108
|
username=username,
|
|
100
|
-
password=password
|
|
109
|
+
password=password,
|
|
110
|
+
timeout=self.timeout # Use configured timeout
|
|
101
111
|
)
|
|
102
112
|
return self._server
|
|
103
113
|
|
|
104
114
|
def _api_call(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
105
115
|
"""
|
|
106
|
-
Make a direct REST API call to Jenkins.
|
|
116
|
+
Make a direct REST API call to Jenkins with configured timeout.
|
|
107
117
|
|
|
108
118
|
Args:
|
|
109
119
|
method: HTTP method (GET, POST, etc.)
|
|
@@ -115,8 +125,11 @@ class JenkinsClient:
|
|
|
115
125
|
"""
|
|
116
126
|
url = f"{self.base_url}{endpoint}"
|
|
117
127
|
kwargs.setdefault('auth', self.auth)
|
|
118
|
-
kwargs.setdefault('verify', False)
|
|
119
|
-
|
|
128
|
+
kwargs.setdefault('verify', self.verify_ssl if self.verify_ssl else False)
|
|
129
|
+
|
|
130
|
+
# Use configured timeout (can be overridden per call)
|
|
131
|
+
if 'timeout' not in kwargs:
|
|
132
|
+
kwargs['timeout'] = (self.connect_timeout, self.read_timeout)
|
|
120
133
|
|
|
121
134
|
response = requests.request(method, url, **kwargs)
|
|
122
135
|
response.raise_for_status()
|
|
@@ -220,82 +233,46 @@ class JenkinsClient:
|
|
|
220
233
|
Returns:
|
|
221
234
|
Dict with 'queue_id' and 'build_number' (if wait_for_start=True)
|
|
222
235
|
"""
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
queue_id = self.server.build_job(job_name, parameters or {})
|
|
226
|
-
queue_id = int(queue_id) if queue_id else None
|
|
227
|
-
|
|
228
|
-
build_number = None
|
|
229
|
-
if wait_for_start and queue_id:
|
|
230
|
-
build_number = self._wait_for_build_start(
|
|
231
|
-
queue_id, timeout, poll_interval
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
return {"queue_id": queue_id, "build_number": build_number}
|
|
236
|
+
# Get the last build number before triggering
|
|
237
|
+
last_build_num = self.get_last_build_number(job_name) or 0
|
|
235
238
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
239
|
+
# Trigger the build
|
|
240
|
+
if parameters:
|
|
241
|
+
self._api_call(
|
|
242
|
+
'POST',
|
|
243
|
+
f'/job/{job_name}/buildWithParameters',
|
|
244
|
+
params=parameters
|
|
240
245
|
)
|
|
246
|
+
else:
|
|
247
|
+
self._api_call('POST', f'/job/{job_name}/build')
|
|
241
248
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
job_name
|
|
245
|
-
|
|
246
|
-
wait_for_start: bool,
|
|
247
|
-
timeout: int,
|
|
248
|
-
poll_interval: float
|
|
249
|
-
) -> Dict[str, Optional[int]]:
|
|
250
|
-
"""Build job using REST API (fallback method)"""
|
|
251
|
-
endpoint = f'/job/{job_name}/buildWithParameters' if parameters else f'/job/{job_name}/build'
|
|
252
|
-
|
|
253
|
-
response = self._api_call('POST', endpoint, params=parameters)
|
|
249
|
+
# Get queue ID from response
|
|
250
|
+
queue_id = self._extract_queue_id_from_location(
|
|
251
|
+
f'/job/{job_name}/build'
|
|
252
|
+
)
|
|
254
253
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
result = {
|
|
255
|
+
'queue_id': queue_id,
|
|
256
|
+
'build_number': None
|
|
257
|
+
}
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
if wait_for_start
|
|
261
|
-
|
|
259
|
+
# Optionally wait for build to start
|
|
260
|
+
if wait_for_start:
|
|
261
|
+
start_time = time.time()
|
|
262
|
+
while time.time() - start_time < timeout:
|
|
263
|
+
time.sleep(poll_interval)
|
|
262
264
|
|
|
263
|
-
|
|
265
|
+
# Check if a new build has started
|
|
266
|
+
current_build_num = self.get_last_build_number(job_name)
|
|
267
|
+
if current_build_num and current_build_num > last_build_num:
|
|
268
|
+
result['build_number'] = current_build_num
|
|
269
|
+
logger.info(f"Build {job_name} #{current_build_num} started")
|
|
270
|
+
break
|
|
264
271
|
|
|
265
|
-
|
|
266
|
-
self,
|
|
267
|
-
queue_id: int,
|
|
268
|
-
timeout: int,
|
|
269
|
-
poll_interval: float
|
|
270
|
-
) -> Optional[int]:
|
|
271
|
-
"""Wait for a queued build to start and return its build number"""
|
|
272
|
-
elapsed = 0.0
|
|
273
|
-
|
|
274
|
-
while elapsed < timeout:
|
|
275
|
-
try:
|
|
276
|
-
# Try python-jenkins first
|
|
277
|
-
item = self.server.get_queue_item(queue_id)
|
|
278
|
-
if item and item.get('executable'):
|
|
279
|
-
return int(item['executable']['number'])
|
|
280
|
-
except Exception:
|
|
281
|
-
# Fall back to REST API
|
|
282
|
-
try:
|
|
283
|
-
response = self._api_call('GET', f'/queue/item/{queue_id}/api/json')
|
|
284
|
-
item = response.json()
|
|
285
|
-
if item.get('executable'):
|
|
286
|
-
return int(item['executable']['number'])
|
|
287
|
-
except Exception:
|
|
288
|
-
pass
|
|
289
|
-
|
|
290
|
-
time.sleep(poll_interval)
|
|
291
|
-
elapsed += poll_interval
|
|
292
|
-
|
|
293
|
-
logger.warning(f"Timeout waiting for build {queue_id} to start")
|
|
294
|
-
return None
|
|
272
|
+
return result
|
|
295
273
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"""Extract queue ID from Jenkins Location header"""
|
|
274
|
+
def _extract_queue_id_from_location(self, location: str) -> Optional[int]:
|
|
275
|
+
"""Extract queue ID from Location header"""
|
|
299
276
|
if not location:
|
|
300
277
|
return None
|
|
301
278
|
|
|
@@ -480,6 +457,18 @@ class JenkinsClient:
|
|
|
480
457
|
response = self._api_call('GET', f'/computer/{node_name}/api/json')
|
|
481
458
|
return response.json()
|
|
482
459
|
|
|
460
|
+
# ==================== Additional Helper Methods ====================
|
|
461
|
+
|
|
462
|
+
def get_whoami(self) -> Dict[str, Any]:
|
|
463
|
+
"""Get information about the current authenticated user"""
|
|
464
|
+
response = self._api_call('GET', '/me/api/json')
|
|
465
|
+
return response.json()
|
|
466
|
+
|
|
467
|
+
def get_version(self) -> str:
|
|
468
|
+
"""Get Jenkins version"""
|
|
469
|
+
response = self._api_call('GET', '/api/json')
|
|
470
|
+
return response.headers.get('X-Jenkins', 'Unknown')
|
|
471
|
+
|
|
483
472
|
|
|
484
473
|
# ==================== Client Factory ====================
|
|
485
474
|
|
|
@@ -515,4 +504,4 @@ def get_jenkins_client(settings: Optional[JenkinsSettings] = None) -> JenkinsCli
|
|
|
515
504
|
def reset_default_client() -> None:
|
|
516
505
|
"""Reset the default client (useful for testing or reconfiguration)"""
|
|
517
506
|
global _default_client
|
|
518
|
-
_default_client = None
|
|
507
|
+
_default_client = None
|