hatch-xclam 0.7.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.
- hatch/__init__.py +21 -0
- hatch/cli_hatch.py +2748 -0
- hatch/environment_manager.py +1375 -0
- hatch/installers/__init__.py +25 -0
- hatch/installers/dependency_installation_orchestrator.py +636 -0
- hatch/installers/docker_installer.py +545 -0
- hatch/installers/hatch_installer.py +198 -0
- hatch/installers/installation_context.py +109 -0
- hatch/installers/installer_base.py +195 -0
- hatch/installers/python_installer.py +342 -0
- hatch/installers/registry.py +179 -0
- hatch/installers/system_installer.py +588 -0
- hatch/mcp_host_config/__init__.py +38 -0
- hatch/mcp_host_config/backup.py +458 -0
- hatch/mcp_host_config/host_management.py +572 -0
- hatch/mcp_host_config/models.py +602 -0
- hatch/mcp_host_config/reporting.py +181 -0
- hatch/mcp_host_config/strategies.py +513 -0
- hatch/package_loader.py +263 -0
- hatch/python_environment_manager.py +734 -0
- hatch/registry_explorer.py +171 -0
- hatch/registry_retriever.py +335 -0
- hatch/template_generator.py +179 -0
- hatch_xclam-0.7.0.dist-info/METADATA +150 -0
- hatch_xclam-0.7.0.dist-info/RECORD +93 -0
- hatch_xclam-0.7.0.dist-info/WHEEL +5 -0
- hatch_xclam-0.7.0.dist-info/entry_points.txt +2 -0
- hatch_xclam-0.7.0.dist-info/licenses/LICENSE +661 -0
- hatch_xclam-0.7.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/run_environment_tests.py +124 -0
- tests/test_cli_version.py +122 -0
- tests/test_data/packages/basic/base_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg/mcp_server.py +21 -0
- tests/test_data/packages/basic/base_pkg_v2/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/base_pkg_v2/mcp_server.py +21 -0
- tests/test_data/packages/basic/utility_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/basic/utility_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/complex_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/docker_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/mixed_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/python_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/python_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/simple_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/dependencies/system_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/dependencies/system_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/circular_dep_pkg_b/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/invalid_dep_pkg/mcp_server.py +21 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/error_scenarios/version_conflict_pkg/mcp_server.py +21 -0
- tests/test_data/packages/schema_versions/schema_v1_1_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_0_pkg/main.py +11 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/hatch_mcp_server.py +18 -0
- tests/test_data/packages/schema_versions/schema_v1_2_1_pkg/mcp_server.py +21 -0
- tests/test_data_utils.py +472 -0
- tests/test_dependency_orchestrator_consent.py +266 -0
- tests/test_docker_installer.py +524 -0
- tests/test_env_manip.py +991 -0
- tests/test_hatch_installer.py +179 -0
- tests/test_installer_base.py +221 -0
- tests/test_mcp_atomic_operations.py +276 -0
- tests/test_mcp_backup_integration.py +308 -0
- tests/test_mcp_cli_all_host_specific_args.py +303 -0
- tests/test_mcp_cli_backup_management.py +295 -0
- tests/test_mcp_cli_direct_management.py +453 -0
- tests/test_mcp_cli_discovery_listing.py +582 -0
- tests/test_mcp_cli_host_config_integration.py +823 -0
- tests/test_mcp_cli_package_management.py +360 -0
- tests/test_mcp_cli_partial_updates.py +859 -0
- tests/test_mcp_environment_integration.py +520 -0
- tests/test_mcp_host_config_backup.py +257 -0
- tests/test_mcp_host_configuration_manager.py +331 -0
- tests/test_mcp_host_registry_decorator.py +348 -0
- tests/test_mcp_pydantic_architecture_v4.py +603 -0
- tests/test_mcp_server_config_models.py +242 -0
- tests/test_mcp_server_config_type_field.py +221 -0
- tests/test_mcp_sync_functionality.py +316 -0
- tests/test_mcp_user_feedback_reporting.py +359 -0
- tests/test_non_tty_integration.py +281 -0
- tests/test_online_package_loader.py +202 -0
- tests/test_python_environment_manager.py +882 -0
- tests/test_python_installer.py +327 -0
- tests/test_registry.py +51 -0
- tests/test_registry_retriever.py +250 -0
- tests/test_system_installer.py +733 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""MCP host configuration backup system.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive backup and restore functionality for MCP
|
|
4
|
+
host configuration files with atomic operations and Pydantic data validation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional, Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field, validator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BackupError(Exception):
|
|
18
|
+
"""Exception raised when backup operations fail."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RestoreError(Exception):
|
|
23
|
+
"""Exception raised when restore operations fail."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BackupInfo(BaseModel):
|
|
28
|
+
"""Information about a backup file with validation."""
|
|
29
|
+
hostname: str = Field(..., description="Host identifier")
|
|
30
|
+
timestamp: datetime = Field(..., description="Backup creation timestamp")
|
|
31
|
+
file_path: Path = Field(..., description="Path to backup file")
|
|
32
|
+
file_size: int = Field(..., ge=0, description="Backup file size in bytes")
|
|
33
|
+
original_config_path: Path = Field(..., description="Original configuration file path")
|
|
34
|
+
|
|
35
|
+
@validator('hostname')
|
|
36
|
+
def validate_hostname(cls, v):
|
|
37
|
+
"""Validate hostname is supported."""
|
|
38
|
+
supported_hosts = {
|
|
39
|
+
'claude-desktop', 'claude-code', 'vscode',
|
|
40
|
+
'cursor', 'lmstudio', 'gemini'
|
|
41
|
+
}
|
|
42
|
+
if v not in supported_hosts:
|
|
43
|
+
raise ValueError(f"Unsupported hostname: {v}. Supported: {supported_hosts}")
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
@validator('file_path')
|
|
47
|
+
def validate_file_exists(cls, v):
|
|
48
|
+
"""Validate backup file exists."""
|
|
49
|
+
if not v.exists():
|
|
50
|
+
raise ValueError(f"Backup file does not exist: {v}")
|
|
51
|
+
return v
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def backup_name(self) -> str:
|
|
55
|
+
"""Get backup filename."""
|
|
56
|
+
return f"mcp.json.{self.hostname}.{self.timestamp.strftime('%Y%m%d_%H%M%S_%f')}"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def age_days(self) -> int:
|
|
60
|
+
"""Get backup age in days."""
|
|
61
|
+
return (datetime.now() - self.timestamp).days
|
|
62
|
+
|
|
63
|
+
class Config:
|
|
64
|
+
"""Pydantic configuration."""
|
|
65
|
+
arbitrary_types_allowed = True
|
|
66
|
+
json_encoders = {
|
|
67
|
+
Path: str,
|
|
68
|
+
datetime: lambda v: v.isoformat()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BackupResult(BaseModel):
|
|
73
|
+
"""Result of backup operation with validation."""
|
|
74
|
+
success: bool = Field(..., description="Operation success status")
|
|
75
|
+
backup_path: Optional[Path] = Field(None, description="Path to created backup")
|
|
76
|
+
error_message: Optional[str] = Field(None, description="Error message if failed")
|
|
77
|
+
original_size: int = Field(0, ge=0, description="Original file size in bytes")
|
|
78
|
+
backup_size: int = Field(0, ge=0, description="Backup file size in bytes")
|
|
79
|
+
|
|
80
|
+
@validator('backup_path')
|
|
81
|
+
def validate_backup_path_on_success(cls, v, values):
|
|
82
|
+
"""Validate backup_path is provided when success is True."""
|
|
83
|
+
if values.get('success') and v is None:
|
|
84
|
+
raise ValueError("backup_path must be provided when success is True")
|
|
85
|
+
return v
|
|
86
|
+
|
|
87
|
+
@validator('error_message')
|
|
88
|
+
def validate_error_message_on_failure(cls, v, values):
|
|
89
|
+
"""Validate error_message is provided when success is False."""
|
|
90
|
+
if not values.get('success') and not v:
|
|
91
|
+
raise ValueError("error_message must be provided when success is False")
|
|
92
|
+
return v
|
|
93
|
+
|
|
94
|
+
class Config:
|
|
95
|
+
"""Pydantic configuration."""
|
|
96
|
+
arbitrary_types_allowed = True
|
|
97
|
+
json_encoders = {
|
|
98
|
+
Path: str
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AtomicFileOperations:
|
|
103
|
+
"""Atomic file operations for safe configuration updates."""
|
|
104
|
+
|
|
105
|
+
def atomic_write_with_backup(self, file_path: Path, data: Dict[str, Any],
|
|
106
|
+
backup_manager: "MCPHostConfigBackupManager",
|
|
107
|
+
hostname: str, skip_backup: bool = False) -> bool:
|
|
108
|
+
"""Atomic write with automatic backup creation.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
file_path (Path): Target file path for writing
|
|
112
|
+
data (Dict[str, Any]): Data to write as JSON
|
|
113
|
+
backup_manager (MCPHostConfigBackupManager): Backup manager instance
|
|
114
|
+
hostname (str): Host identifier for backup
|
|
115
|
+
skip_backup (bool, optional): Skip backup creation. Defaults to False.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
bool: True if operation successful, False otherwise
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
BackupError: If backup creation fails and skip_backup is False
|
|
122
|
+
"""
|
|
123
|
+
# Create backup if file exists and backup not skipped
|
|
124
|
+
backup_result = None
|
|
125
|
+
if file_path.exists() and not skip_backup:
|
|
126
|
+
backup_result = backup_manager.create_backup(file_path, hostname)
|
|
127
|
+
if not backup_result.success:
|
|
128
|
+
raise BackupError(f"Required backup failed: {backup_result.error_message}")
|
|
129
|
+
|
|
130
|
+
# Create temporary file for atomic write
|
|
131
|
+
temp_file = None
|
|
132
|
+
try:
|
|
133
|
+
# Write to temporary file first
|
|
134
|
+
temp_file = file_path.with_suffix(f"{file_path.suffix}.tmp")
|
|
135
|
+
with open(temp_file, 'w', encoding='utf-8') as f:
|
|
136
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
137
|
+
|
|
138
|
+
# Atomic move to target location
|
|
139
|
+
temp_file.replace(file_path)
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
except Exception as e:
|
|
143
|
+
# Clean up temporary file on failure
|
|
144
|
+
if temp_file and temp_file.exists():
|
|
145
|
+
temp_file.unlink()
|
|
146
|
+
|
|
147
|
+
# Restore from backup if available
|
|
148
|
+
if backup_result and backup_result.backup_path:
|
|
149
|
+
try:
|
|
150
|
+
backup_manager.restore_backup(hostname, backup_result.backup_path.name)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass # Log but don't raise - original error is more important
|
|
153
|
+
|
|
154
|
+
raise BackupError(f"Atomic write failed: {str(e)}")
|
|
155
|
+
|
|
156
|
+
def atomic_copy(self, source: Path, target: Path) -> bool:
|
|
157
|
+
"""Atomic file copy operation.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
source (Path): Source file path
|
|
161
|
+
target (Path): Target file path
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
bool: True if copy successful, False otherwise
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
# Create temporary target file
|
|
168
|
+
temp_target = target.with_suffix(f"{target.suffix}.tmp")
|
|
169
|
+
|
|
170
|
+
# Copy to temporary location
|
|
171
|
+
shutil.copy2(source, temp_target)
|
|
172
|
+
|
|
173
|
+
# Atomic move to final location
|
|
174
|
+
temp_target.replace(target)
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
except Exception:
|
|
178
|
+
# Clean up temporary file on failure
|
|
179
|
+
temp_target = target.with_suffix(f"{target.suffix}.tmp")
|
|
180
|
+
if temp_target.exists():
|
|
181
|
+
temp_target.unlink()
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class MCPHostConfigBackupManager:
|
|
186
|
+
"""Manages MCP host configuration backups."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, backup_root: Optional[Path] = None):
|
|
189
|
+
"""Initialize backup manager.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
backup_root (Path, optional): Root directory for backups.
|
|
193
|
+
Defaults to ~/.hatch/mcp_host_config_backups/
|
|
194
|
+
"""
|
|
195
|
+
self.backup_root = backup_root or Path.home() / ".hatch" / "mcp_host_config_backups"
|
|
196
|
+
self.backup_root.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
self.atomic_ops = AtomicFileOperations()
|
|
198
|
+
|
|
199
|
+
def create_backup(self, config_path: Path, hostname: str) -> BackupResult:
|
|
200
|
+
"""Create timestamped backup of host configuration.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
config_path (Path): Path to original configuration file
|
|
204
|
+
hostname (str): Host identifier (claude-desktop, claude-code, vscode, cursor, lmstudio, gemini)
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
BackupResult: Operation result with backup path or error message
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
# Validate inputs
|
|
211
|
+
if not config_path.exists():
|
|
212
|
+
return BackupResult(
|
|
213
|
+
success=False,
|
|
214
|
+
error_message=f"Configuration file not found: {config_path}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Validate hostname using Pydantic
|
|
218
|
+
try:
|
|
219
|
+
BackupInfo.validate_hostname(hostname)
|
|
220
|
+
except ValueError as e:
|
|
221
|
+
return BackupResult(
|
|
222
|
+
success=False,
|
|
223
|
+
error_message=str(e)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Create host-specific backup directory
|
|
227
|
+
host_backup_dir = self.backup_root / hostname
|
|
228
|
+
host_backup_dir.mkdir(exist_ok=True)
|
|
229
|
+
|
|
230
|
+
# Generate timestamped backup filename with microseconds for uniqueness
|
|
231
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
232
|
+
backup_name = f"mcp.json.{hostname}.{timestamp}"
|
|
233
|
+
backup_path = host_backup_dir / backup_name
|
|
234
|
+
|
|
235
|
+
# Get original file size
|
|
236
|
+
original_size = config_path.stat().st_size
|
|
237
|
+
|
|
238
|
+
# Atomic copy operation
|
|
239
|
+
if not self.atomic_ops.atomic_copy(config_path, backup_path):
|
|
240
|
+
return BackupResult(
|
|
241
|
+
success=False,
|
|
242
|
+
error_message="Atomic copy operation failed"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Verify backup integrity
|
|
246
|
+
backup_size = backup_path.stat().st_size
|
|
247
|
+
if backup_size != original_size:
|
|
248
|
+
backup_path.unlink()
|
|
249
|
+
return BackupResult(
|
|
250
|
+
success=False,
|
|
251
|
+
error_message="Backup size mismatch - backup deleted"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return BackupResult(
|
|
255
|
+
success=True,
|
|
256
|
+
backup_path=backup_path,
|
|
257
|
+
original_size=original_size,
|
|
258
|
+
backup_size=backup_size
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
return BackupResult(
|
|
263
|
+
success=False,
|
|
264
|
+
error_message=f"Backup creation failed: {str(e)}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def restore_backup(self, hostname: str, backup_file: Optional[str] = None) -> bool:
|
|
268
|
+
"""Restore configuration from backup.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
hostname (str): Host identifier
|
|
272
|
+
backup_file (str, optional): Specific backup file name. Defaults to latest.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
bool: True if restoration successful, False otherwise
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
# Get backup file path
|
|
279
|
+
if backup_file:
|
|
280
|
+
backup_path = self.backup_root / hostname / backup_file
|
|
281
|
+
else:
|
|
282
|
+
backup_path = self._get_latest_backup(hostname)
|
|
283
|
+
|
|
284
|
+
if not backup_path or not backup_path.exists():
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
# Get target configuration path using host registry
|
|
288
|
+
from .host_management import MCPHostRegistry
|
|
289
|
+
from .models import MCPHostType
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
host_type = MCPHostType(hostname)
|
|
293
|
+
target_path = MCPHostRegistry.get_host_config_path(host_type)
|
|
294
|
+
|
|
295
|
+
if not target_path:
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
# Ensure target directory exists
|
|
299
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
|
|
301
|
+
# Perform atomic restore operation
|
|
302
|
+
return self.atomic_ops.atomic_copy(backup_path, target_path)
|
|
303
|
+
|
|
304
|
+
except ValueError:
|
|
305
|
+
# Invalid hostname
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
except Exception:
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
def list_backups(self, hostname: str) -> List[BackupInfo]:
|
|
312
|
+
"""List available backups for hostname.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
hostname (str): Host identifier
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
List[BackupInfo]: List of backup information objects
|
|
319
|
+
"""
|
|
320
|
+
host_backup_dir = self.backup_root / hostname
|
|
321
|
+
|
|
322
|
+
if not host_backup_dir.exists():
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
backups = []
|
|
326
|
+
|
|
327
|
+
# Search for both correct format and legacy incorrect format for backward compatibility
|
|
328
|
+
patterns = [
|
|
329
|
+
f"mcp.json.{hostname}.*", # Correct format: mcp.json.gemini.*
|
|
330
|
+
f"mcp.json.MCPHostType.{hostname.upper()}.*" # Legacy incorrect format: mcp.json.MCPHostType.GEMINI.*
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
for pattern in patterns:
|
|
334
|
+
for backup_file in host_backup_dir.glob(pattern):
|
|
335
|
+
try:
|
|
336
|
+
# Parse timestamp from filename
|
|
337
|
+
timestamp_str = backup_file.name.split('.')[-1]
|
|
338
|
+
timestamp = datetime.strptime(timestamp_str, "%Y%m%d_%H%M%S_%f")
|
|
339
|
+
|
|
340
|
+
backup_info = BackupInfo(
|
|
341
|
+
hostname=hostname,
|
|
342
|
+
timestamp=timestamp,
|
|
343
|
+
file_path=backup_file,
|
|
344
|
+
file_size=backup_file.stat().st_size,
|
|
345
|
+
original_config_path=Path("placeholder") # Will be implemented in host config phase
|
|
346
|
+
)
|
|
347
|
+
backups.append(backup_info)
|
|
348
|
+
|
|
349
|
+
except (ValueError, OSError):
|
|
350
|
+
# Skip invalid backup files
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Sort by timestamp (newest first)
|
|
354
|
+
return sorted(backups, key=lambda b: b.timestamp, reverse=True)
|
|
355
|
+
|
|
356
|
+
def clean_backups(self, hostname: str, **filters) -> int:
|
|
357
|
+
"""Clean old backups based on filters.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
hostname (str): Host identifier
|
|
361
|
+
**filters: Filter criteria (e.g., older_than_days, keep_count)
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
int: Number of backups cleaned
|
|
365
|
+
"""
|
|
366
|
+
backups = self.list_backups(hostname)
|
|
367
|
+
cleaned_count = 0
|
|
368
|
+
|
|
369
|
+
# Apply filters
|
|
370
|
+
older_than_days = filters.get('older_than_days')
|
|
371
|
+
keep_count = filters.get('keep_count')
|
|
372
|
+
|
|
373
|
+
if older_than_days:
|
|
374
|
+
for backup in backups:
|
|
375
|
+
if backup.age_days > older_than_days:
|
|
376
|
+
try:
|
|
377
|
+
backup.file_path.unlink()
|
|
378
|
+
cleaned_count += 1
|
|
379
|
+
except OSError:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
if keep_count and len(backups) > keep_count:
|
|
383
|
+
# Keep newest backups, remove oldest
|
|
384
|
+
to_remove = backups[keep_count:]
|
|
385
|
+
for backup in to_remove:
|
|
386
|
+
try:
|
|
387
|
+
backup.file_path.unlink()
|
|
388
|
+
cleaned_count += 1
|
|
389
|
+
except OSError:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
return cleaned_count
|
|
393
|
+
|
|
394
|
+
def _get_latest_backup(self, hostname: str) -> Optional[Path]:
|
|
395
|
+
"""Get path to latest backup for hostname.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
hostname (str): Host identifier
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Optional[Path]: Path to latest backup or None if no backups exist
|
|
402
|
+
"""
|
|
403
|
+
backups = self.list_backups(hostname)
|
|
404
|
+
return backups[0].file_path if backups else None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class BackupAwareOperation:
|
|
408
|
+
"""Base class for operations that require backup awareness."""
|
|
409
|
+
|
|
410
|
+
def __init__(self, backup_manager: MCPHostConfigBackupManager):
|
|
411
|
+
"""Initialize backup-aware operation.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
backup_manager (MCPHostConfigBackupManager): Backup manager instance
|
|
415
|
+
"""
|
|
416
|
+
self.backup_manager = backup_manager
|
|
417
|
+
|
|
418
|
+
def prepare_backup(self, config_path: Path, hostname: str,
|
|
419
|
+
no_backup: bool = False) -> Optional[BackupResult]:
|
|
420
|
+
"""Prepare backup before operation if required.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
config_path (Path): Path to configuration file
|
|
424
|
+
hostname (str): Host identifier
|
|
425
|
+
no_backup (bool, optional): Skip backup creation. Defaults to False.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
Optional[BackupResult]: BackupResult if backup created, None if skipped
|
|
429
|
+
|
|
430
|
+
Raises:
|
|
431
|
+
BackupError: If backup required but fails
|
|
432
|
+
"""
|
|
433
|
+
if no_backup:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
backup_result = self.backup_manager.create_backup(config_path, hostname)
|
|
437
|
+
if not backup_result.success:
|
|
438
|
+
raise BackupError(f"Required backup failed: {backup_result.error_message}")
|
|
439
|
+
|
|
440
|
+
return backup_result
|
|
441
|
+
|
|
442
|
+
def rollback_on_failure(self, backup_result: Optional[BackupResult],
|
|
443
|
+
config_path: Path, hostname: str) -> bool:
|
|
444
|
+
"""Rollback configuration on operation failure.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
backup_result (Optional[BackupResult]): Result from prepare_backup
|
|
448
|
+
config_path (Path): Path to configuration file
|
|
449
|
+
hostname (str): Host identifier
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
bool: True if rollback successful, False otherwise
|
|
453
|
+
"""
|
|
454
|
+
if backup_result and backup_result.backup_path:
|
|
455
|
+
return self.backup_manager.restore_backup(
|
|
456
|
+
hostname, backup_result.backup_path.name
|
|
457
|
+
)
|
|
458
|
+
return False
|