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

1""" 

2Backup management module for MoAI-ADK migrations 

3 

4Creates and manages backups during migration processes 

5to ensure data safety and enable rollback. 

6""" 

7 

8import json 

9import logging 

10import shutil 

11from datetime import datetime 

12from pathlib import Path 

13from typing import Dict, List, Optional 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class BackupManager: 

19 """Manages backup creation and restoration for migrations""" 

20 

21 def __init__(self, project_root: Path): 

22 """ 

23 Initialize backup manager 

24 

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) 

31 

32 def create_backup(self, description: str = "migration") -> Path: 

33 """ 

34 Create a full backup of configuration files 

35 

36 Args: 

37 description: Description of this backup 

38 

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) 

46 

47 logger.info(f"Creating backup at {backup_dir}") 

48 

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 ] 

56 

57 backed_up_files = [] 

58 

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 

64 

65 # Create parent directories 

66 backup_path.parent.mkdir(parents=True, exist_ok=True) 

67 

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}") 

72 

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 } 

80 

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) 

84 

85 logger.info(f"✅ Backup created successfully: {backup_dir}") 

86 return backup_dir 

87 

88 def list_backups(self) -> List[Dict[str, str]]: 

89 """ 

90 List all available backups 

91 

92 Returns: 

93 List of backup information dictionaries 

94 """ 

95 backups = [] 

96 

97 if not self.backup_base_dir.exists(): 

98 return backups 

99 

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}") 

119 

120 return backups 

121 

122 def restore_backup(self, backup_path: Path) -> bool: 

123 """ 

124 Restore files from a backup 

125 

126 Args: 

127 backup_path: Path to the backup directory 

128 

129 Returns: 

130 True if restore was successful, False otherwise 

131 """ 

132 backup_path = Path(backup_path) 

133 

134 if not backup_path.exists(): 

135 logger.error(f"Backup directory not found: {backup_path}") 

136 return False 

137 

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 

142 

143 try: 

144 # Read metadata 

145 with open(metadata_path, "r", encoding="utf-8") as f: 

146 metadata = json.load(f) 

147 

148 logger.info(f"Restoring backup from {backup_path}") 

149 

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 

154 

155 if backup_file.exists(): 

156 # Create parent directories 

157 target_file.parent.mkdir(parents=True, exist_ok=True) 

158 

159 # Restore file 

160 shutil.copy2(backup_file, target_file) 

161 logger.debug(f"Restored: {rel_path}") 

162 

163 logger.info("✅ Backup restored successfully") 

164 return True 

165 

166 except Exception as e: 

167 logger.error(f"Failed to restore backup: {e}") 

168 return False 

169 

170 def cleanup_old_backups(self, keep_count: int = 5) -> int: 

171 """ 

172 Clean up old backups, keeping only the most recent ones 

173 

174 Args: 

175 keep_count: Number of recent backups to keep 

176 

177 Returns: 

178 Number of backups deleted 

179 """ 

180 backups = self.list_backups() 

181 

182 if len(backups) <= keep_count: 

183 return 0 

184 

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}") 

194 

195 logger.info(f"Cleaned up {deleted_count} old backups") 

196 return deleted_count 

197 

198 def get_latest_backup(self) -> Optional[Path]: 

199 """ 

200 Get the most recent backup 

201 

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 

209 

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 

215 

216 Backs up: 

217 - .claude/ (entire directory) 

218 - .moai/ (entire directory) 

219 - CLAUDE.md (file) 

220 

221 Args: 

222 description: Description of this backup (default: "pre-update-backup") 

223 

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) 

231 

232 logger.info(f"Creating full project backup at {backup_dir}") 

233 

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 ] 

240 

241 backed_up_items = [] 

242 

243 for target_path, is_dir in backup_targets: 

244 if not target_path.exists(): 

245 continue 

246 

247 try: 

248 rel_path = target_path.relative_to(self.project_root) 

249 backup_path = backup_dir / rel_path 

250 

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}") 

262 

263 except Exception as e: 

264 logger.error(f"Failed to backup {target_path}: {e}") 

265 raise 

266 

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 } 

275 

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) 

279 

280 logger.info(f"✅ Full project backup created successfully: {backup_dir}") 

281 return backup_dir