Coverage for src / moai_adk / core / migration / backup_manager.py: 12.20%
123 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""
2Backup management module for MoAI-ADK migrations
4Creates and manages backups during migration processes
5to ensure data safety and enable rollback.
6"""
8import json
9import logging
10import shutil
11from datetime import datetime
12from pathlib import Path
13from typing import Dict, List, Optional
15logger = logging.getLogger(__name__)
18class BackupManager:
19 """Manages backup creation and restoration for migrations"""
21 def __init__(self, project_root: Path):
22 """
23 Initialize backup manager
25 Args:
26 project_root: Root directory of the project
27 """
28 self.project_root = Path(project_root)
29 self.backup_base_dir = self.project_root / ".moai" / "backups"
30 self.backup_base_dir.mkdir(parents=True, exist_ok=True)
32 def create_backup(self, description: str = "migration") -> Path:
33 """
34 Create a full backup of configuration files
36 Args:
37 description: Description of this backup
39 Returns:
40 Path to the backup directory
41 """
42 # Create timestamped backup directory
43 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
44 backup_dir = self.backup_base_dir / f"{description}_{timestamp}"
45 backup_dir.mkdir(parents=True, exist_ok=True)
47 logger.info(f"Creating backup at {backup_dir}")
49 # Files to backup
50 backup_targets = [
51 self.project_root / ".moai" / "config.json",
52 self.project_root / ".moai" / "config" / "config.json",
53 self.project_root / ".claude" / "statusline-config.yaml",
54 self.project_root / ".moai" / "config" / "statusline-config.yaml",
55 ]
57 backed_up_files = []
59 for target in backup_targets:
60 if target.exists():
61 # Preserve relative path structure in backup
62 rel_path = target.relative_to(self.project_root)
63 backup_path = backup_dir / rel_path
65 # Create parent directories
66 backup_path.parent.mkdir(parents=True, exist_ok=True)
68 # Copy file
69 shutil.copy2(target, backup_path)
70 backed_up_files.append(str(rel_path))
71 logger.debug(f"Backed up: {rel_path}")
73 # Save backup metadata
74 metadata = {
75 "timestamp": timestamp,
76 "description": description,
77 "backed_up_files": backed_up_files,
78 "project_root": str(self.project_root),
79 }
81 metadata_path = backup_dir / "backup_metadata.json"
82 with open(metadata_path, "w", encoding="utf-8") as f:
83 json.dump(metadata, f, indent=2, ensure_ascii=False)
85 logger.info(f"✅ Backup created successfully: {backup_dir}")
86 return backup_dir
88 def list_backups(self) -> List[Dict[str, str]]:
89 """
90 List all available backups
92 Returns:
93 List of backup information dictionaries
94 """
95 backups = []
97 if not self.backup_base_dir.exists():
98 return backups
100 for backup_dir in sorted(self.backup_base_dir.iterdir(), reverse=True):
101 if backup_dir.is_dir():
102 metadata_path = backup_dir / "backup_metadata.json"
103 if metadata_path.exists():
104 try:
105 with open(metadata_path, "r", encoding="utf-8") as f:
106 metadata = json.load(f)
107 backups.append(
108 {
109 "path": str(backup_dir),
110 "timestamp": metadata.get("timestamp", "unknown"),
111 "description": metadata.get(
112 "description", "unknown"
113 ),
114 "files": len(metadata.get("backed_up_files", [])),
115 }
116 )
117 except Exception as e:
118 logger.warning(f"Failed to read backup metadata: {e}")
120 return backups
122 def restore_backup(self, backup_path: Path) -> bool:
123 """
124 Restore files from a backup
126 Args:
127 backup_path: Path to the backup directory
129 Returns:
130 True if restore was successful, False otherwise
131 """
132 backup_path = Path(backup_path)
134 if not backup_path.exists():
135 logger.error(f"Backup directory not found: {backup_path}")
136 return False
138 metadata_path = backup_path / "backup_metadata.json"
139 if not metadata_path.exists():
140 logger.error(f"Backup metadata not found: {metadata_path}")
141 return False
143 try:
144 # Read metadata
145 with open(metadata_path, "r", encoding="utf-8") as f:
146 metadata = json.load(f)
148 logger.info(f"Restoring backup from {backup_path}")
150 # Restore each file
151 for rel_path in metadata.get("backed_up_files", []):
152 backup_file = backup_path / rel_path
153 target_file = self.project_root / rel_path
155 if backup_file.exists():
156 # Create parent directories
157 target_file.parent.mkdir(parents=True, exist_ok=True)
159 # Restore file
160 shutil.copy2(backup_file, target_file)
161 logger.debug(f"Restored: {rel_path}")
163 logger.info("✅ Backup restored successfully")
164 return True
166 except Exception as e:
167 logger.error(f"Failed to restore backup: {e}")
168 return False
170 def cleanup_old_backups(self, keep_count: int = 5) -> int:
171 """
172 Clean up old backups, keeping only the most recent ones
174 Args:
175 keep_count: Number of recent backups to keep
177 Returns:
178 Number of backups deleted
179 """
180 backups = self.list_backups()
182 if len(backups) <= keep_count:
183 return 0
185 deleted_count = 0
186 for backup_info in backups[keep_count:]:
187 backup_path = Path(backup_info["path"])
188 try:
189 shutil.rmtree(backup_path)
190 deleted_count += 1
191 logger.debug(f"Deleted old backup: {backup_path}")
192 except Exception as e:
193 logger.warning(f"Failed to delete backup {backup_path}: {e}")
195 logger.info(f"Cleaned up {deleted_count} old backups")
196 return deleted_count
198 def get_latest_backup(self) -> Optional[Path]:
199 """
200 Get the most recent backup
202 Returns:
203 Path to the latest backup directory, or None if no backups exist
204 """
205 backups = self.list_backups()
206 if backups:
207 return Path(backups[0]["path"])
208 return None
210 def create_full_project_backup(
211 self, description: str = "pre-update-backup"
212 ) -> Path:
213 """
214 Create a complete backup of entire project structure before update
216 Backs up:
217 - .claude/ (entire directory)
218 - .moai/ (entire directory)
219 - CLAUDE.md (file)
221 Args:
222 description: Description of this backup (default: "pre-update-backup")
224 Returns:
225 Path to the backup directory
226 """
227 # Create timestamped backup directory
228 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
229 backup_dir = self.project_root / ".moai-backups" / f"{description}_{timestamp}"
230 backup_dir.mkdir(parents=True, exist_ok=True)
232 logger.info(f"Creating full project backup at {backup_dir}")
234 # Directories and files to backup
235 backup_targets = [
236 (self.project_root / ".claude", True), # (path, is_directory)
237 (self.project_root / ".moai", True),
238 (self.project_root / "CLAUDE.md", False),
239 ]
241 backed_up_items = []
243 for target_path, is_dir in backup_targets:
244 if not target_path.exists():
245 continue
247 try:
248 rel_path = target_path.relative_to(self.project_root)
249 backup_path = backup_dir / rel_path
251 if is_dir:
252 # Backup directory
253 shutil.copytree(target_path, backup_path, dirs_exist_ok=True)
254 backed_up_items.append(str(rel_path))
255 logger.debug(f"Backed up directory: {rel_path}")
256 else:
257 # Backup file
258 backup_path.parent.mkdir(parents=True, exist_ok=True)
259 shutil.copy2(target_path, backup_path)
260 backed_up_items.append(str(rel_path))
261 logger.debug(f"Backed up file: {rel_path}")
263 except Exception as e:
264 logger.error(f"Failed to backup {target_path}: {e}")
265 raise
267 # Save backup metadata
268 metadata = {
269 "timestamp": timestamp,
270 "description": description,
271 "backed_up_items": backed_up_items,
272 "project_root": str(self.project_root),
273 "backup_type": "full_project",
274 }
276 metadata_path = backup_dir / "backup_metadata.json"
277 with open(metadata_path, "w", encoding="utf-8") as f:
278 json.dump(metadata, f, indent=2, ensure_ascii=False)
280 logger.info(f"✅ Full project backup created successfully: {backup_dir}")
281 return backup_dir